diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index 160b5b2..e7b55eb 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,3 @@ { - "flutterSdkVersion": "3.22.3", - "flavors": {} + "flutterSdkVersion": "3.22.3" } \ No newline at end of file diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..e03e940 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,4 @@ +{ + "flutter": "3.22.3", + "flavors": {} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1be2d87..8c6c143 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,8 @@ app.*.map.json /android/app/release # fvm -.fvm/flutter_sdk \ No newline at end of file + +# FVM Version Cache +.fvm/ + +.env \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f285aa4..c959187 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,9 @@ { - "dart.flutterSdkPath": ".fvm/flutter_sdk", - "search.exclude": { - "**/.fvm": true - }, - "files.watcherExclude": { - "**/.fvm": true - } + "dart.flutterSdkPath": ".fvm/versions/3.22.3", + "search.exclude": { + "**/.fvm": true + }, + "files.watcherExclude": { + "**/.fvm": true + } } \ No newline at end of file diff --git a/README.md b/README.md index 412d444..d7758c6 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,49 @@ # Restaurant Tour -Welcome to Superformula's Coding challenge, we are excited to see what you can build! +## Overview -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. +This project is a solution to the **Superformula's Coding Challenge**, designed to demonstrate skills in Flutter development and architecture. The focus of the project is on creating a scalable, well-structured, and well-tested Flutter application that allows users to explore restaurants using the Yelp GraphQL API. -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. +## Key Features -Things we'll be looking on your submission: -- App structure for scalability -- Error and optional (?) handling -- Widget tree optimization -- State management -- Test coverage +- **Restaurant Tour Page**: Displays a list of restaurants, allowing users to view details and add favorites. The app also shows restaurants by categories such as price, name, and rating. +- **Restaurant Detail Page**: Provides detailed information about the selected restaurant, including user reviews, restaurant category, and other essential details. +- **Favorites Feature**: Users can favorite businesses, with favorites being stored locally using **SharedPreferences**. -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. +## Architecture and State Management -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. +This application follows the principles of **Clean Architecture** to ensure scalability, separation of concerns, and maintainability. The following layers were implemented: +1. **Domain Layer**: Contains the business logic, including the use of usecases and entities. +2. **Data Layer**: Responsible for data fetching and caching, using the Yelp GraphQL API and storing API keys using the `flutter_dotenv` package for secure management. +3. **Presentation Layer**: Flutter widgets and state management using **flutter_bloc** for managing UI states, and reactions. +I utilized several design patterns to create a solid structure, such as: +- **Factory Pattern**: For creating complex objects and handling dependencies dynamically. +- **Adapter Pattern**: To allow integration of different API responses with internal models. +- **Strategy Pattern**: For handling various business logic strategies. + +In addition, I applied **SOLID** principles where appropriate, ensuring that classes and components are modular, extensible, and maintainable. -Be sure to read **all** of this document carefully, and follow the guidelines within. +### State Management + +The state management in this project is handled using the **flutter_bloc** library. + +### API Key Management + +The **flutter_dotenv** package is used to securely store and manage the API Key needed for communicating with the Yelp GraphQL API. The API key is stored in an environment file, which is then loaded into the application at runtime. + +## Testing + +A comprehensive test strategy was implemented to cover different parts of the application. The project includes: +- **Unit Tests**: For testing business logic and data manipulation within usecases and adapters. +- **Widget Tests**: For testing the UI components. + +## Steps to run the application -## Vendorized Flutter +## 1. Install fvm -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: +1. [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: ```sh dart pub global activate fvm @@ -35,168 +55,35 @@ Be sure to read **all** of this document carefully, and follow the guidelines wi export PATH="$PATH":"$HOME/.pub-cache/bin" # Add this to your environment variables ``` -4. Install the project's flutter version using `fvm`. +2. Install the project's flutter version using `fvm`. ```sh fvm use ``` -5. From now on, you will run all the flutter commands with the `fvm` prefix. Get all the projects dependencies. +3. From now on, you will run all the flutter commands with the `fvm` prefix. Get all the projects dependencies. ```sh fvm flutter pub get ``` -More information on the approach can be found here: - -> hhttps://fvm.app/docs/getting_started/installation - -From the root directory: - - -### IDE Setup - -
-Use with VSCode -

- -If you're a VScode user link the new Flutter SDK path in your settings -`$projectRoot/.vscode/settings.json` (create if it doesn't exist yet) - -```json -{ - "dart.flutterSdkPath": ".fvm/flutter_sdk" -} -``` - - -

-
- -
-Use with IntelliJ / Android Studio -

- -Go to `Preferences > Languages & Frameworks > Flutter` and set the Flutter SDK path to `$projectRoot/.fvm/flutter_sdk` - -IntelliJ Settings - -

-
- -## Requirements - -### App Structure - -#### Restaurant List Page - -- Tab Bar - - List of favorites (stored client side) - - List of businesses - - Hero image - - Name - - Price - - Category - - Rating (rounded to the nearest value) - - Open/Closed - -#### Restaurant Detail View - -- Ability to favorite a business -- Name -- Hero image -- Price and category -- Address -- Rating -- Total reviews -- List of reviews - - User name - - Rating - - User image - - Review Text (These are just snippets of the full review, usually like 3-4 lines long) - -#### Misc. - -- Clear documentation on the structure and architecture of your application. -- Clear and logical commit messages. - - We suggest following [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) - -## Test Coverage - -To demonstrate your experience writing different types of tests in Flutter please do the following: - -- 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). - -Feel free to add more tests as you see fit but the above is the minimum requirement. - -## Design - -- See this [Figma File](https://www.figma.com/file/KsEhQUp66m9yeVkvQ0hSZm/Flutter-Test?node-id=0%3A1) for design information related to the overall look and feel of the application. We do not expect pixel-perfection but would like the application to visually be close to what is specified in the Figma file. - -![List View](screenshots/listview.png) -![Detail View](screenshots/detailview.png) - -## API - -The [Yelp GraphQL API](https://www.yelp.com/developers/graphql/guides/intro) is used as the API for this Application. We have provided the boilerplate of the API requests and backing data models to save you some time. To successfully make a request to the Yelp GraphQL API, please follow these steps: - -1. Please go to https://www.yelp.com/signup and sign up for a developer account. -1. Once signed up, navigate to https://www.yelp.com/developers/v3/manage_app. -1. Create a new app by filling out the required information. -1. Once your app is created, scroll down and join the `Developer Beta`. This allows you to use the GraphQL API. -1. Copy your API Key from your app page and paste it on `line 5` [yelp_repository.dart](app/lib/yelp_repository.dart) replacing the `` with your key. -1. Run the app and tap the `Fetch Restaurants` button. If you see a log like `Fetched x restaurants` you are all set! - -## Technical Requirements - -### State Management - -Please restrict your usage of state management or dependency injection to the following options: - -1. [provider](https://pub.dev/packages/provider) -2. [Riverpod](https://pub.dev/packages/riverpod) -3. [bloc](https://pub.dev/packages/bloc) -4. [get_it](https://pub.dev/packages/get_it)/[get_it_mixins](https://pub.dev/packages/get_it_mixin) -5. [Mobx](https://pub.dev/packages/mobx) - -We ask this because this challenge values consistency and efficiency over ingenuity. Using commonly used libraries ensures that we can review your code in a timely manner and allows us to provide better feedback. - -## Coding Values - -At **Superformula** we strive to build applications that have - -- Consistent architecture -- Extensible, clean code -- Solid testing -- Good security & performance best practices - -### Clear, consistent architecture - -Approach your submission as if it were a real world app. This includes Use any libraries that you would normally choose. - -_Please note: we're interested in your code & the way you solve the problem, not how well you can use a particular library or feature._ - -### Easy to understand - -Writing boring code that is easy to follow is essential at **Superformula**. - -We're interested in your method and how you approach the problem just as much as we're interested in the end result. - -### Solid testing approach +### 2. Create the environment file -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. +1. Create a .env file in the root of your project with the following content: -## Q&A + ```bash + API_KEY=API_KEY + BASE_URL=BASE_URL -> Where should I send back the result when I'm done? +### 3. Run on iOS simulator -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. +1. Open the iOS simulator via terminal: -> What if I have a question? + ```bash + open -a Simulator -Just create a new issue in this repo and we will respond and get back to you quickly. +2. With the iOS simulator running, go back to the Flutter project root directory and run: -## Review + ```bash + flutter run -The coding challenge is a take-home test upon which we'll be conducting a thorough code review once complete. The review will consist of meeting some more of our mobile engineers and giving a review of the solution you have designed. Please be prepared to share your screen and run/demo the application to the group. During this process, the engineers will be asking questions. diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..d97f17e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..39c22b7 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,23 @@ +PODS: + - Flutter (1.0.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - Flutter (from `Flutter`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + +SPEC CHECKSUMS: + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + +PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 + +COCOAPODS: 1.13.0 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 182fb57..e52502d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,10 +10,12 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 5BF3A6EEB678A0BEDC7DC002 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 025F475AA2CC185B321E613A /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B5FF564FF705753D0B264C84 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 374876DA1A901122796EE1BC /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -40,10 +42,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 025F475AA2CC185B321E613A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 334A71043A1D8E2D8C6236B4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 374876DA1A901122796EE1BC /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -55,13 +60,27 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + CB89848961AA2D2EF6D7A465 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + D1F1E58588222E5ED7C3A4EE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + E6C96E8930E1BB81E2283805 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + E7BD18E113576D7009DDC15B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + EBBFC6439AB8493DBCDAE3DF /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 35FCC4A6367841382A086E2A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5BF3A6EEB678A0BEDC7DC002 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B5FF564FF705753D0B264C84 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -76,6 +95,20 @@ path = RunnerTests; sourceTree = ""; }; + 737F57C08BCA69E04DC18677 /* Pods */ = { + isa = PBXGroup; + children = ( + CB89848961AA2D2EF6D7A465 /* Pods-Runner.debug.xcconfig */, + EBBFC6439AB8493DBCDAE3DF /* Pods-Runner.release.xcconfig */, + D1F1E58588222E5ED7C3A4EE /* Pods-Runner.profile.xcconfig */, + E6C96E8930E1BB81E2283805 /* Pods-RunnerTests.debug.xcconfig */, + E7BD18E113576D7009DDC15B /* Pods-RunnerTests.release.xcconfig */, + 334A71043A1D8E2D8C6236B4 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -94,6 +127,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 737F57C08BCA69E04DC18677 /* Pods */, + D5B101D8DFED79F5616FE27C /* Frameworks */, ); sourceTree = ""; }; @@ -121,6 +156,15 @@ path = Runner; sourceTree = ""; }; + D5B101D8DFED79F5616FE27C /* Frameworks */ = { + isa = PBXGroup; + children = ( + 374876DA1A901122796EE1BC /* Pods_Runner.framework */, + 025F475AA2CC185B321E613A /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -128,8 +172,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 83D7EB3AE6CF6A51F3A0C342 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 35FCC4A6367841382A086E2A /* Frameworks */, ); buildRules = ( ); @@ -145,12 +191,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + ACC17BD020EA506076E8ECF9 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + AD620165592B4FBF3C4EE416 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -238,6 +286,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 83D7EB3AE6CF6A51F3A0C342 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,6 +323,45 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + ACC17BD020EA506076E8ECF9 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + AD620165592B4FBF3C4EE416 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -379,6 +488,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E6C96E8930E1BB81E2283805 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -396,6 +506,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E7BD18E113576D7009DDC15B /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -411,6 +522,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 334A71043A1D8E2D8C6236B4 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/lib/core/config/application_config.dart b/lib/core/config/application_config.dart new file mode 100644 index 0000000..59b78fc --- /dev/null +++ b/lib/core/config/application_config.dart @@ -0,0 +1,11 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +abstract class ApplicationConfig { + static Future setUp() async { + await _loadEnv(); + } + + static Future _loadEnv() async { + await dotenv.load(); + } +} diff --git a/lib/core/config/config.dart b/lib/core/config/config.dart new file mode 100644 index 0000000..4e9eeab --- /dev/null +++ b/lib/core/config/config.dart @@ -0,0 +1,2 @@ +export 'application_config.dart'; +export 'env.dart'; diff --git a/lib/core/config/env.dart b/lib/core/config/env.dart new file mode 100644 index 0000000..ae28a0b --- /dev/null +++ b/lib/core/config/env.dart @@ -0,0 +1,7 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +abstract class ENV { + static final env = dotenv.env; + static String apiKey = env['API_KEY'] ?? ''; + static String baseUrl = env['BASE_URL'] ?? ''; +} diff --git a/lib/core/core.dart b/lib/core/core.dart new file mode 100644 index 0000000..b8ea6ab --- /dev/null +++ b/lib/core/core.dart @@ -0,0 +1,2 @@ +export 'config/config.dart'; +export 'typography/typography.dart'; diff --git a/lib/typography.dart b/lib/core/typography/typography.dart similarity index 100% rename from lib/typography.dart rename to lib/core/typography/typography.dart diff --git a/lib/data/models/category_model.dart b/lib/data/models/category_model.dart new file mode 100644 index 0000000..b244b9f --- /dev/null +++ b/lib/data/models/category_model.dart @@ -0,0 +1,44 @@ +import '../../domain/entities/entities.dart'; +import '../../domain/helpers/helpers.dart'; + +class CategoryModel { + final String title; + final String alias; + + CategoryModel({ + required this.title, + required this.alias, + }); + + factory CategoryModel.fromEntity(CategoryEntity category) { + return CategoryModel( + title: category.title, + alias: category.alias, + ); + } + + factory CategoryModel.fromJson(Map json) { + try { + return CategoryModel( + title: json['title'] ?? '', + alias: json['alias'] ?? '', + ); + } catch (_) { + throw DomainError.unexpected; + } + } + + Map toJson() { + return { + 'title': title, + 'alias': alias, + }; + } + + CategoryEntity toEntity() { + return CategoryEntity( + title: title, + alias: alias, + ); + } +} diff --git a/lib/data/models/local_restaurant_model.dart b/lib/data/models/local_restaurant_model.dart new file mode 100644 index 0000000..6e6ddef --- /dev/null +++ b/lib/data/models/local_restaurant_model.dart @@ -0,0 +1,87 @@ +import '../../domain/entities/entities.dart'; +import '../../domain/helpers/helpers.dart'; +import 'models.dart'; + +class LocalRestaurantModel { + final String id; + final List categories; + final bool isOpen; + final String address; + final String name; + final List photos; + final String price; + final double rating; + final List reviews; + + LocalRestaurantModel({ + required this.id, + required this.categories, + required this.isOpen, + required this.address, + required this.name, + required this.photos, + required this.price, + required this.rating, + required this.reviews, + }); + + factory LocalRestaurantModel.fromJson(Map json) { + try { + return LocalRestaurantModel( + id: json['id'], + categories: (json['categories'] as List).map((item) => CategoryModel.fromJson(json)).toList(), + isOpen: json['is_open'], + address: json['address'], + name: json['name'], + photos: List.from(json['photos']), + price: json['price'], + rating: json['rating'], + reviews: (json['reviews'] as List).map(((item) => ReviewModel.fromJson(item))).toList(), + ); + } catch (_) { + throw DomainError.unexpected; + } + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'categories': categories.map((category) => category.toJson()).toList(), + 'is_open': isOpen, + 'address': address, + 'photos': photos, + 'price': price, + 'rating': rating, + 'reviews': reviews.map((review) => review.toJson()).toList(), + }; + } + + factory LocalRestaurantModel.fromEntity(RestaurantEntity entity) { + return LocalRestaurantModel( + id: entity.id, + rating: entity.rating, + categories: entity.categories.map((category) => CategoryModel.fromEntity(category)).toList(), + isOpen: entity.isOpen, + address: entity.address, + name: entity.name, + photos: entity.photos, + price: entity.price, + reviews: entity.reviews.map((review) => ReviewModel.fromEntity(review)).toList(), + ); + } + + FavoriteRestaurantEntity toEntity() { + return FavoriteRestaurantEntity( + id: id, + rating: rating, + categories: categories.map((category) => category.toEntity()).toList(), + isOpen: isOpen, + address: address, + name: name, + photos: photos, + price: price, + reviews: reviews.map((review) => review.toEntity()).toList(), + ); + } +} diff --git a/lib/data/models/models.dart b/lib/data/models/models.dart new file mode 100644 index 0000000..8208ddb --- /dev/null +++ b/lib/data/models/models.dart @@ -0,0 +1,5 @@ +export 'category_model.dart'; +export 'local_restaurant_model.dart'; +export 'remote_restaurant_model.dart'; +export 'review_model.dart'; +export 'user_model.dart'; diff --git a/lib/data/models/remote_restaurant_model.dart b/lib/data/models/remote_restaurant_model.dart new file mode 100644 index 0000000..7e761ee --- /dev/null +++ b/lib/data/models/remote_restaurant_model.dart @@ -0,0 +1,59 @@ +import '../../domain/entities/entities.dart'; +import '../../domain/helpers/helpers.dart'; +import 'models.dart'; + +class RemoteRestaurantModel { + final String id; + final List categories; + final bool isOpen; + final String address; + final String name; + final List photos; + final String price; + final double rating; + final List reviews; + + RemoteRestaurantModel({ + required this.id, + required this.categories, + required this.isOpen, + required this.address, + required this.name, + required this.photos, + required this.price, + required this.rating, + required this.reviews, + }); + + factory RemoteRestaurantModel.fromJson(Map json) { + try { + return RemoteRestaurantModel( + id: json['id'] ?? '', + categories: (json['categories'] as List).map((item) => CategoryModel.fromJson(json)).toList(), + isOpen: (json['hours'] ?? []).first['is_open_now'] ?? true, + address: json['location']['formatted_address'] ?? '', + name: json['name'] ?? '', + photos: List.from(json['photos'] ?? []), + price: json['price'] ?? '', + rating: json['rating'] ?? 5.0, + reviews: (json['reviews'] as List).map(((item) => ReviewModel.fromJson(item))).toList(), + ); + } catch (_) { + throw DomainError.unexpected; + } + } + + RestaurantEntity toEntity() { + return RestaurantEntity( + id: id, + rating: rating, + categories: categories.map((category) => category.toEntity()).toList(), + isOpen: isOpen, + address: address, + name: name, + photos: photos, + price: price, + reviews: reviews.map((review) => review.toEntity()).toList(), + ); + } +} diff --git a/lib/data/models/review_model.dart b/lib/data/models/review_model.dart new file mode 100644 index 0000000..f21d362 --- /dev/null +++ b/lib/data/models/review_model.dart @@ -0,0 +1,57 @@ +import '../../domain/entities/entities.dart'; +import '../../domain/helpers/helpers.dart'; +import 'models.dart'; + +class ReviewModel { + final String id; + final int rating; + final String text; + final UserModel user; + + ReviewModel({ + required this.id, + required this.rating, + required this.text, + required this.user, + }); + + factory ReviewModel.fromEntity(ReviewEntity entity) { + return ReviewModel( + id: entity.id, + rating: entity.rating, + text: entity.text, + user: UserModel.fromEntity(entity.user), + ); + } + + factory ReviewModel.fromJson(Map json) { + try { + return ReviewModel( + id: json['id'] ?? '', + rating: (json['rating'] ?? 5.0).round(), + text: json['text'] ?? '', + user: UserModel.fromJson(json['user']), + ); + } catch (_) { + throw DomainError.unexpected; + } + } + + Map toJson() { + return { + 'id': id, + 'rating': rating, + 'text': text, + 'user': user.toJson(), + }; + } + + ReviewEntity toEntity() { + return ReviewEntity( + id: id, + rating: rating, + text: text, + user: user.toEntity(), + ); + } +} diff --git a/lib/data/models/user_model.dart b/lib/data/models/user_model.dart new file mode 100644 index 0000000..0c86365 --- /dev/null +++ b/lib/data/models/user_model.dart @@ -0,0 +1,50 @@ +import '../../domain/entities/entities.dart'; +import '../../domain/helpers/helpers.dart'; + +class UserModel { + final String id; + final String imageUrl; + final String name; + + UserModel({ + required this.id, + required this.imageUrl, + required this.name, + }); + + factory UserModel.fromEntity(UserEntity user) { + return UserModel( + id: user.id, + imageUrl: user.imageUrl, + name: user.name, + ); + } + + factory UserModel.fromJson(Map json) { + try { + return UserModel( + id: json['id'] ?? '', + imageUrl: json['image_url'] ?? '', + name: json['name'] ?? '', + ); + } catch (_) { + throw DomainError.unexpected; + } + } + + Map toJson() { + return { + 'id': id, + 'image_url': imageUrl, + 'name': name, + }; + } + + UserEntity toEntity() { + return UserEntity( + id: id, + imageUrl: imageUrl, + name: name, + ); + } +} diff --git a/lib/data/usecases/local_get_favorite_restaurants.dart b/lib/data/usecases/local_get_favorite_restaurants.dart new file mode 100644 index 0000000..39bd254 --- /dev/null +++ b/lib/data/usecases/local_get_favorite_restaurants.dart @@ -0,0 +1,27 @@ +import 'dart:convert'; + +import '../../domain/entities/entities.dart'; +import '../../domain/helpers/helpers.dart'; +import '../../domain/usecases/usecases.dart'; +import '../../infra/infra.dart'; +import '../models/models.dart'; + +class LocalGetFavoriteRestaurants implements GetFavoriteRestaurants { + final FetchCache cache; + + LocalGetFavoriteRestaurants({ + required this.cache, + }); + + @override + Future> call() async { + try { + final result = await cache.fetch('favorite_restaurants'); + if (result == null) return []; + final jsonDecoded = jsonDecode(result); + return (jsonDecoded as List).map((article) => LocalRestaurantModel.fromJson(article).toEntity()).toList(); + } catch (_) { + throw DomainError.unexpected; + } + } +} diff --git a/lib/data/usecases/local_save_favorite_restaurants.dart b/lib/data/usecases/local_save_favorite_restaurants.dart new file mode 100644 index 0000000..670d308 --- /dev/null +++ b/lib/data/usecases/local_save_favorite_restaurants.dart @@ -0,0 +1,23 @@ +import 'dart:convert'; + +import '../../domain/entities/entities.dart'; +import '../../domain/helpers/helpers.dart'; +import '../../domain/usecases/usecases.dart'; +import '../../infra/cache/cache.dart'; +import '../models/models.dart'; + +class LocalSaveFavoriteRestaurants implements SaveFavoriteRestaurants { + final SaveCache cache; + + LocalSaveFavoriteRestaurants({required this.cache}); + + @override + Future call(List restaurants) async { + try { + final value = jsonEncode(restaurants.map((restaurant) => LocalRestaurantModel.fromEntity(restaurant).toJson()).toList()); + return await cache.save(key: 'favorite_restaurants', value: value); + } catch (_) { + throw DomainError.unexpected; + } + } +} diff --git a/lib/data/usecases/remote_get_restaurants.dart b/lib/data/usecases/remote_get_restaurants.dart new file mode 100644 index 0000000..f6306b4 --- /dev/null +++ b/lib/data/usecases/remote_get_restaurants.dart @@ -0,0 +1,61 @@ +import '../../domain/entities/entities.dart'; +import '../../domain/helpers/helpers.dart'; +import '../../domain/usecases/usecases.dart'; +import '../../infra/http/http.dart'; +import '../models/models.dart'; + +class RemoteGetRestaurants implements GetRestaurants { + final HttpClient _client; + final String _url; + + const RemoteGetRestaurants({ + required HttpClient client, + required String url, + }) : _client = client, + _url = url; + + static const query = ''' + query getRestaurants { + search(location: "Las Vegas", limit: 20, offset: 0) { + total + business { + id + name + price + rating + photos + reviews { + id + rating + text + user { + id + image_url + name + } + } + categories { + title + alias + } + hours { + is_open_now + } + location { + formatted_address + } + } + } + } + '''; + + @override + Future> call() async { + try { + final response = await _client.request(url: _url, method: 'post', data: query); + return (response?['data']['search']['business'] as List).map((item) => RemoteRestaurantModel.fromJson(item).toEntity()).toList(); + } catch (_) { + throw DomainError.unexpected; + } + } +} diff --git a/lib/data/usecases/usecases.dart b/lib/data/usecases/usecases.dart new file mode 100644 index 0000000..53ee661 --- /dev/null +++ b/lib/data/usecases/usecases.dart @@ -0,0 +1,3 @@ +export 'local_get_favorite_restaurants.dart'; +export 'local_save_favorite_restaurants.dart'; +export 'remote_get_restaurants.dart'; diff --git a/lib/domain/entities/category_entity.dart b/lib/domain/entities/category_entity.dart new file mode 100644 index 0000000..26c80ff --- /dev/null +++ b/lib/domain/entities/category_entity.dart @@ -0,0 +1,9 @@ +class CategoryEntity { + final String title; + final String alias; + + CategoryEntity({ + required this.title, + required this.alias, + }); +} diff --git a/lib/domain/entities/entities.dart b/lib/domain/entities/entities.dart new file mode 100644 index 0000000..0dbd033 --- /dev/null +++ b/lib/domain/entities/entities.dart @@ -0,0 +1,5 @@ +export 'category_entity.dart'; +export 'favorite_restaurant_entity.dart'; +export 'restaurant_entity.dart'; +export 'review_entity.dart'; +export 'user_entity.dart'; diff --git a/lib/domain/entities/favorite_restaurant_entity.dart b/lib/domain/entities/favorite_restaurant_entity.dart new file mode 100644 index 0000000..70f41b9 --- /dev/null +++ b/lib/domain/entities/favorite_restaurant_entity.dart @@ -0,0 +1,15 @@ +import 'restaurant_entity.dart'; + +class FavoriteRestaurantEntity extends RestaurantEntity { + FavoriteRestaurantEntity({ + required super.id, + required super.categories, + required super.isOpen, + required super.address, + required super.name, + required super.photos, + required super.price, + required super.rating, + required super.reviews, + }); +} diff --git a/lib/domain/entities/restaurant_entity.dart b/lib/domain/entities/restaurant_entity.dart new file mode 100644 index 0000000..d339749 --- /dev/null +++ b/lib/domain/entities/restaurant_entity.dart @@ -0,0 +1,25 @@ +import 'entities.dart'; + +class RestaurantEntity { + final String id; + final List categories; + final bool isOpen; + final String address; + final String name; + final List photos; + final String price; + final double rating; + final List reviews; + + RestaurantEntity({ + required this.id, + required this.categories, + required this.isOpen, + required this.address, + required this.name, + required this.photos, + required this.price, + required this.rating, + required this.reviews, + }); +} diff --git a/lib/domain/entities/review_entity.dart b/lib/domain/entities/review_entity.dart new file mode 100644 index 0000000..bd5bfb9 --- /dev/null +++ b/lib/domain/entities/review_entity.dart @@ -0,0 +1,15 @@ +import 'entities.dart'; + +class ReviewEntity { + final String id; + final int rating; + final String text; + final UserEntity user; + + ReviewEntity({ + required this.id, + required this.rating, + required this.text, + required this.user, + }); +} diff --git a/lib/domain/entities/user_entity.dart b/lib/domain/entities/user_entity.dart new file mode 100644 index 0000000..1817058 --- /dev/null +++ b/lib/domain/entities/user_entity.dart @@ -0,0 +1,11 @@ +class UserEntity { + final String id; + final String imageUrl; + final String name; + + UserEntity({ + required this.id, + required this.imageUrl, + required this.name, + }); +} diff --git a/lib/domain/helpers/domain_error.dart b/lib/domain/helpers/domain_error.dart new file mode 100644 index 0000000..96fea30 --- /dev/null +++ b/lib/domain/helpers/domain_error.dart @@ -0,0 +1,4 @@ +enum DomainError { + unexpected, + invalidCredentials, +} diff --git a/lib/domain/helpers/helpers.dart b/lib/domain/helpers/helpers.dart new file mode 100644 index 0000000..bde4f1b --- /dev/null +++ b/lib/domain/helpers/helpers.dart @@ -0,0 +1 @@ +export 'domain_error.dart'; diff --git a/lib/domain/usecases/get_favorite_restaurants.dart b/lib/domain/usecases/get_favorite_restaurants.dart new file mode 100644 index 0000000..20e7d64 --- /dev/null +++ b/lib/domain/usecases/get_favorite_restaurants.dart @@ -0,0 +1,5 @@ +import '../entities/entities.dart'; + +abstract class GetFavoriteRestaurants { + Future> call(); +} diff --git a/lib/domain/usecases/get_restaurants.dart b/lib/domain/usecases/get_restaurants.dart new file mode 100644 index 0000000..de8cc73 --- /dev/null +++ b/lib/domain/usecases/get_restaurants.dart @@ -0,0 +1,5 @@ +import '../entities/entities.dart'; + +abstract class GetRestaurants { + Future> call(); +} diff --git a/lib/domain/usecases/save_favorite_restaurants.dart b/lib/domain/usecases/save_favorite_restaurants.dart new file mode 100644 index 0000000..95075ac --- /dev/null +++ b/lib/domain/usecases/save_favorite_restaurants.dart @@ -0,0 +1,5 @@ +import '../entities/entities.dart'; + +abstract class SaveFavoriteRestaurants { + Future call(List restaurant); +} diff --git a/lib/domain/usecases/usecases.dart b/lib/domain/usecases/usecases.dart new file mode 100644 index 0000000..258616d --- /dev/null +++ b/lib/domain/usecases/usecases.dart @@ -0,0 +1,3 @@ +export 'get_favorite_restaurants.dart'; +export 'get_restaurants.dart'; +export 'save_favorite_restaurants.dart'; diff --git a/lib/factories/factories.dart b/lib/factories/factories.dart new file mode 100644 index 0000000..f0bedc2 --- /dev/null +++ b/lib/factories/factories.dart @@ -0,0 +1,4 @@ +export 'infra/infra.dart'; +export 'presentation/presentation.dart'; +export 'ui/ui.dart'; +export 'usecases/usecases.dart'; diff --git a/lib/factories/infra/cache/cache.dart b/lib/factories/infra/cache/cache.dart new file mode 100644 index 0000000..bdd08a9 --- /dev/null +++ b/lib/factories/infra/cache/cache.dart @@ -0,0 +1 @@ +export 'local_storage_adapter_factory.dart'; diff --git a/lib/factories/infra/cache/local_storage_adapter_factory.dart b/lib/factories/infra/cache/local_storage_adapter_factory.dart new file mode 100644 index 0000000..f8a0156 --- /dev/null +++ b/lib/factories/infra/cache/local_storage_adapter_factory.dart @@ -0,0 +1,5 @@ +import '../../../infra/infra.dart'; + +LocalStorageAdapter makeLocalStorageAdapter() { + return LocalStorageAdapter(); +} diff --git a/lib/factories/infra/http/api_url_factory.dart b/lib/factories/infra/http/api_url_factory.dart new file mode 100644 index 0000000..0d45fb5 --- /dev/null +++ b/lib/factories/infra/http/api_url_factory.dart @@ -0,0 +1,3 @@ +import '../../../core/core.dart'; + +String makeApiUrl() => ENV.baseUrl; diff --git a/lib/factories/infra/http/http.dart b/lib/factories/infra/http/http.dart new file mode 100644 index 0000000..65f549c --- /dev/null +++ b/lib/factories/infra/http/http.dart @@ -0,0 +1,2 @@ +export 'api_url_factory.dart'; +export 'http_client_factory.dart'; \ No newline at end of file diff --git a/lib/factories/infra/http/http_client_factory.dart b/lib/factories/infra/http/http_client_factory.dart new file mode 100644 index 0000000..63058ca --- /dev/null +++ b/lib/factories/infra/http/http_client_factory.dart @@ -0,0 +1,13 @@ +import 'package:http/http.dart'; + +import '../../../../infra/http/http.dart'; +import '../../../core/core.dart'; + +HttpAdapter makeHttpAdapter() { + final client = Client(); + final headers = { + 'Content-Type': 'application/graphql', + 'Authorization': 'Bearer ${ENV.apiKey}', + }; + return HttpAdapter(client: client, headers: headers); +} diff --git a/lib/factories/infra/infra.dart b/lib/factories/infra/infra.dart new file mode 100644 index 0000000..1bf41b0 --- /dev/null +++ b/lib/factories/infra/infra.dart @@ -0,0 +1,2 @@ +export 'cache/cache.dart'; +export 'http/http.dart'; diff --git a/lib/factories/presentation/presentation.dart b/lib/factories/presentation/presentation.dart new file mode 100644 index 0000000..5c7594e --- /dev/null +++ b/lib/factories/presentation/presentation.dart @@ -0,0 +1 @@ +export 'restaurant_tour_presenter_factory.dart'; diff --git a/lib/factories/presentation/restaurant_tour_presenter_factory.dart b/lib/factories/presentation/restaurant_tour_presenter_factory.dart new file mode 100644 index 0000000..17bf3a6 --- /dev/null +++ b/lib/factories/presentation/restaurant_tour_presenter_factory.dart @@ -0,0 +1,11 @@ +import '../../presentation/presentation.dart'; +import '../../ui/pages/pages.dart'; +import '../factories.dart'; + +RestaurantTourPresenter makeRestaurantTourPresenter() { + return CubitRestaurantTourPresenter( + getRestaurants: makeGetRestaurants(), + getFavoriteRestaurants: makeGetFavoriteRestaurants(), + saveFavoriteRestaurants: makeSaveFavoriteRestaurants(), + ); +} diff --git a/lib/factories/ui/restaurant_tour_page_factory.dart b/lib/factories/ui/restaurant_tour_page_factory.dart new file mode 100644 index 0000000..9172cbf --- /dev/null +++ b/lib/factories/ui/restaurant_tour_page_factory.dart @@ -0,0 +1,6 @@ +import '../../ui/pages/pages.dart'; +import '../factories.dart'; + +RestaurantTourPage makeRestaurantTourPage() { + return RestaurantTourPage(presenter: makeRestaurantTourPresenter()); +} diff --git a/lib/factories/ui/ui.dart b/lib/factories/ui/ui.dart new file mode 100644 index 0000000..1b089b9 --- /dev/null +++ b/lib/factories/ui/ui.dart @@ -0,0 +1 @@ +export 'restaurant_tour_page_factory.dart'; diff --git a/lib/factories/usecases/get_favorite_restaurants_factory.dart b/lib/factories/usecases/get_favorite_restaurants_factory.dart new file mode 100644 index 0000000..14b6664 --- /dev/null +++ b/lib/factories/usecases/get_favorite_restaurants_factory.dart @@ -0,0 +1,8 @@ +import '../../data/usecases/usecases.dart'; +import '../../domain/usecases/usecases.dart'; +import '../factories.dart'; + +GetFavoriteRestaurants makeGetFavoriteRestaurants() { + final localStorageAdapter = makeLocalStorageAdapter(); + return LocalGetFavoriteRestaurants(cache: localStorageAdapter); +} diff --git a/lib/factories/usecases/get_restaurants_factory.dart b/lib/factories/usecases/get_restaurants_factory.dart new file mode 100644 index 0000000..0c57fe7 --- /dev/null +++ b/lib/factories/usecases/get_restaurants_factory.dart @@ -0,0 +1,9 @@ +import '../../data/usecases/usecases.dart'; +import '../../domain/usecases/usecases.dart'; +import '../factories.dart'; + +GetRestaurants makeGetRestaurants() { + final client = makeHttpAdapter(); + final url = makeApiUrl(); + return RemoteGetRestaurants(client: client, url: url); +} diff --git a/lib/factories/usecases/save_favorite_restaurants_factory.dart b/lib/factories/usecases/save_favorite_restaurants_factory.dart new file mode 100644 index 0000000..7cbfae8 --- /dev/null +++ b/lib/factories/usecases/save_favorite_restaurants_factory.dart @@ -0,0 +1,8 @@ +import '../../data/usecases/usecases.dart'; +import '../../domain/usecases/usecases.dart'; +import '../factories.dart'; + +SaveFavoriteRestaurants makeSaveFavoriteRestaurants() { + final localStorageAdapter = makeLocalStorageAdapter(); + return LocalSaveFavoriteRestaurants(cache: localStorageAdapter); +} diff --git a/lib/factories/usecases/usecases.dart b/lib/factories/usecases/usecases.dart new file mode 100644 index 0000000..fa90c3c --- /dev/null +++ b/lib/factories/usecases/usecases.dart @@ -0,0 +1,3 @@ +export 'get_favorite_restaurants_factory.dart'; +export 'get_restaurants_factory.dart'; +export 'save_favorite_restaurants_factory.dart'; diff --git a/lib/infra/cache/cache.dart b/lib/infra/cache/cache.dart new file mode 100644 index 0000000..16d5638 --- /dev/null +++ b/lib/infra/cache/cache.dart @@ -0,0 +1,3 @@ +export 'fetch_cache.dart'; +export 'save_cache.dart'; +export 'local_storage_adapter.dart'; diff --git a/lib/infra/cache/fetch_cache.dart b/lib/infra/cache/fetch_cache.dart new file mode 100644 index 0000000..6d58705 --- /dev/null +++ b/lib/infra/cache/fetch_cache.dart @@ -0,0 +1,3 @@ +abstract class FetchCache { + Future fetch(String key); +} diff --git a/lib/infra/cache/local_storage_adapter.dart b/lib/infra/cache/local_storage_adapter.dart new file mode 100644 index 0000000..003210c --- /dev/null +++ b/lib/infra/cache/local_storage_adapter.dart @@ -0,0 +1,17 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import 'cache.dart'; + +class LocalStorageAdapter implements SaveCache, FetchCache { + @override + Future save({required String key, required String value}) async { + final storage = await SharedPreferences.getInstance(); + await storage.setString(key, value); + } + + @override + Future fetch(String key) async { + final storage = await SharedPreferences.getInstance(); + return storage.getString(key); + } +} diff --git a/lib/infra/cache/save_cache.dart b/lib/infra/cache/save_cache.dart new file mode 100644 index 0000000..fc79c84 --- /dev/null +++ b/lib/infra/cache/save_cache.dart @@ -0,0 +1,3 @@ +abstract class SaveCache { + Future save({required String key, required String value}); +} diff --git a/lib/infra/http/http.dart b/lib/infra/http/http.dart new file mode 100644 index 0000000..41964ea --- /dev/null +++ b/lib/infra/http/http.dart @@ -0,0 +1,3 @@ +export 'http_adapter.dart'; +export 'http_client.dart'; +export 'http_error.dart'; diff --git a/lib/infra/http/http_adapter.dart b/lib/infra/http/http_adapter.dart new file mode 100644 index 0000000..f058048 --- /dev/null +++ b/lib/infra/http/http_adapter.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; + +import 'http.dart'; + +class HttpAdapter implements HttpClient { + final Client _client; + final Map _headers; + + HttpAdapter({ + required Client client, + required Map headers, + }) : _client = client, + _headers = headers; + + @override + Future request({required String url, required String method, String? data}) async { + final uri = Uri.parse(url); + var response = Response('', 500); + + try { + if (method == 'post') { + response = await _client.post(uri, headers: _headers, body: data); + } + } catch (_) { + throw HttpError.serverError; + } + + return _handleResponse(response); + } + + Map? _handleResponse(Response response) { + if (response.statusCode == 200) { + return response.body.isEmpty ? null : jsonDecode(response.body); + } else if (response.statusCode == 400) { + throw HttpError.badRequest; + } else if (response.statusCode == 401) { + throw HttpError.unauthorized; + } else { + throw HttpError.serverError; + } + } +} diff --git a/lib/infra/http/http_client.dart b/lib/infra/http/http_client.dart new file mode 100644 index 0000000..5becda2 --- /dev/null +++ b/lib/infra/http/http_client.dart @@ -0,0 +1,7 @@ +abstract class HttpClient { + Future request({ + required String url, + required String method, + DataType data, + }); +} diff --git a/lib/infra/http/http_error.dart b/lib/infra/http/http_error.dart new file mode 100644 index 0000000..d9f29e2 --- /dev/null +++ b/lib/infra/http/http_error.dart @@ -0,0 +1,8 @@ +enum HttpError { + badRequest, + notFound, + serverError, + unauthorized, + forbbiden, + invalidData, +} diff --git a/lib/infra/infra.dart b/lib/infra/infra.dart new file mode 100644 index 0000000..1bf41b0 --- /dev/null +++ b/lib/infra/infra.dart @@ -0,0 +1,2 @@ +export 'cache/cache.dart'; +export 'http/http.dart'; diff --git a/lib/main.dart b/lib/main.dart index ae7012a..64ae30e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,87 +1,9 @@ -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'; +import 'core/core.dart'; +import 'restaurant_tour.dart'; -void main() { +void main() async { + await ApplicationConfig.setUp(); 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/models/restaurant.dart b/lib/models/restaurant.dart deleted file mode 100644 index 1c7ad2f..0000000 --- a/lib/models/restaurant.dart +++ /dev/null @@ -1,157 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'restaurant.g.dart'; - -@JsonSerializable() -class Category { - final String? alias; - final String? title; - - Category({ - this.alias, - this.title, - }); - - factory Category.fromJson(Map json) => - _$CategoryFromJson(json); - - Map toJson() => _$CategoryToJson(this); -} - -@JsonSerializable() -class Hours { - @JsonKey(name: 'is_open_now') - final bool? isOpenNow; - - const Hours({ - this.isOpenNow, - }); - - factory Hours.fromJson(Map json) => _$HoursFromJson(json); - - Map toJson() => _$HoursToJson(this); -} - -@JsonSerializable() -class User { - final String? id; - @JsonKey(name: 'image_url') - final String? imageUrl; - final String? name; - - const User({ - this.id, - this.imageUrl, - this.name, - }); - - factory User.fromJson(Map json) => _$UserFromJson(json); - - Map toJson() => _$UserToJson(this); -} - -@JsonSerializable() -class Review { - final String? id; - final int? rating; - final String? text; - final User? user; - - const Review({ - this.id, - this.rating, - this.user, - this.text, - }); - - factory Review.fromJson(Map json) => _$ReviewFromJson(json); - - Map toJson() => _$ReviewToJson(this); -} - -@JsonSerializable() -class Location { - @JsonKey(name: 'formatted_address') - final String? formattedAddress; - - Location({ - this.formattedAddress, - }); - - factory Location.fromJson(Map json) => - _$LocationFromJson(json); - - Map toJson() => _$LocationToJson(this); -} - -@JsonSerializable() -class Restaurant { - final String? id; - final String? name; - final String? price; - final double? rating; - final List? photos; - final List? categories; - final List? hours; - final List? reviews; - final Location? location; - - const Restaurant({ - this.id, - this.name, - this.price, - this.rating, - this.photos, - this.categories, - this.hours, - this.reviews, - this.location, - }); - - factory Restaurant.fromJson(Map json) => - _$RestaurantFromJson(json); - - Map toJson() => _$RestaurantToJson(this); - - /// Use the first category for the category shown to the user - String get displayCategory { - if (categories != null && categories!.isNotEmpty) { - return categories!.first.title ?? ''; - } - return ''; - } - - /// Use the first image as the image shown to the user - String get heroImage { - if (photos != null && photos!.isNotEmpty) { - return photos!.first; - } - return ''; - } - - /// This logic is probably not correct in all cases but it is ok - /// for this application - bool get isOpen { - if (hours != null && hours!.isNotEmpty) { - return hours!.first.isOpenNow ?? false; - } - return false; - } -} - -@JsonSerializable() -class RestaurantQueryResult { - final int? total; - @JsonKey(name: 'business') - final List? restaurants; - - const RestaurantQueryResult({ - this.total, - this.restaurants, - }); - - factory RestaurantQueryResult.fromJson(Map json) => - _$RestaurantQueryResultFromJson(json); - - Map toJson() => _$RestaurantQueryResultToJson(this); -} diff --git a/lib/models/restaurant.g.dart b/lib/models/restaurant.g.dart deleted file mode 100644 index 3ed33f9..0000000 --- a/lib/models/restaurant.g.dart +++ /dev/null @@ -1,109 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'restaurant.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Category _$CategoryFromJson(Map json) => Category( - alias: json['alias'] as String?, - title: json['title'] as String?, - ); - -Map _$CategoryToJson(Category instance) => { - 'alias': instance.alias, - 'title': instance.title, - }; - -Hours _$HoursFromJson(Map json) => Hours( - isOpenNow: json['is_open_now'] as bool?, - ); - -Map _$HoursToJson(Hours instance) => { - 'is_open_now': instance.isOpenNow, - }; - -User _$UserFromJson(Map json) => User( - id: json['id'] as String?, - imageUrl: json['image_url'] as String?, - name: json['name'] as String?, - ); - -Map _$UserToJson(User instance) => { - 'id': instance.id, - 'image_url': instance.imageUrl, - 'name': instance.name, - }; - -Review _$ReviewFromJson(Map json) => Review( - id: json['id'] as String?, - rating: json['rating'] as int?, - user: json['user'] == null - ? null - : User.fromJson(json['user'] as Map), - ); - -Map _$ReviewToJson(Review instance) => { - 'id': instance.id, - 'rating': instance.rating, - 'user': instance.user, - }; - -Location _$LocationFromJson(Map json) => Location( - formattedAddress: json['formatted_address'] as String?, - ); - -Map _$LocationToJson(Location instance) => { - 'formatted_address': instance.formattedAddress, - }; - -Restaurant _$RestaurantFromJson(Map json) => Restaurant( - id: json['id'] as String?, - name: json['name'] as String?, - price: json['price'] as String?, - rating: (json['rating'] as num?)?.toDouble(), - photos: - (json['photos'] as List?)?.map((e) => e as String).toList(), - categories: (json['categories'] as List?) - ?.map((e) => Category.fromJson(e as Map)) - .toList(), - hours: (json['hours'] as List?) - ?.map((e) => Hours.fromJson(e as Map)) - .toList(), - reviews: (json['reviews'] as List?) - ?.map((e) => Review.fromJson(e as Map)) - .toList(), - location: json['location'] == null - ? null - : Location.fromJson(json['location'] as Map), - ); - -Map _$RestaurantToJson(Restaurant instance) => - { - 'id': instance.id, - 'name': instance.name, - 'price': instance.price, - 'rating': instance.rating, - 'photos': instance.photos, - 'categories': instance.categories, - 'hours': instance.hours, - 'reviews': instance.reviews, - 'location': instance.location, - }; - -RestaurantQueryResult _$RestaurantQueryResultFromJson( - Map json) => - RestaurantQueryResult( - total: json['total'] as int?, - restaurants: (json['business'] as List?) - ?.map((e) => Restaurant.fromJson(e as Map)) - .toList(), - ); - -Map _$RestaurantQueryResultToJson( - RestaurantQueryResult instance) => - { - 'total': instance.total, - 'business': instance.restaurants, - }; diff --git a/lib/presentation/presentation.dart b/lib/presentation/presentation.dart new file mode 100644 index 0000000..5d18f74 --- /dev/null +++ b/lib/presentation/presentation.dart @@ -0,0 +1 @@ +export 'restaurant_tour/cubit_restaurant_tour_presenter.dart'; diff --git a/lib/presentation/restaurant_tour/cubit_restaurant_tour_presenter.dart b/lib/presentation/restaurant_tour/cubit_restaurant_tour_presenter.dart new file mode 100644 index 0000000..32ffc79 --- /dev/null +++ b/lib/presentation/restaurant_tour/cubit_restaurant_tour_presenter.dart @@ -0,0 +1,154 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../domain/entities/entities.dart'; +import '../../domain/usecases/usecases.dart'; +import '../../ui/pages/pages.dart'; + +part 'states.dart'; + +class CubitRestaurantTourPresenter extends Cubit implements RestaurantTourPresenter { + final GetRestaurants _getRestaurants; + final GetFavoriteRestaurants _getFavoriteRestaurants; + final SaveFavoriteRestaurants _saveFavoriteRestaurants; + + CubitRestaurantTourPresenter({ + required GetRestaurants getRestaurants, + required GetFavoriteRestaurants getFavoriteRestaurants, + required SaveFavoriteRestaurants saveFavoriteRestaurants, + }) : _getRestaurants = getRestaurants, + _getFavoriteRestaurants = getFavoriteRestaurants, + _saveFavoriteRestaurants = saveFavoriteRestaurants, + super(RestaurantInitialState()) { + _loadData(); + } + + Future _loadData() async { + await getFavoriteRestaurants(); + await getAllRestaurants(); + _matchFavoriteRestaurants(); + } + + void _matchFavoriteRestaurants() { + for (var favoriteRestaurant in _favoriteRestaurantList) { + final index = _restaurantList.indexWhere((restaurant) => restaurant.id == favoriteRestaurant.id); + if (index != -1) _restaurantList[index] = favoriteRestaurant; + } + } + + void _updateRestaurantAsFavorite( + RestaurantEntity restaurant, + FavoriteRestaurantEntity favoriteRestaurant, + ) { + final index = _restaurantList.indexOf(restaurant); + _restaurantList[index] = favoriteRestaurant; + _favoriteRestaurantList.add(favoriteRestaurant); + } + + void _removeFavoriteRestaurant( + RestaurantEntity restaurant, + FavoriteRestaurantEntity favoriteRestaurant, + ) { + final index = _restaurantList.indexOf(favoriteRestaurant); + if (index != -1) _restaurantList[index] = restaurant; + _favoriteRestaurantList.remove(favoriteRestaurant); + } + + RestaurantEntity _makeRestaurantFromFavoriteRestaurant( + FavoriteRestaurantEntity favoriteRestaurant, + ) { + return RestaurantEntity( + id: favoriteRestaurant.id, + categories: favoriteRestaurant.categories, + isOpen: favoriteRestaurant.isOpen, + address: favoriteRestaurant.address, + name: favoriteRestaurant.name, + photos: favoriteRestaurant.photos, + price: favoriteRestaurant.price, + rating: favoriteRestaurant.rating, + reviews: favoriteRestaurant.reviews, + ); + } + + FavoriteRestaurantEntity _makeFavoriteRestaurantFromRestaurant( + RestaurantEntity restaurant, + ) { + return FavoriteRestaurantEntity( + id: restaurant.id, + categories: restaurant.categories, + isOpen: restaurant.isOpen, + address: restaurant.address, + name: restaurant.name, + photos: restaurant.photos, + price: restaurant.price, + rating: restaurant.rating, + reviews: restaurant.reviews, + ); + } + + void _setLoading() { + emit(RestaurantLoadingState()); + } + + void _setSuccess() { + emit(RestaurantSuccessState()); + } + + void _setError(String message) { + emit(RestaurantErrorState(message)); + } + + final _restaurantList = []; + final _favoriteRestaurantList = []; + + @override + List get restaurantList => _restaurantList; + + @override + List get favoriteRestaurantList => _favoriteRestaurantList; + + @override + Future getFavoriteRestaurants() async { + try { + _setLoading(); + _favoriteRestaurantList.addAll(await _getFavoriteRestaurants()); + _setSuccess(); + } catch (_) { + _setError('Error loading favorite restaurants. Please try again later.'); + } + } + + @override + Future getAllRestaurants() async { + try { + _setLoading(); + _restaurantList.addAll(await _getRestaurants()); + _setSuccess(); + } catch (_) { + _setError('Oops! We had trouble finding the restaurants.'); + } + } + + @override + Future addFavoriteRestaurant(RestaurantEntity restaurant) async { + try { + final favoriteRestaurant = _makeFavoriteRestaurantFromRestaurant(restaurant); + _updateRestaurantAsFavorite(restaurant, favoriteRestaurant); + await _saveFavoriteRestaurants(_favoriteRestaurantList); + } catch (_) { + _setError('Error favoriting restaurant. Try again later.'); + } + } + + @override + Future removeFavoriteRestaurant(FavoriteRestaurantEntity favoriteRestaurant) async { + try { + final restaurant = _makeRestaurantFromFavoriteRestaurant(favoriteRestaurant); + _removeFavoriteRestaurant(restaurant, favoriteRestaurant); + await _saveFavoriteRestaurants(_favoriteRestaurantList); + _setSuccess(); + } catch (_) { + _setError('Oops! There was an issue removing the restaurant from your favorites.'); + } + } +} diff --git a/lib/presentation/restaurant_tour/states.dart b/lib/presentation/restaurant_tour/states.dart new file mode 100644 index 0000000..c386783 --- /dev/null +++ b/lib/presentation/restaurant_tour/states.dart @@ -0,0 +1,18 @@ +part of 'cubit_restaurant_tour_presenter.dart'; + +@immutable +class RestaurantState {} + +class RestaurantInitialState extends RestaurantState {} + +class RestaurantLoadingState extends RestaurantState {} + +class RestaurantSuccessState extends RestaurantState {} + +class RestaurantErrorState extends RestaurantState { + final String message; + + RestaurantErrorState(this.message); +} + +class RestaurantTabState extends RestaurantState {} diff --git a/lib/query.dart b/lib/query.dart deleted file mode 100644 index 7a8993b..0000000 --- a/lib/query.dart +++ /dev/null @@ -1,34 +0,0 @@ -String query(int offset) => ''' - query getRestaurants { - search(location: "Las Vegas", limit: 20, offset: $offset) { - total - business { - id - name - price - rating - photos - reviews { - id - rating - text - user { - id - image_url - name - } - } - categories { - title - alias - } - hours { - is_open_now - } - location { - formatted_address - } - } - } - } - '''; diff --git a/lib/restaurant_tour.dart b/lib/restaurant_tour.dart new file mode 100644 index 0000000..e617038 --- /dev/null +++ b/lib/restaurant_tour.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +import 'factories/factories.dart'; + +class RestaurantTour extends StatelessWidget { + const RestaurantTour({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Restaurant Tour', + home: makeRestaurantTourPage(), + theme: ThemeData.from(colorScheme: ColorScheme.fromSwatch(backgroundColor: const Color(0xFFFAFAFA))), + ); + } +} diff --git a/lib/ui/pages/pages.dart b/lib/ui/pages/pages.dart new file mode 100644 index 0000000..0c255b1 --- /dev/null +++ b/lib/ui/pages/pages.dart @@ -0,0 +1,2 @@ +export 'restaurant_detail/restaurant_detail.dart'; +export 'restaurant_tour/restaurant_tour.dart'; diff --git a/lib/ui/pages/restaurant_detail/restaurant_detail.dart b/lib/ui/pages/restaurant_detail/restaurant_detail.dart new file mode 100644 index 0000000..ae8cfbd --- /dev/null +++ b/lib/ui/pages/restaurant_detail/restaurant_detail.dart @@ -0,0 +1 @@ +export 'restaurant_detail_page.dart'; diff --git a/lib/ui/pages/restaurant_detail/restaurant_detail_page.dart b/lib/ui/pages/restaurant_detail/restaurant_detail_page.dart new file mode 100644 index 0000000..644bdbb --- /dev/null +++ b/lib/ui/pages/restaurant_detail/restaurant_detail_page.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +import '../../../core/core.dart'; +import '../../../domain/entities/entities.dart'; +import '../../widgets/widgets.dart'; + +part 'widgets/arrow_back_icon.dart'; +part 'widgets/divider.dart'; +part 'widgets/image.dart'; +part 'widgets/favorite_icon.dart'; +part 'widgets/rating.dart'; +part 'widgets/review_item.dart'; +part 'widgets/review_list.dart'; + +class RestaurantDetailPage extends StatelessWidget { + final RestaurantEntity _restaurant; + final VoidCallback _onFavorite; + + const RestaurantDetailPage({ + super.key, + required RestaurantEntity restaurant, + required VoidCallback onFavorite, + }) : _restaurant = restaurant, + _onFavorite = onFavorite; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + forceMaterialTransparency: true, + leading: const _ArrowBackIcon(), + actions: [ + _FavoriteIcon( + isFavorite: _restaurant is FavoriteRestaurantEntity, + onFavorite: _onFavorite, + ), + ], + title: Text(_restaurant.name, style: AppTextStyles.loraRegularHeadline), + ), + body: ListView( + children: [ + _Image(photos: _restaurant.photos), + Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(_restaurant.price, style: AppTextStyles.openRegularText), + Status(_restaurant.isOpen), + ], + ), + const _Divider(), + const Text('Address', style: AppTextStyles.openRegularText), + const SizedBox(height: 24), + Text(_restaurant.address, style: AppTextStyles.openRegularTitleSemiBold), + const _Divider(), + const Text('Overall Rating', style: AppTextStyles.openRegularText), + const SizedBox(height: 16), + _Rating(_restaurant.rating.toString()), + const _Divider(), + _ReviewList(_restaurant.reviews), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/pages/restaurant_detail/widgets/arrow_back_icon.dart b/lib/ui/pages/restaurant_detail/widgets/arrow_back_icon.dart new file mode 100644 index 0000000..0d4e897 --- /dev/null +++ b/lib/ui/pages/restaurant_detail/widgets/arrow_back_icon.dart @@ -0,0 +1,13 @@ +part of '../restaurant_detail_page.dart'; + +class _ArrowBackIcon extends StatelessWidget { + const _ArrowBackIcon(); + + @override + Widget build(BuildContext context) { + return GestureDetector( + child: const Icon(Icons.arrow_back), + onTap: () => Navigator.pop(context), + ); + } +} diff --git a/lib/ui/pages/restaurant_detail/widgets/divider.dart b/lib/ui/pages/restaurant_detail/widgets/divider.dart new file mode 100644 index 0000000..834ec30 --- /dev/null +++ b/lib/ui/pages/restaurant_detail/widgets/divider.dart @@ -0,0 +1,15 @@ +part of '../restaurant_detail_page.dart'; + +class _Divider extends StatelessWidget { + final double verticalPadding; + + const _Divider({this.verticalPadding = 24}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(vertical: verticalPadding), + child: const Divider(color: Color(0xFFEEEEEE)), + ); + } +} diff --git a/lib/ui/pages/restaurant_detail/widgets/favorite_icon.dart b/lib/ui/pages/restaurant_detail/widgets/favorite_icon.dart new file mode 100644 index 0000000..b40893e --- /dev/null +++ b/lib/ui/pages/restaurant_detail/widgets/favorite_icon.dart @@ -0,0 +1,41 @@ +part of '../restaurant_detail_page.dart'; + +class _FavoriteIcon extends StatefulWidget { + final bool isFavorite; + final VoidCallback _onFavorite; + + const _FavoriteIcon({ + this.isFavorite = false, + required VoidCallback onFavorite, + }) : _onFavorite = onFavorite; + + @override + State<_FavoriteIcon> createState() => _FavoriteIconState(); +} + +class _FavoriteIconState extends State<_FavoriteIcon> { + late bool _isFavorite; + + @override + void initState() { + _isFavorite = widget.isFavorite; + super.initState(); + } + + void _onTap() { + widget._onFavorite(); + _isFavorite = !_isFavorite; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 16), + child: GestureDetector( + onTap: _onTap, + child: Icon(_isFavorite ? Icons.favorite : Icons.favorite_border), + ), + ); + } +} diff --git a/lib/ui/pages/restaurant_detail/widgets/image.dart b/lib/ui/pages/restaurant_detail/widgets/image.dart new file mode 100644 index 0000000..0a9f0f2 --- /dev/null +++ b/lib/ui/pages/restaurant_detail/widgets/image.dart @@ -0,0 +1,18 @@ +part of '../restaurant_detail_page.dart'; + +class _Image extends StatelessWidget { + final List _photos; + + const _Image({ + required List photos, + }) : _photos = photos; + + @override + Widget build(BuildContext context) { + return Container( + height: 360, + color: Colors.black12, + child: _photos.isEmpty ? const NoImage() : Image.network(_photos.first, fit: BoxFit.cover), + ); + } +} diff --git a/lib/ui/pages/restaurant_detail/widgets/rating.dart b/lib/ui/pages/restaurant_detail/widgets/rating.dart new file mode 100644 index 0000000..0fce9ef --- /dev/null +++ b/lib/ui/pages/restaurant_detail/widgets/rating.dart @@ -0,0 +1,24 @@ +part of '../restaurant_detail_page.dart'; + +class _Rating extends StatelessWidget { + final String _rating; + + const _Rating(this._rating); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _rating, + style: AppTextStyles.loraRegularHeadline.copyWith(fontSize: 28), + ), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: StarIcon(), + ), + ], + ); + } +} diff --git a/lib/ui/pages/restaurant_detail/widgets/review_item.dart b/lib/ui/pages/restaurant_detail/widgets/review_item.dart new file mode 100644 index 0000000..da55d8e --- /dev/null +++ b/lib/ui/pages/restaurant_detail/widgets/review_item.dart @@ -0,0 +1,40 @@ +part of '../restaurant_detail_page.dart'; + +class _ReviewItem extends StatelessWidget { + final ReviewEntity _review; + + const _ReviewItem(this._review); + + @override + Widget build(BuildContext context) { + final userImage = _review.user.imageUrl; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stars(_review.rating), + const SizedBox(height: 8), + Text( + _review.text, + style: AppTextStyles.openRegularHeadline, + ), + const SizedBox(height: 8), + Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(32), + child: Container( + height: 40, + width: 40, + decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.black12), + child: userImage.isEmpty ? const Icon(Icons.person) : Image.network(_review.user.imageUrl, fit: BoxFit.fill), + ), + ), + const SizedBox(width: 8), + Text(_review.user.name, style: AppTextStyles.openRegularText), + ], + ), + const _Divider(verticalPadding: 16), + ], + ); + } +} diff --git a/lib/ui/pages/restaurant_detail/widgets/review_list.dart b/lib/ui/pages/restaurant_detail/widgets/review_list.dart new file mode 100644 index 0000000..2a178a1 --- /dev/null +++ b/lib/ui/pages/restaurant_detail/widgets/review_list.dart @@ -0,0 +1,27 @@ +part of '../restaurant_detail_page.dart'; + +class _ReviewList extends StatelessWidget { + final List _reviews; + + const _ReviewList(this._reviews); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${_reviews.length} reviews', style: AppTextStyles.openRegularText), + const SizedBox(height: 16), + ListView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: _reviews.length, + itemBuilder: (context, index) { + final review = _reviews[index]; + return _ReviewItem(review); + }, + ), + ], + ); + } +} diff --git a/lib/ui/pages/restaurant_tour/restaurant_tour.dart b/lib/ui/pages/restaurant_tour/restaurant_tour.dart new file mode 100644 index 0000000..bbffbb0 --- /dev/null +++ b/lib/ui/pages/restaurant_tour/restaurant_tour.dart @@ -0,0 +1,2 @@ +export 'restaurant_tour_page.dart'; +export 'restaurant_tour_presenter.dart'; diff --git a/lib/ui/pages/restaurant_tour/restaurant_tour_page.dart b/lib/ui/pages/restaurant_tour/restaurant_tour_page.dart new file mode 100644 index 0000000..4cebd65 --- /dev/null +++ b/lib/ui/pages/restaurant_tour/restaurant_tour_page.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../core/core.dart'; +import '../../../domain/entities/entities.dart'; +import '../../../presentation/presentation.dart'; +import '../../widgets/widgets.dart'; +import '../pages.dart'; + +part 'widgets/app_bar.dart'; +part 'widgets/message_content.dart'; +part 'widgets/image.dart'; +part 'widgets/favorite_restaurant_list.dart'; +part 'widgets/restaurant_item.dart'; +part 'widgets/restaurant_list.dart'; +part 'widgets/snack_bar.dart'; + +class RestaurantTourPage extends StatefulWidget { + final RestaurantTourPresenter _presenter; + + const RestaurantTourPage({ + super.key, + required RestaurantTourPresenter presenter, + }) : _presenter = presenter; + + @override + State createState() => _RestaurantTourPageState(); +} + +class _RestaurantTourPageState extends State with SingleTickerProviderStateMixin { + late final CubitRestaurantTourPresenter _bloc; + late final TabController _tabController; + + void _setUp() { + _bloc = widget._presenter as CubitRestaurantTourPresenter; + _tabController = TabController(length: 2, vsync: this); + } + + void _dispose() { + _tabController.dispose(); + } + + @override + void initState() { + super.initState(); + _setUp(); + } + + @override + void dispose() { + super.dispose(); + _dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => _bloc, + child: DefaultTabController( + length: _tabController.length, + child: Scaffold( + appBar: _AppBar(_tabController), + body: BlocConsumer( + listener: (context, state) { + if (state is RestaurantErrorState) showSnackBar(context, state: state); + }, + bloc: _bloc, + builder: (context, state) { + if (state is RestaurantLoadingState) { + return const Center(child: CircularLoading()); + } + + return TabBarView( + controller: _tabController, + physics: const NeverScrollableScrollPhysics(), + children: const [ + _RestaurantList(), + _FavoriteRestaurantList(), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/ui/pages/restaurant_tour/restaurant_tour_presenter.dart b/lib/ui/pages/restaurant_tour/restaurant_tour_presenter.dart new file mode 100644 index 0000000..f82cf37 --- /dev/null +++ b/lib/ui/pages/restaurant_tour/restaurant_tour_presenter.dart @@ -0,0 +1,10 @@ +import '../../../domain/entities/entities.dart'; + +abstract class RestaurantTourPresenter { + List get restaurantList; + Future getAllRestaurants(); + List get favoriteRestaurantList; + Future getFavoriteRestaurants(); + Future addFavoriteRestaurant(RestaurantEntity restaurant); + Future removeFavoriteRestaurant(FavoriteRestaurantEntity favoriteRestaurant); +} diff --git a/lib/ui/pages/restaurant_tour/widgets/app_bar.dart b/lib/ui/pages/restaurant_tour/widgets/app_bar.dart new file mode 100644 index 0000000..d81d357 --- /dev/null +++ b/lib/ui/pages/restaurant_tour/widgets/app_bar.dart @@ -0,0 +1,59 @@ +part of '../restaurant_tour_page.dart'; + +class _AppBar extends StatefulWidget implements PreferredSizeWidget { + final TabController _tabController; + + const _AppBar(this._tabController) : preferredSize = const Size.fromHeight(kToolbarHeight + 32); + + @override + final Size preferredSize; + + @override + _AppBarState createState() => _AppBarState(); +} + +class _AppBarState extends State<_AppBar> { + void _onTabBarTapped(int index) { + widget._tabController.animateTo(index); + } + + @override + Widget build(BuildContext context) { + return AppBar( + title: const Text('RestauranTour', style: AppTextStyles.loraRegularHeadline), + centerTitle: true, + elevation: 0, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(56), + child: Material( + elevation: 3, + color: Colors.white, + shadowColor: Colors.black.withOpacity(0.4), + child: TabBar( + controller: widget._tabController, + onTap: _onTabBarTapped, + tabAlignment: TabAlignment.center, + splashFactory: NoSplash.splashFactory, + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + indicatorColor: Colors.black, + labelStyle: AppTextStyles.openRegularTitleSemiBold, + isScrollable: true, + unselectedLabelStyle: AppTextStyles.openRegularTitleSemiBold.copyWith(color: const Color(0xFF606060)), + indicator: const UnderlineTabIndicator( + borderSide: BorderSide( + width: 2.0, + color: Colors.black, + ), + insets: EdgeInsets.symmetric(horizontal: 0), + ), + labelPadding: const EdgeInsets.symmetric(horizontal: 12), + tabs: const [ + Tab(text: 'All Restaurants'), + Tab(text: 'My Favorites'), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/pages/restaurant_tour/widgets/favorite_restaurant_list.dart b/lib/ui/pages/restaurant_tour/widgets/favorite_restaurant_list.dart new file mode 100644 index 0000000..a2467f2 --- /dev/null +++ b/lib/ui/pages/restaurant_tour/widgets/favorite_restaurant_list.dart @@ -0,0 +1,32 @@ +part of '../restaurant_tour_page.dart'; + +const favoriteRestaurantListKey = Key('_FavoriteRestaurantList'); + +class _FavoriteRestaurantList extends StatelessWidget { + const _FavoriteRestaurantList(); + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + return BlocBuilder( + bloc: cubit, + builder: (context, state) { + final favoriteRestaurantList = cubit.favoriteRestaurantList; + if (favoriteRestaurantList.isEmpty) { + return const _MessageContent('No favorites.'); + } else { + return ListView.separated( + key: favoriteRestaurantListKey, + itemCount: favoriteRestaurantList.length, + padding: const EdgeInsets.all(16), + itemBuilder: (context, index) { + final restaurant = favoriteRestaurantList[index]; + return _RestaurantItem(restaurant); + }, + separatorBuilder: (_, __) => const SizedBox(height: 12), + ); + } + }, + ); + } +} diff --git a/lib/ui/pages/restaurant_tour/widgets/image.dart b/lib/ui/pages/restaurant_tour/widgets/image.dart new file mode 100644 index 0000000..877ad01 --- /dev/null +++ b/lib/ui/pages/restaurant_tour/widgets/image.dart @@ -0,0 +1,20 @@ +part of '../restaurant_tour_page.dart'; + +class _Image extends StatelessWidget { + final String _url; + + const _Image(this._url); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + height: 88, + width: 88, + decoration: BoxDecoration(color: Colors.black12, borderRadius: BorderRadius.circular(8)), + child: _url.isEmpty ? const NoImage(style: AppTextStyles.openRegularText) : Image.network(_url, fit: BoxFit.cover), + ), + ); + } +} diff --git a/lib/ui/pages/restaurant_tour/widgets/message_content.dart b/lib/ui/pages/restaurant_tour/widgets/message_content.dart new file mode 100644 index 0000000..eb6c3cf --- /dev/null +++ b/lib/ui/pages/restaurant_tour/widgets/message_content.dart @@ -0,0 +1,20 @@ +part of '../restaurant_tour_page.dart'; + +class _MessageContent extends StatelessWidget { + final String message; + + const _MessageContent( + this.message, + ); + + @override + Widget build(BuildContext context) { + return Center( + child: Text( + message, + style: AppTextStyles.openRegularHeadline, + textAlign: TextAlign.center, + ), + ); + } +} diff --git a/lib/ui/pages/restaurant_tour/widgets/restaurant_item.dart b/lib/ui/pages/restaurant_tour/widgets/restaurant_item.dart new file mode 100644 index 0000000..8d8a19f --- /dev/null +++ b/lib/ui/pages/restaurant_tour/widgets/restaurant_item.dart @@ -0,0 +1,77 @@ +part of '../restaurant_tour_page.dart'; + +Key restaurantItemKey(String id) => Key('_RestaurantItem::$id'); + +class _RestaurantItem extends StatelessWidget { + final RestaurantEntity _restaurant; + + const _RestaurantItem(this._restaurant); + + void _onItemTapped(BuildContext context) { + void onFavorite() { + final cubit = context.read(); + if (_restaurant is FavoriteRestaurantEntity) { + cubit.removeFavoriteRestaurant(_restaurant as FavoriteRestaurantEntity); + } else { + cubit.addFavoriteRestaurant(_restaurant); + } + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => RestaurantDetailPage( + restaurant: _restaurant, + onFavorite: onFavorite, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: restaurantItemKey(_restaurant.id), + onTap: () => _onItemTapped(context), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: const [ + BoxShadow( + color: Color(0x1F000000), + offset: Offset(0, 1), + blurRadius: 5, + spreadRadius: 0, + ), + ], + ), + child: Row( + children: [ + _Image(_restaurant.photos.isEmpty ? '' : _restaurant.photos.first), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_restaurant.name, style: AppTextStyles.loraRegularTitle), + const SizedBox(height: 4), + Text(_restaurant.price, style: AppTextStyles.openRegularText), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Stars(_restaurant.rating.round()), + Status(_restaurant.isOpen), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/pages/restaurant_tour/widgets/restaurant_list.dart b/lib/ui/pages/restaurant_tour/widgets/restaurant_list.dart new file mode 100644 index 0000000..3ca53b3 --- /dev/null +++ b/lib/ui/pages/restaurant_tour/widgets/restaurant_list.dart @@ -0,0 +1,26 @@ +part of '../restaurant_tour_page.dart'; + +const restaurantListKey = Key('_RestaurantList'); + +class _RestaurantList extends StatelessWidget { + const _RestaurantList(); + + @override + Widget build(BuildContext context) { + final restaurantList = context.read().restaurantList; + if (restaurantList.isEmpty) { + return const _MessageContent('No restaurants.'); + } else { + return ListView.separated( + key: restaurantListKey, + padding: const EdgeInsets.all(16), + itemCount: restaurantList.length, + itemBuilder: (context, index) { + final restaurant = restaurantList[index]; + return _RestaurantItem(restaurant); + }, + separatorBuilder: (_, __) => const SizedBox(height: 12), + ); + } + } +} diff --git a/lib/ui/pages/restaurant_tour/widgets/snack_bar.dart b/lib/ui/pages/restaurant_tour/widgets/snack_bar.dart new file mode 100644 index 0000000..8c0a37e --- /dev/null +++ b/lib/ui/pages/restaurant_tour/widgets/snack_bar.dart @@ -0,0 +1,17 @@ +part of '../restaurant_tour_page.dart'; + +void showSnackBar(BuildContext context, {required RestaurantErrorState state}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + elevation: 0, + backgroundColor: Colors.red, + duration: const Duration(milliseconds: 2000), + content: Text( + state.message, + style: AppTextStyles.openRegularHeadline.copyWith( + color: Colors.white, + ), + ), + ), + ); +} diff --git a/lib/ui/widgets/circular_loading.dart b/lib/ui/widgets/circular_loading.dart new file mode 100644 index 0000000..e06b13e --- /dev/null +++ b/lib/ui/widgets/circular_loading.dart @@ -0,0 +1,73 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class CircularLoading extends StatefulWidget { + const CircularLoading({super.key}); + + @override + CircularLoadingState createState() => CircularLoadingState(); +} + +class CircularLoadingState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return CustomPaint( + painter: _LoadingPainter(_controller.value), + child: const SizedBox( + width: 36, + height: 36, + ), + ); + }, + ); + } +} + +class _LoadingPainter extends CustomPainter { + final double progress; + + _LoadingPainter(this.progress); + + @override + void paint(Canvas canvas, Size size) { + final Paint paint = Paint() + ..color = Colors.black + ..strokeWidth = 4.0 + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + + const double startAngle = -pi / 2; + const double sweepAngle = 2 * pi * 0.75; + final Rect rect = Rect.fromLTWH(0, 0, size.width, size.height); + + final double rotateAngle = 2 * pi * progress; + + canvas.drawArc(rect, startAngle + rotateAngle, sweepAngle, false, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return true; + } +} diff --git a/lib/ui/widgets/no_image.dart b/lib/ui/widgets/no_image.dart new file mode 100644 index 0000000..30a6a21 --- /dev/null +++ b/lib/ui/widgets/no_image.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +import '../../core/core.dart'; + +class NoImage extends StatelessWidget { + final TextStyle? style; + + const NoImage({super.key, this.style}); + + @override + Widget build(BuildContext context) { + return Center( + child: Text( + 'No image', + style: style ?? AppTextStyles.openRegularHeadline, + ), + ); + } +} diff --git a/lib/ui/widgets/star_icon.dart b/lib/ui/widgets/star_icon.dart new file mode 100644 index 0000000..9fa5fb3 --- /dev/null +++ b/lib/ui/widgets/star_icon.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class StarIcon extends StatelessWidget { + const StarIcon({super.key}); + + @override + Widget build(BuildContext context) { + return const Icon(Icons.star, color: Color(0xFFFFB800), size: 12); + } +} diff --git a/lib/ui/widgets/stars.dart b/lib/ui/widgets/stars.dart new file mode 100644 index 0000000..b88c71a --- /dev/null +++ b/lib/ui/widgets/stars.dart @@ -0,0 +1,18 @@ +import 'package:flutter/widgets.dart'; + +import 'widgets.dart'; + +class Stars extends StatelessWidget { + final int _quantity; + + const Stars(this._quantity, {super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + for (int i = 0; i < _quantity; i++) const StarIcon(), + ], + ); + } +} diff --git a/lib/ui/widgets/status.dart b/lib/ui/widgets/status.dart new file mode 100644 index 0000000..b1c2b3a --- /dev/null +++ b/lib/ui/widgets/status.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +import '../../core/core.dart'; + +class Status extends StatelessWidget { + final bool _isOpen; + + const Status(this._isOpen, {super.key}); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(_isOpen ? 'Open now' : 'Closed', style: AppTextStyles.openRegularItalic), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(top: 2), + child: SizedBox( + height: 8, + width: 8, + child: CircleAvatar(backgroundColor: _isOpen ? const Color(0XFF5CD313) : const Color(0XFFEA5E5E)), + ), + ), + ], + ); + } +} diff --git a/lib/ui/widgets/widgets.dart b/lib/ui/widgets/widgets.dart new file mode 100644 index 0000000..2391fa6 --- /dev/null +++ b/lib/ui/widgets/widgets.dart @@ -0,0 +1,5 @@ +export 'circular_loading.dart'; +export 'no_image.dart'; +export 'star_icon.dart'; +export 'stars.dart'; +export 'status.dart'; diff --git a/pubspec.lock b/pubspec.lock index f95a63e..15ff885 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" url: "https://pub.dev" source: hosted - version: "61.0.0" + version: "67.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "6.4.1" 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,78 +33,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - build: - dependency: transitive - description: - name: build - sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - build_config: + bloc: dependency: transitive description: - name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + name: bloc + sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" url: "https://pub.dev" source: hosted - version: "1.1.1" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" - url: "https://pub.dev" - source: hosted - version: "4.0.0" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: "6c4dd11d05d056e76320b828a1db0fc01ccd376922526f8e9d6c796a5adbac20" - url: "https://pub.dev" - source: hosted - version: "2.2.1" - build_runner: + version: "8.1.4" + bloc_test: dependency: "direct dev" description: - name: build_runner - sha256: "644dc98a0f179b872f612d3eb627924b578897c629788e858157fa5e704ca0c7" + name: bloc_test + sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2" url: "https://pub.dev" source: hosted - version: "2.4.11" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: f4d6244cc071ba842c296cb1c4ee1b31596b9f924300647ac7a1445493471a3f - url: "https://pub.dev" - source: hosted - version: "7.2.3" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: + version: "9.1.7" + boolean_selector: dependency: transitive description: - name: built_value - sha256: b6c9911b2d670376918d5b8779bc27e0e612a94ec3ff0343689e991d8d0a3b8a + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" url: "https://pub.dev" source: hosted - version: "8.1.4" + version: "2.1.1" characters: dependency: transitive description: @@ -113,22 +65,6 @@ 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 - url: "https://pub.dev" - source: hosted - version: "2.0.1" clock: dependency: transitive description: @@ -137,14 +73,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 - url: "https://pub.dev" - source: hosted - version: "4.10.0" collection: dependency: transitive description: @@ -157,26 +85,34 @@ 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" - dart_style: + version: "3.0.5" + diff_match_patch: dependency: transitive description: - name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "0.4.1" fake_async: dependency: transitive description: @@ -185,27 +121,51 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - file: + faker: + dependency: "direct dev" + description: + name: faker + sha256: "544c34e9e1d322824156d5a8d451bc1bb778263b892aded24ec7ba77b0706624" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi: dependency: transitive description: - name: file - sha256: b69516f2c26a5bcac4eee2e32512e1a5205ab312b3536c1c1227b2b942b5f9ad + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "6.1.2" - fixnum: + version: "2.1.3" + file: dependency: transitive description: - name: fixnum - sha256: "6a2ef17156f4dc49684f9d99aaf4a93aba8ac49f5eac861755f5730ddf6e2e4e" + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "7.0.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_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: "9357883bdd153ab78cbf9ffa07656e336b8bbb2b5a3ca596b0b27e119f7c7d77" + url: "https://pub.dev" + source: hosted + version: "5.1.0" flutter_lints: dependency: "direct dev" description: @@ -219,30 +179,27 @@ 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" glob: dependency: transitive description: name: glob - sha256: "8321dd2c0ab0683a91a51307fa844c6db4aa8e3981219b78961672aaab434658" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.0.2" - graphs: - dependency: transitive - description: - name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 - url: "https://pub.dev" - source: hosted - version: "2.3.1" + version: "2.1.2" http: dependency: "direct main" description: @@ -255,50 +212,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" - json_annotation: - dependency: "direct main" - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" - json_serializable: - dependency: "direct dev" - description: - name: json_serializable - sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b - url: "https://pub.dev" - source: hosted - version: "6.8.0" + version: "0.7.1" leak_tracker: dependency: transitive description: @@ -335,10 +276,10 @@ packages: dependency: transitive description: name: logging - sha256: "293ae2d49fd79d4c04944c3a26dfd313382d5f52e821ec57119230ae16031ad4" + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.2.0" matcher: dependency: transitive description: @@ -367,18 +308,42 @@ packages: 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 dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + 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,67 +352,179 @@ 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" - pubspec_parse: + version: "2.1.4" + 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: pubspec_parse - sha256: "3686efe4a4613a4449b1a4ae08670aadbd3376f2e78d93e3f8f0919db02a7256" + name: shared_preferences_android + sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" url: "https://pub.dev" source: hosted - version: "1.2.0" + 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: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.4" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" - source_gen: + source_map_stack_trace: dependency: transitive description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b url: "https://pub.dev" source: hosted - version: "1.5.0" - source_helper: + version: "2.1.2" + source_maps: dependency: transitive description: - name: source_helper - sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" url: "https://pub.dev" source: hosted - version: "1.3.4" + version: "0.10.12" source_span: dependency: transitive description: @@ -472,14 +549,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ed464977cb26a1f41537e177e190c67223dbd9f4f683489b6ab2e5d211ec564e - url: "https://pub.dev" - source: hosted - version: "2.0.0" string_scanner: dependency: transitive description: @@ -496,6 +565,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + url: "https://pub.dev" + source: hosted + version: "1.25.2" test_api: dependency: transitive description: @@ -504,22 +581,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" - timing: + test_core: dependency: transitive description: - name: timing - sha256: c386d07d7f5efc613479a7c4d9d64b03710b03cfaa7e8ad5f2bfb295a1f0dfad + name: test_core + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "0.6.0" 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: @@ -540,10 +617,10 @@ packages: 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: @@ -556,18 +633,34 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "0c2ada1b1aeb2ad031ca81872add6be049b8cb479262c6ad3c4b0f9c24eaab2f" + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.4.0" + 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" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml index bc8a205..1946da7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,14 +14,17 @@ dependencies: flutter: sdk: flutter http: ^1.2.2 - json_annotation: ^4.9.0 + flutter_bloc: ^8.1.6 + shared_preferences: ^2.3.2 + flutter_dotenv: ^5.1.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^4.0.0 - build_runner: ^2.4.10 - json_serializable: ^6.8.0 + faker: ^2.2.0 + mocktail: ^1.0.4 + bloc_test: ^9.1.7 flutter: generate: true @@ -45,4 +48,6 @@ flutter: style: italic - asset: assets/fonts/OpenSans/OpenSans-SemiBold.ttf weight: 600 - + + assets: + - .env diff --git a/test/core/factories.dart b/test/core/factories.dart new file mode 100644 index 0000000..d2d3613 --- /dev/null +++ b/test/core/factories.dart @@ -0,0 +1,189 @@ +import 'package:faker/faker.dart'; +import 'package:restaurant_tour/data/models/category_model.dart'; +import 'package:restaurant_tour/data/models/local_restaurant_model.dart'; +import 'package:restaurant_tour/data/models/remote_restaurant_model.dart'; +import 'package:restaurant_tour/data/models/review_model.dart'; +import 'package:restaurant_tour/data/models/user_model.dart'; +import 'package:restaurant_tour/domain/entities/entities.dart'; + +RemoteRestaurantModel makeRemoteRestaurantModel() { + return RemoteRestaurantModel( + id: faker.guid.guid(), + categories: makeListCategoryModel(), + isOpen: faker.randomGenerator.boolean(), + address: faker.address.streetAddress(), + name: faker.person.name(), + photos: [], + price: faker.randomGenerator.decimal(scale: 15, min: 5).toString(), + rating: faker.randomGenerator.decimal(min: 1), + reviews: makeListReviewModel(), + ); +} + +LocalRestaurantModel makeLocalRestaurantModel() { + return LocalRestaurantModel( + id: faker.guid.guid(), + categories: makeListCategoryModel(), + isOpen: faker.randomGenerator.boolean(), + address: faker.address.streetAddress(), + name: faker.person.name(), + photos: [], + price: faker.randomGenerator.decimal(scale: 15, min: 5).toString(), + rating: faker.randomGenerator.decimal(min: 1), + reviews: makeListReviewModel(), + ); +} + +List makeListCategoryModel() { + return List.generate(10, (_) => makeCategoryModel()); +} + +CategoryModel makeCategoryModel() { + return CategoryModel(title: faker.lorem.sentence(), alias: faker.lorem.word()); +} + +List makeListReviewModel() { + return List.generate(5, (_) => makeReviewModel()); +} + +ReviewModel makeReviewModel() { + return ReviewModel( + id: faker.guid.guid(), + rating: faker.randomGenerator.integer(5, min: 1), + text: faker.lorem.word(), + user: makeUserModel(), + ); +} + +UserModel makeUserModel() { + return UserModel( + id: faker.guid.guid(), + imageUrl: '', + name: faker.person.name(), + ); +} + +List makeListRestaurantEntity() { + return List.generate(4, (_) => makeRestaurantEntity()); +} + +RestaurantEntity makeRestaurantEntity() { + return RestaurantEntity( + id: faker.guid.guid(), + categories: makeListCategoryEntity(), + isOpen: faker.randomGenerator.boolean(), + address: faker.address.streetAddress(), + name: faker.person.name(), + photos: [], + price: faker.randomGenerator.decimal(scale: 15, min: 5).toString(), + rating: faker.randomGenerator.decimal(min: 1), + reviews: makeListReviewEntity(), + ); +} + +List makeListFavoriteRestaurantEntity() { + return List.generate(4, (_) => makeFavoriteRestaurantEntity()); +} + +FavoriteRestaurantEntity makeFavoriteRestaurantEntity() { + return FavoriteRestaurantEntity( + id: faker.guid.guid(), + categories: makeListCategoryEntity(), + isOpen: faker.randomGenerator.boolean(), + address: faker.address.streetAddress(), + name: faker.person.name(), + photos: [], + price: faker.randomGenerator.decimal(scale: 15, min: 5).toString(), + rating: faker.randomGenerator.decimal(min: 1), + reviews: makeListReviewEntity(), + ); +} + +List makeListCategoryEntity() { + return List.generate(10, (_) => makeCategoryEntity()); +} + +CategoryEntity makeCategoryEntity() { + return CategoryEntity(title: faker.lorem.sentence(), alias: faker.lorem.word()); +} + +List makeListReviewEntity() { + return List.generate(5, (_) => makeReviewEntity()); +} + +ReviewEntity makeReviewEntity() { + return ReviewEntity( + id: faker.guid.guid(), + rating: faker.randomGenerator.integer(5, min: 1), + text: faker.lorem.word(), + user: makeUserEntity(), + ); +} + +UserEntity makeUserEntity() { + return UserEntity( + id: faker.guid.guid(), + imageUrl: '', + name: faker.person.name(), + ); +} + +List makeRemoteRestaurantsJson() { + return List.generate( + 4, + (_) => { + 'id': faker.guid.guid(), + 'categories': List.generate(4, (_) => makeCategoryModel().toJson()), + 'hours': [ + {'is_open_now': faker.randomGenerator.boolean()}, + ], + 'location': {'formatted_address': faker.address.streetAddress()}, + 'name': faker.person.name(), + 'photos': [], + 'price': '\$\$\$', + 'rating': faker.randomGenerator.decimal(min: 1), + 'reviews': List.generate(3, (_) => makeReviewModel().toJson()), + }, + ); +} + +List makeLocalRestaurantsJson() { + return List.generate(5, (_) => makeLocalRestaurantModel().toJson()); +} + +Map makeCategoryJson() { + return { + 'title': faker.lorem.word(), + 'alias': faker.lorem.word(), + }; +} + +Map makeReviewJson() { + return { + 'id': faker.guid.guid(), + 'rating': faker.randomGenerator.integer(5, min: 1), + 'text': faker.lorem.sentence(), + 'user': { + 'id': faker.guid.guid(), + 'image_url': '', + 'name': faker.person.name(), + }, + }; +} + +Map makeUserJson() { + return { + 'id': faker.guid.guid(), + 'image_url': '', + 'name': faker.person.name(), + }; +} + +Map makeInvalidJson() { + return { + 'data': { + 'id': faker.guid.guid(), + 'categories': null, + }, + }; +} diff --git a/test/data/models/category_model_test.dart b/test/data/models/category_model_test.dart new file mode 100644 index 0000000..2743c8e --- /dev/null +++ b/test/data/models/category_model_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/data/models/models.dart'; +import 'package:restaurant_tour/domain/entities/entities.dart'; +import 'package:restaurant_tour/domain/helpers/helpers.dart'; + +import '../../core/factories.dart'; + +void main() { + late Map categoryJson; + late CategoryModel categoryModel; + late CategoryEntity categoryEntity; + + setUp(() { + categoryJson = makeCategoryJson(); + categoryModel = makeCategoryModel(); + categoryEntity = makeCategoryEntity(); + }); + + test('Should create CategoryModel object when json is valid', () async { + final json = CategoryModel.fromJson(categoryJson); + expect(json, isA()); + }); + + test('Should create CategoryEntity object from CategoryModel object', () async { + final json = CategoryModel.fromEntity(categoryEntity); + expect(json, isA()); + }); + + test('Should throw a unexpected error when json is not valid', () async { + expect(() => CategoryModel.fromJson({'alias': 33}), throwsA(DomainError.unexpected)); + }); + + test('Should create a CategoryEntity object', () async { + final result = categoryModel.toEntity(); + expect(result, isA()); + }); +} diff --git a/test/data/models/local_restaurant_model_test.dart b/test/data/models/local_restaurant_model_test.dart new file mode 100644 index 0000000..5bb0025 --- /dev/null +++ b/test/data/models/local_restaurant_model_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/data/models/models.dart'; +import 'package:restaurant_tour/domain/entities/entities.dart'; +import 'package:restaurant_tour/domain/helpers/helpers.dart'; + +import '../../core/factories.dart'; + +void main() { + late Map restaurantJson; + late LocalRestaurantModel localRestaurantModel; + late RestaurantEntity restaurantEntity; + + setUp(() { + restaurantJson = makeLocalRestaurantsJson().first; + localRestaurantModel = makeLocalRestaurantModel(); + restaurantEntity = makeRestaurantEntity(); + }); + + test('Should create LocalRestaurantModel object when json is valid', () async { + final json = LocalRestaurantModel.fromJson(restaurantJson); + expect(json, isA()); + }); + + test('Should create RestaurantEntity object from LocalRestaurantModel object', () async { + final json = LocalRestaurantModel.fromEntity(restaurantEntity); + expect(json, isA()); + }); + + test('Should throw a unexpected error when json is not valid', () async { + expect(() => LocalRestaurantModel.fromJson(makeInvalidJson()), throwsA(DomainError.unexpected)); + }); + + test('Should create a FavoriteRestaurantEntity object', () async { + final result = localRestaurantModel.toEntity(); + expect(result, isA()); + }); +} diff --git a/test/data/models/remote_restaurant_model_test.dart b/test/data/models/remote_restaurant_model_test.dart new file mode 100644 index 0000000..6afb192 --- /dev/null +++ b/test/data/models/remote_restaurant_model_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/data/models/models.dart'; +import 'package:restaurant_tour/domain/entities/entities.dart'; +import 'package:restaurant_tour/domain/helpers/helpers.dart'; + +import '../../core/factories.dart'; + +void main() { + late Map restaurantJson; + late RemoteRestaurantModel remoteRestaurantModel; + + setUp(() { + restaurantJson = makeRemoteRestaurantsJson().first; + remoteRestaurantModel = makeRemoteRestaurantModel(); + }); + + test('Should create RemoteRestaurantModel object when json is valid', () async { + final json = RemoteRestaurantModel.fromJson(restaurantJson); + expect(json, isA()); + }); + + test('Should throw a unexpected error when json is not valid', () async { + expect(() => RemoteRestaurantModel.fromJson(makeInvalidJson()), throwsA(DomainError.unexpected)); + }); + + test('Should create a RestaurantEntity object', () async { + final result = remoteRestaurantModel.toEntity(); + expect(result, isA()); + }); +} diff --git a/test/data/models/review_model_test.dart b/test/data/models/review_model_test.dart new file mode 100644 index 0000000..c0ff44c --- /dev/null +++ b/test/data/models/review_model_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/data/models/models.dart'; +import 'package:restaurant_tour/domain/entities/entities.dart'; +import 'package:restaurant_tour/domain/helpers/helpers.dart'; + +import '../../core/factories.dart'; + +void main() { + late Map reviewJson; + late ReviewModel reviewModel; + late ReviewEntity reviewEntity; + + setUp(() { + reviewJson = makeReviewJson(); + reviewModel = makeReviewModel(); + reviewEntity = makeReviewEntity(); + }); + + test('Should create ReviewModel object when json is valid', () async { + final json = ReviewModel.fromJson(reviewJson); + expect(json, isA()); + }); + + test('Should create ReviewEntity object from ReviewModel object', () async { + final json = ReviewModel.fromEntity(reviewEntity); + expect(json, isA()); + }); + + test('Should throw a unexpected error when json is not valid', () async { + expect(() => ReviewModel.fromJson(makeInvalidJson()), throwsA(DomainError.unexpected)); + }); + + test('Should create a ReviewEntity object', () async { + final result = reviewModel.toEntity(); + expect(result, isA()); + }); +} diff --git a/test/data/models/user_model_test.dart b/test/data/models/user_model_test.dart new file mode 100644 index 0000000..31d108d --- /dev/null +++ b/test/data/models/user_model_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/data/models/models.dart'; +import 'package:restaurant_tour/domain/entities/entities.dart'; +import 'package:restaurant_tour/domain/helpers/helpers.dart'; + +import '../../core/factories.dart'; + +void main() { + late Map userJson; + late UserModel userModel; + late UserEntity userEntity; + + setUp(() { + userJson = makeUserJson(); + userModel = makeUserModel(); + userEntity = makeUserEntity(); + }); + + test('Should create UserModel object when json is valid', () async { + final json = UserModel.fromJson(userJson); + expect(json, isA()); + }); + + test('Should create UserEntity object from UserModel object', () async { + final json = UserModel.fromEntity(userEntity); + expect(json, isA()); + }); + + test('Should throw a unexpected error when json is not valid', () async { + expect(() => UserModel.fromJson({'id': 33}), throwsA(DomainError.unexpected)); + }); + + test('Should create a UserEntity object', () async { + final result = userModel.toEntity(); + expect(result, isA()); + }); +} diff --git a/test/data/usecases/local_get_favorite_restaurants_test.dart b/test/data/usecases/local_get_favorite_restaurants_test.dart new file mode 100644 index 0000000..f5e3623 --- /dev/null +++ b/test/data/usecases/local_get_favorite_restaurants_test.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/data/usecases/usecases.dart'; +import 'package:restaurant_tour/domain/entities/entities.dart'; +import 'package:restaurant_tour/domain/helpers/helpers.dart'; +import 'package:restaurant_tour/infra/infra.dart'; + +import '../../core/factories.dart'; + +class FetchCacheSpy extends Mock implements FetchCache {} + +void main() { + late LocalGetFavoriteRestaurants sut; + late FetchCacheSpy fetchCache; + + When mockFetchCall() => when(() => fetchCache.fetch(any())); + + void mockFetch() { + mockFetchCall().thenAnswer((_) async => jsonEncode(makeLocalRestaurantsJson())); + } + + void mockFetchError() { + mockFetchCall().thenThrow(Exception()); + } + + setUp(() { + fetchCache = FetchCacheSpy(); + sut = LocalGetFavoriteRestaurants(cache: fetchCache); + mockFetch(); + }); + + test('Should call fetchCache with correct value', () async { + await sut(); + verify(() => fetchCache.fetch(any())); + }); + + test('Should return an List', () async { + final result = await sut(); + expect(result, isA>()); + }); + + test('Should throw UnexpectedError if fetchCache throws', () async { + mockFetchError(); + final future = sut(); + expect(future, throwsA(DomainError.unexpected)); + }); +} diff --git a/test/data/usecases/local_save_favorite_restaurants_test.dart b/test/data/usecases/local_save_favorite_restaurants_test.dart new file mode 100644 index 0000000..fb6ea94 --- /dev/null +++ b/test/data/usecases/local_save_favorite_restaurants_test.dart @@ -0,0 +1,48 @@ +import 'package:faker/faker.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/data/usecases/usecases.dart'; +import 'package:restaurant_tour/domain/entities/entities.dart'; +import 'package:restaurant_tour/domain/helpers/helpers.dart'; +import 'package:restaurant_tour/infra/infra.dart'; + +import '../../core/factories.dart'; + +class SaveCacheSpy extends Mock implements SaveCache {} + +void main() { + late SaveCacheSpy saveCache; + late LocalSaveFavoriteRestaurants sut; + late List restaurantList; + + When mockSaveCall() { + return when(() => saveCache.save(key: any(named: 'key'), value: any(named: 'value'))); + } + + void mockSave() { + mockSaveCall().thenAnswer((_) async => faker.randomGenerator.integer(10)); + } + + void mockSaveError() { + mockSaveCall().thenThrow(Exception()); + } + + setUp(() { + saveCache = SaveCacheSpy(); + sut = LocalSaveFavoriteRestaurants(cache: saveCache); + restaurantList = makeListRestaurantEntity(); + mockSave(); + }); + + test('Should call SaveCache with correct value', () async { + await sut(restaurantList); + verify(() => saveCache.save(key: any(named: 'key'), value: any(named: 'value'))); + }); + + test('Should throw UnexpectedError if saveCache throws', () async { + mockSaveError(); + final future = sut(restaurantList); + expect(future, throwsA(DomainError.unexpected)); + }); +} diff --git a/test/data/usecases/remote_get_restaurants_test.dart b/test/data/usecases/remote_get_restaurants_test.dart new file mode 100644 index 0000000..2c839b2 --- /dev/null +++ b/test/data/usecases/remote_get_restaurants_test.dart @@ -0,0 +1,79 @@ +import 'package:faker/faker.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/data/usecases/usecases.dart'; +import 'package:restaurant_tour/domain/entities/entities.dart'; +import 'package:restaurant_tour/domain/helpers/helpers.dart'; +import 'package:restaurant_tour/infra/infra.dart'; + +import '../../core/factories.dart'; + +class HttpClientSpy extends Mock implements HttpClient {} + +void main() { + late RemoteGetRestaurants sut; + late HttpClientSpy httpClient; + late String url; + late Map data; + + When mockRequest() { + return when( + () => httpClient.request( + url: any(named: 'url'), + method: any(named: 'method'), + data: any(named: 'data'), + ), + ); + } + + void mockHttpData(Map mockData) { + data = mockData; + mockRequest().thenAnswer((_) async => data); + } + + void mockHttpError(HttpError error) { + mockRequest().thenThrow(error); + } + + setUp(() { + url = faker.internet.httpUrl(); + httpClient = HttpClientSpy(); + sut = RemoteGetRestaurants(url: url, client: httpClient); + mockHttpData({ + 'data': { + 'search': { + 'business': makeRemoteRestaurantsJson(), + }, + }, + }); + }); + + test('Should call HttpClient with correct values', () async { + await sut(); + verify(() => httpClient.request(url: any(named: 'url'), method: any(named: 'method'), data: any(named: 'data'))); + }); + + test('Should return restaurants on 200', () async { + final articles = await sut(); + expect(articles, isA>()); + expect(articles, isNotEmpty); + }); + + test('Should throw UnexpectedError if HttpClient returns 200 with invalid data', () async { + mockHttpData({'invalid_key': 'invalid_value'}); + final future = sut(); + expect(future, throwsA(DomainError.unexpected)); + }); + + test('Should throw UnexpectedError if HttpClient returns 400', () async { + mockHttpError(HttpError.badRequest); + final future = sut(); + expect(future, throwsA(DomainError.unexpected)); + }); + + test('Should throw UnexpectedError if HttpClient returns 500', () async { + mockHttpError(HttpError.serverError); + final future = sut(); + expect(future, throwsA(DomainError.unexpected)); + }); +} diff --git a/test/infra/cache/local_storage_adapter_test.dart b/test/infra/cache/local_storage_adapter_test.dart new file mode 100644 index 0000000..a96ebe5 --- /dev/null +++ b/test/infra/cache/local_storage_adapter_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurant_tour/infra/cache/cache.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late LocalStorageAdapter sut; + late String key; + late String value; + + setUp(() { + key = 'any_key'; + value = 'any_value'; + SharedPreferences.setMockInitialValues({key: value}); + sut = LocalStorageAdapter(); + }); + + group('fetch', () { + test('Should return the value from SharedPreferences', () async { + final result = await sut.fetch(key); + expect(result, value); + }); + + test('Should return null if the key is not found', () async { + final result = await sut.fetch('test'); + expect(result, isNull); + }); + }); + + group('save', () { + test('Should save the value in SharedPreferences', () async { + const key = 'test_key'; + const value = 'test_value'; + await sut.save(key: key, value: value); + final result = await sut.fetch(key); + expect(result, value); + }); + }); +} diff --git a/test/infra/http/http_adapter_test.dart b/test/infra/http/http_adapter_test.dart new file mode 100644 index 0000000..1ce0a27 --- /dev/null +++ b/test/infra/http/http_adapter_test.dart @@ -0,0 +1,120 @@ +import 'package:faker/faker.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/infra/infra.dart'; + +class ClientSpy extends Mock implements Client {} + +class UriFake extends Fake implements Uri {} + +void main() { + late HttpAdapter sut; + late ClientSpy client; + late String url; + late Uri uri; + late String data; + + setUp(() { + client = ClientSpy(); + sut = HttpAdapter(client: client, headers: {'content-type': 'application/json', 'accept': 'application/json'}); + url = faker.internet.httpUrl(); + uri = Uri.parse(url); + data = '{"any_key":"any_value"}'; + }); + + setUpAll(() { + registerFallbackValue(UriFake()); + }); + + group('shared', () { + test('Should throw ServeError if invalid method is provided', () async { + final future = sut.request(url: url, method: 'invalid_method'); + expect(future, throwsA(HttpError.serverError)); + }); + }); + + group('post', () { + When mockRequest() { + return when( + () => client.post(any(), body: any(named: 'body'), headers: any(named: 'headers')), + ); + } + + void mockError() { + mockRequest().thenThrow(Exception()); + } + + void mockResponse(int statusCode, {String body = '{"any_key":"any_value"}'}) { + mockRequest().thenAnswer( + (_) async => Response(body, statusCode), + ); + } + + setUp(() { + mockResponse(200); + }); + + test('Should call post with correct values', () async { + await sut.request( + url: url, + method: 'post', + data: '{"any_key":"any_value"}', + ); + + verify( + () => client.post( + uri, + headers: {'content-type': 'application/json', 'accept': 'application/json'}, + body: data, + ), + ); + }); + + test('Should call post without body', () async { + await sut.request(url: url, method: 'post'); + verify(() => client.post(any(), headers: any(named: 'headers'))); + }); + + test('Should return data if post returns 200', () async { + final response = await sut.request(url: url, method: 'post', data: data); + expect(response, {'any_key': 'any_value'}); + }); + + test('Should return null if post returns 200 with no data', () async { + mockResponse(200, body: ''); + final response = await sut.request(url: url, method: 'post', data: data); + expect(response, null); + }); + + test('Should return BadRequestError if post returns 400', () async { + mockResponse(400, body: ''); + final future = sut.request(url: url, method: 'post', data: data); + expect(future, throwsA(HttpError.badRequest)); + }); + + test('Should return BadRequestError if post returns 400', () async { + mockResponse(400); + final future = sut.request(url: url, method: 'post', data: data); + expect(future, throwsA(HttpError.badRequest),); + }); + + test('Should return UnathorizedError if post returns 401', () async { + mockResponse(401); + final future = sut.request(url: url, method: 'post', data: data); + expect(future, throwsA(HttpError.unauthorized)); + }); + + test('Should return ServerError if post returns 500', () async { + mockResponse(500); + final future = sut.request(url: url, method: 'post', data: data); + expect(future, throwsA(HttpError.serverError)); + }); + + test('Should return ServerError if post throws', () async { + mockError(); + final future = sut.request(url: url, method: 'post', data: data); + expect(future, throwsA(HttpError.serverError)); + }); + }); +} diff --git a/test/presentation/restaurant_tour/cubit_restaurant_tour_presenter_test.dart b/test/presentation/restaurant_tour/cubit_restaurant_tour_presenter_test.dart new file mode 100644 index 0000000..95f5dec --- /dev/null +++ b/test/presentation/restaurant_tour/cubit_restaurant_tour_presenter_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurant_tour/domain/entities/entities.dart'; +import 'package:restaurant_tour/domain/helpers/helpers.dart'; +import 'package:restaurant_tour/domain/usecases/usecases.dart'; +import 'package:restaurant_tour/presentation/presentation.dart'; + +import '../../core/factories.dart'; + +class SpyGetRestaurants extends Mock implements GetRestaurants {} + +class SpyGetFavoriteRestaurants extends Mock implements GetFavoriteRestaurants {} + +class SpySaveFavoriteRestaurants extends Mock implements SaveFavoriteRestaurants {} + +class FakeArticleEntity extends Fake implements RestaurantEntity {} + +class FakefavoriteRestaurantEntity extends Fake implements FavoriteRestaurantEntity {} + +void main() { + late GetRestaurants getRestaurants; + late GetFavoriteRestaurants getFavoriteRestaurants; + late SaveFavoriteRestaurants saveFavoriteRestaurants; + late CubitRestaurantTourPresenter sut; + late List restaurants; + late RestaurantEntity restaurantEntity; + late List favoriteRestaurants; + + When mockGetRestaurantsCall() { + return when(() => getRestaurants()); + } + + void mockGetRestaurantsSuccess() { + mockGetRestaurantsCall().thenAnswer((_) => Future.value(restaurants)); + } + + void mockGetRestaurantsError() { + mockGetRestaurantsCall().thenThrow(DomainError.unexpected); + } + + When mockGetFavoriteRestaurantsCall() { + return when(() => getFavoriteRestaurants()); + } + + void mockGetFavoriteRestaurantsSuccess() { + mockGetFavoriteRestaurantsCall().thenAnswer((_) => Future.value(favoriteRestaurants)); + } + + void mockGetFavoriteRestaurantsError() { + mockGetFavoriteRestaurantsCall().thenThrow(DomainError.unexpected); + } + + When mockSaveFavoriteRestaurantsCall() { + return when(() => saveFavoriteRestaurants(any())); + } + + void mockSaveFavoriteRestaurantsSuccess() { + mockSaveFavoriteRestaurantsCall().thenAnswer((_) => Future.value()); + } + + void mockSaveFavoriteRestaurantsError() { + mockSaveFavoriteRestaurantsCall().thenThrow(DomainError.unexpected); + } + + void expectSuccessFlowStatesEmitted() { + expectLater( + sut.stream, + emitsInOrder([isA(), isA()]), + ); + } + + void expectErrorFlowStatesEmitted() { + expectLater( + sut.stream, + emitsInOrder([isA(), isA()]), + ); + } + + void expectErrorEmitted() { + expectLater(sut.stream, emits(isA())); + } + + setUp(() { + getRestaurants = SpyGetRestaurants(); + getFavoriteRestaurants = SpyGetFavoriteRestaurants(); + saveFavoriteRestaurants = SpySaveFavoriteRestaurants(); + + sut = CubitRestaurantTourPresenter( + getRestaurants: getRestaurants, + getFavoriteRestaurants: getFavoriteRestaurants, + saveFavoriteRestaurants: saveFavoriteRestaurants, + ); + + restaurants = makeListRestaurantEntity(); + restaurantEntity = restaurants[0]; + favoriteRestaurants = makeListFavoriteRestaurantEntity(); + + mockGetRestaurantsSuccess(); + mockGetFavoriteRestaurantsSuccess(); + mockSaveFavoriteRestaurantsSuccess(); + }); + + setUpAll(() { + registerFallbackValue(FakeArticleEntity()); + registerFallbackValue(FakefavoriteRestaurantEntity()); + }); + + group('getAllRestaurants', () { + test('Should emits [RestaurantLoadingState, RestaurantSuccessState] when getAllRestaurants was success', () async { + expectSuccessFlowStatesEmitted(); + await sut.getAllRestaurants(); + }); + + test('Should restaurantList is not empty when getAllRestaurants was success', () async { + sut.restaurantList.clear(); + expect(sut.restaurantList.isEmpty, isTrue); + await sut.getAllRestaurants(); + expect(sut.restaurantList.isNotEmpty, isTrue); + }); + + test('Should emits [RestaurantLoadingState, RestaurantErrorState] when getAllRestaurants fails', () async { + mockGetRestaurantsError(); + expectErrorFlowStatesEmitted(); + await sut.getAllRestaurants(); + }); + }); + + group('getFavoriteRestaurants', () { + test('Should emits [RestaurantLoadingState, RestaurantSuccessState] when getFavoriteRestaurants was success', () async { + expectSuccessFlowStatesEmitted(); + await sut.getFavoriteRestaurants(); + }); + + test('Should groupedFavoriteNews is not empty when getFavoriteRestaurants was success', () async { + expect(sut.favoriteRestaurantList.isEmpty, isTrue); + await sut.getFavoriteRestaurants(); + expect(sut.favoriteRestaurantList.isNotEmpty, isTrue); + }); + + test('Should emits [RestaurantLoadingState, RestaurantErrorState] when getFavoriteRestaurants fails', () async { + mockGetFavoriteRestaurantsError(); + expectErrorFlowStatesEmitted(); + await sut.getFavoriteRestaurants(); + }); + }); + + group('addFavoriteRestaurant', () { + setUp(() { + sut.getAllRestaurants(); + }); + + test('Should restaurantList have restaurant added as favorite when addFavoriteRestaurant was success', () async { + await sut.addFavoriteRestaurant(restaurantEntity); + final result = sut.restaurantList.firstWhere((item) => item.id == restaurantEntity.id); + expect(result, isA()); + }); + + test('Should emits [RestaurantErrorState] when addFavoriteRestaurant fails', () async { + mockSaveFavoriteRestaurantsError(); + expectErrorEmitted(); + await sut.addFavoriteRestaurant(restaurantEntity); + }); + }); +} diff --git a/test/ui/pages/restaurant_tour/restaurant_tour_page_test.dart b/test/ui/pages/restaurant_tour/restaurant_tour_page_test.dart new file mode 100644 index 0000000..f6eb411 --- /dev/null +++ b/test/ui/pages/restaurant_tour/restaurant_tour_page_test.dart @@ -0,0 +1,153 @@ +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:restaurant_tour/domain/entities/entities.dart'; +import 'package:restaurant_tour/presentation/presentation.dart'; +import 'package:restaurant_tour/ui/pages/pages.dart'; +import 'package:restaurant_tour/ui/widgets/widgets.dart'; + +import '../../../core/factories.dart'; + +class SpyCubitRestaurantTourPresenter extends MockCubit implements CubitRestaurantTourPresenter {} + +class SpyNavigatorObserver extends Mock implements NavigatorObserver {} + +class FakeRestaurantEntity extends Fake implements RestaurantEntity {} + +class FakeFavoriteRestaurantEntity extends Fake implements FavoriteRestaurantEntity {} + +class FakeRoute extends Fake implements Route {} + +void main() { + late CubitRestaurantTourPresenter presenter; + late NavigatorObserver navigatorObserver; + late RestaurantEntity restaurantEntity; + late FavoriteRestaurantEntity favoriteRestaurantEntity; + + void mockErrorState() { + whenListen( + presenter, + Stream.fromIterable([RestaurantErrorState('any_message')]), + initialState: RestaurantInitialState(), + ); + } + + void mockLoadingState() { + whenListen( + presenter, + Stream.fromIterable([RestaurantLoadingState()]), + initialState: RestaurantInitialState(), + ); + } + + void mockSuccessState() { + whenListen( + presenter, + Stream.fromIterable([RestaurantSuccessState()]), + initialState: RestaurantInitialState(), + ); + } + + void mockGetAllRestaurants() { + when(() => presenter.getAllRestaurants()).thenAnswer((_) => Future.value()); + } + + void mockGetFavoriteRestaurants() { + when(() => presenter.getFavoriteRestaurants()).thenAnswer((_) => Future.value()); + } + + void mockRestaurantList({restaurantList = const []}) { + when(() => presenter.restaurantList).thenReturn(restaurantList); + } + + void mockFavoriteRestaurantList({favoriteRestaurantList = const []}) { + when(() => presenter.favoriteRestaurantList).thenReturn(favoriteRestaurantList); + } + + setUp(() { + presenter = SpyCubitRestaurantTourPresenter(); + navigatorObserver = SpyNavigatorObserver(); + restaurantEntity = makeRestaurantEntity(); + favoriteRestaurantEntity = makeFavoriteRestaurantEntity(); + mockSuccessState(); + mockGetAllRestaurants(); + mockGetFavoriteRestaurants(); + mockRestaurantList(); + mockFavoriteRestaurantList(); + }); + + setUpAll(() { + registerFallbackValue(FakeRestaurantEntity()); + registerFallbackValue(FakeFavoriteRestaurantEntity()); + registerFallbackValue(FakeRoute()); + }); + + Future loadPage(WidgetTester tester) async { + final page = BlocProvider.value( + value: presenter, + child: MaterialApp( + home: RestaurantTourPage(presenter: presenter), + navigatorObservers: [navigatorObserver], + ), + ); + await tester.pumpWidget(page); + } + + testWidgets('Should load the page with correct widgets', (tester) async { + await loadPage(tester); + expect(find.byType(TabBar), findsOneWidget); + expect(find.byType(TabBarView), findsOneWidget); + expect(find.text('No restaurants.'), findsOneWidget); + expect(find.byKey(restaurantListKey), findsNothing); + }); + + testWidgets('Should show a list of restaurants when there is a restaurant', (tester) async { + mockRestaurantList(restaurantList: [restaurantEntity]); + await loadPage(tester); + expect(find.byKey(restaurantListKey), findsOneWidget); + }); + + testWidgets('Should favorites content when favorite tab was tapped', (tester) async { + await loadPage(tester); + final favoriteTab = find.widgetWithText(Tab, 'My Favorites'); + await tester.tap(favoriteTab); + await tester.pumpAndSettle(); + expect(find.text('No favorites.'), findsOneWidget); + }); + + testWidgets('Should show a list of favorite restaurants when there is a favorite restaurant', (tester) async { + mockFavoriteRestaurantList(favoriteRestaurantList: [favoriteRestaurantEntity]); + await loadPage(tester); + final favoriteTab = find.widgetWithText(Tab, 'My Favorites'); + await tester.tap(favoriteTab); + await tester.pumpAndSettle(); + expect(find.text('No favorites.'), findsNothing); + expect(find.byKey(favoriteRestaurantListKey), findsOneWidget); + }); + + testWidgets('Should show a SnackBar when [RestaurantErrorState] was emitted', (tester) async { + mockErrorState(); + await loadPage(tester); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('any_message'), findsOneWidget); + }); + + testWidgets('Should show a CircularLoading when [RestaurantLoadingState] was emitted', (tester) async { + mockLoadingState(); + await loadPage(tester); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byType(CircularLoading), findsOneWidget); + }); + + testWidgets('Should navigate when RestaurantItem was tapped', (tester) async { + mockRestaurantList(restaurantList: [restaurantEntity]); + await loadPage(tester); + final restaurantItem = find.byKey(restaurantItemKey(restaurantEntity.id)); + await tester.tap(restaurantItem); + await tester.pumpAndSettle(); + verify(() => navigatorObserver.didPush(any(), any())); + }); +} 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); - }); -}