diff --git a/.env_stage b/.env_stage new file mode 100644 index 00000000..538d336f --- /dev/null +++ b/.env_stage @@ -0,0 +1 @@ +API_KEY="KC7Is6ZMmm9pJtce2CCzin4Np4GuCDraZ3M3Pdl6ibKfE96Byt4-RZ2p0c7qj_Dhg8g5BaMPfZ8uYfQ7OEXJoJzC85OULRmwXuaitDLQ5kqthjeFGhTxePA3gu3QZXYx" \ No newline at end of file diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index d8abe1b9..9bdedb8b 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.13.9", + "flutterSdkVersion": "3.16.8", "flavors": {} } \ No newline at end of file diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml new file mode 100644 index 00000000..539f58de --- /dev/null +++ b/.github/workflows/dart.yml @@ -0,0 +1,20 @@ +name: PR Changes + +on: + pull_request: + +jobs: + run_tests: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.13.9' + - run: dart pub global activate arb_utils + - run: flutter pub get + - run: flutter clean + - run: make gen + - run: make l10n + - run: make tests-ci-cd + diff --git a/.gitignore b/.gitignore index 1be2d875..fe7fa86a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,12 +8,15 @@ .buildlog/ .history .svn/ - +# automated_tests related +**/failures/ +**/coverage/ # IntelliJ related *.iml *.ipr *.iws .idea/ +*.env # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line diff --git a/.metadata b/.metadata index 0f055bf1..a072c7d9 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,27 @@ # This file should be version controlled and should not be manually edited. version: - revision: ffb2ecea5223acdd139a5039be2f9c796962833d - channel: stable + revision: "d211f42860350d914a5ad8102f9ec32764dc6d06" + channel: "stable" project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 + base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 + - platform: android + create_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 + base_revision: d211f42860350d914a5ad8102f9ec32764dc6d06 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/.vscode/launch.json b/.vscode/launch.json index 5d0f1d3e..ba1e7bc6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,13 +1,32 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { - "name": "app", + "name": "Smoke Test (prod)", "request": "launch", - "type": "dart" + "type": "dart", + "program": "integration_test/app_test.dart", + "args": [ + "--dart-define=app.flavor=prod" + ] + }, + { + "name": "RestauranTour (stage)", + "request": "launch", + "type": "dart", + "program": "lib/main.dart", + "args": [ + "--dart-define=app.flavor=stage" + ] + }, + { + "name": "RestauranTour (prod)", + "request": "launch", + "type": "dart", + "program": "lib/main.dart", + "args": [ + "--dart-define=app.flavor=prod" + ] } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f285aa4a..534886a5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/flutter_sdk", + "dart.flutterSdkPath": "~/fvm/versions/3.13.9", "search.exclude": { "**/.fvm": true }, diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..bec4a0e6 --- /dev/null +++ b/Makefile @@ -0,0 +1,73 @@ +# SETUP: +get : + flutter pub get + +setup-fvm-version : + fvm install 3.16.8 && \ + fvm global 3.16.8 + +pre-setup : + flutter clean &&\ + dart run build_runner clean && \ + dart pub global activate arb_utils && \ + dart pub global activate flutter_gen + + +setup-env-file : + test -f .env || echo "API_KEY=''" >> .env + +setup : pre-setup get l10n gen asset-gen setup-env-file unit-tests + + +fix-gen : + mkdir -p .dart_tool/flutter_gen/ && echo "name: stub" > .dart_tool/flutter_gen/pubspec.yaml + +try-fix-gen : fix-gen + dart run build_runner build --delete-conflicting-outputs + +# TESTS: + +tests : + flutter test --coverage + +all-tests : tests integration-tests + +unit-tests : + flutter test -x golden --coverage + +golden-tests : + flutter test -t golden --coverage + +tests-ci-cd : + rm -rf coverage && flutter test --coverage --dart-define=runningOnCICD=true + +integration-tests : + flutter test integration_test/app_test.dart + +update-goldens : remove-goldens-failures + flutter test --update-goldens + +remove-goldens-failures : + rm test/golden_test/failures/* + +# TEST COVERAGE + +show-coverage : tests lcov + +lcov-ignore : + lcov --remove coverage/lcov.info 'lib/core/*' 'lib/theme/*' 'lib/models/*.g.dart' -o coverage/lcov.info + +lcov : tests lcov-ignore + genhtml -q -o coverage coverage/lcov.info && open coverage/index.html + + +# CODE GENERATION + +l10n : + flutter gen-l10n && arb_utils sort lib/l10n/app_en.arb + +asset-gen : + fluttergen + +gen : + dart run build_runner build --delete-conflicting-outputs || make try-fix-gen diff --git a/README.md b/README.md index 6c2ea7c9..b0334565 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,113 @@ # RestauranTour -Be sure to read **all** of this document carefully, and follow the guidelines within. -## Vendorized Flutter +## Welcom to RestauranTour! -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: +The idea of this project is to help you to choose the best restaurants! - ```sh - dart pub global activate fvm - ``` +### Some features: +- Navigate through the available restaurants and see basic information like `opened status`, `price` and `classification` from other users directly from the list. +- See details of the restaurant, when you open a restaurant the details page will show you more information like `review` from users. +- Save the best! On the details screen you can add to favorites those restaurant make you heartbeat! +- Favorite list shows all the restaurant that you love. - The output of the command will ask to add the folder `./pub-cache/bin` to your PATH variables, if you didn't already. If that is the case, add it to your environment variables, and restart the terminal. +## Setup: - ```sh - export PATH="$PATH":"$HOME/.pub-cache/bin" # Add this to your environment variables - ``` -4. Install the project's flutter version using `fvm`. +To make things easier, we are using Makefile to manage the commands and setup you project. - ```sh - fvm use - ``` +Before start with Make commands, make sure that you have installed the fvm in your system, see [Vendorized Flutter](#vendorized-flutter) before. -5. From now on, you will run all the flutter commands with the `fvm` prefix. Get all the projects dependencies. +#### 1 - make setup-fvm-version - ```sh - fvm flutter pub get - ``` +Now that you have installed the fvm, by running the command `fvm --version` this should return the fvm version installed in your system like this: + ``` +$ fvm --version +2.4.1 +``` +Now we can proceed, well as mentioned before, we will use make for make things for us. To setup the fvm version run the following command: -More information on the approach can be found here: +``` bash +make setup-fvm-version +``` +The curremt version of Flutter is `3.16.8` but don't worry about setup this, `make setup-fvm-version` will do it automatically. -> hhttps://fvm.app/docs/getting_started/installation +> Make setup-fvm-version will run the following commands: `fvm install 3.16.8` and `fvm global 3.16.8` + +This will download the version that we are using on the project, and setup as global. + +### 2 - make setup + +This will setup all things that we need work on the project. +To make sure that everything is working well, `make setup` will run some commands: + +Pre-setup: + +- `flutter clean` # clean the flutter project +- `dart run build_runner clean` # clean the build runner +- `dart pub global activate arb_utils` # activate the arb_utils package to generate locales +- `dart pub global activate flutter_gen` # activate the flutter_gen package for management assets + +Setup: +- `get` # flutter pub get +- `l10n` # generate the locales and sort +- `gen` # generate the models +- `asset-gen` # generate the assets +- `setup-env-file` # generate the `.env` file +- `unit-tests` # run unit tests to ensure everything is working + +
+All make commands available -From the root directory: + + + + + + + + +
### IDE Setup @@ -181,3 +256,51 @@ Just create a new issue in this repo and we will respond and get back to you qui ## Review The coding challenge is a take-home test upon which we'll be conducting a thorough code review once complete. The review will consist of meeting some more of our mobile engineers and giving a review of the solution you have designed. Please be prepared to share your screen and run/demo the application to the group. During this process, the engineers will be asking questions. + + +## Vendorized Flutter + +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: + +```sh + dart pub global activate fvm +``` + +The output of the command will ask to add the folder `./pub-cache/bin` to your PATH variables, if you didn't already. If that is the case, add it to your environment variables, and restart the terminal. + +```sh +export PATH="$PATH":"$HOME/.pub-cache/bin" # Add this to your environment variables +``` + + + +More information on the approach can be found here: + +> hhttps://fvm.app/docs/getting_started/installation + +[Back to setup](#setup) + +## Coverage reports - lcov +LCOV is an extension of GCOV, a GNU tool which provides information about +what parts of a program are actually executed (i.e. "covered") while running +a particular test case. The extension consists of a set of Perl scripts +which build on the textual GCOV output to implement the following enhanced +functionality: + +- HTML based output: coverage rates are additionally indicated using bar +graphs and specific colors. + +- Support for large projects: overview pages allow quick browsing of +coverage data by providing three levels of detail: directory view, +file view and source code view. + +LCOV was initially designed to support Linux kernel coverage measurements, +but works as well for coverage measurements on standard user space +applications. + +Install on Linux/MacOs using brew + +Install on Windows + + +[Back to make](#2---make-setup) \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index adc7c5bf..8a88ba21 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,5 +9,4 @@ linter: rules: ## The following rules are excluded only to keep compatibility with our previous lint set (pedantic). ## There's room to discuss them individually and raise PRs adjusting our codebase. - avoid_print: false require_trailing_commas: true \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index e47cb81d..5cd8452f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -21,12 +22,10 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - android { + namespace "com.example.restaurantour" compileSdkVersion flutter.compileSdkVersion + ndkVersion flutter.ndkVersion compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -44,6 +43,8 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.restaurantour" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdkVersion flutter.minSdkVersion targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() @@ -63,6 +64,4 @@ flutter { source '../..' } -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} +dependencies {} diff --git a/android/build.gradle b/android/build.gradle index 24047dce..f7eb7f63 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.7.10' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' + classpath 'com.android.tools.build:gradle:7.3.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -26,6 +26,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index bc6a58af..3c472b99 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 44e62bcf..55c4ca8b 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,11 +1,20 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + } + settings.ext.flutterSdkPath = flutterSdkPath() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + plugins { + id "dev.flutter.flutter-gradle-plugin" version "1.0.0" apply false + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +include ":app" + +apply from: "${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/arb.yaml b/arb.yaml new file mode 100644 index 00000000..15338f2d --- /dev/null +++ b/arb.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart diff --git a/assets/fonts/Lora-Bold.ttf b/assets/fonts/Lora-Bold.ttf new file mode 100644 index 00000000..530c9e11 Binary files /dev/null and b/assets/fonts/Lora-Bold.ttf differ diff --git a/assets/fonts/Lora-Medium.ttf b/assets/fonts/Lora-Medium.ttf new file mode 100644 index 00000000..85ca5a27 Binary files /dev/null and b/assets/fonts/Lora-Medium.ttf differ diff --git a/assets/fonts/OpenSans-Regular.ttf b/assets/fonts/OpenSans-Regular.ttf new file mode 100644 index 00000000..67803bb6 Binary files /dev/null and b/assets/fonts/OpenSans-Regular.ttf differ diff --git a/assets/fonts/OpenSans-SemiBold.ttf b/assets/fonts/OpenSans-SemiBold.ttf new file mode 100644 index 00000000..e5ab4644 Binary files /dev/null and b/assets/fonts/OpenSans-SemiBold.ttf differ diff --git a/assets/svg/not_found.svg b/assets/svg/not_found.svg new file mode 100644 index 00000000..f86fd0f9 --- /dev/null +++ b/assets/svg/not_found.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/star.svg b/assets/svg/star.svg new file mode 100644 index 00000000..fec08c60 --- /dev/null +++ b/assets/svg/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart new file mode 100644 index 00000000..24f94450 --- /dev/null +++ b/integration_test/app_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:restaurantour/core/inject.dart'; +import 'package:restaurantour/app.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('end-to-end test', () { + testWidgets('navigate between tabs', (tester) async { + await setupInjection(); + await tester.pumpWidget(const App()); + + // Verify if the app loads + expect(find.byKey(const Key('all-restaurants-tab')), findsOneWidget); + expect(find.byKey(const Key('my-favorites-tab')), findsOneWidget); + + // Navigate between tabs + await tester.tap(find.byKey(const Key('my-favorites-tab'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('all-restaurants-tab'))); + await tester.pumpAndSettle(); + }); + }); +} diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 9625e105..7c569640 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 11.0 + 12.0 diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee8..ec97fc6f 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 592ceee8..c4855bfe 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 00000000..1ac99b85 --- /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 00000000..0d59166f --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,29 @@ +PODS: + - Flutter (1.0.0) + - integration_test (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - Flutter (from `Flutter`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + integration_test: + :path: ".symlinks/plugins/integration_test/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + +SPEC CHECKSUMS: + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + integration_test: 13825b8a9334a850581300559b8839134b124670 + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + +PODFILE CHECKSUM: 075ddf6b19cdcced44581bd8fbdfb58404a78f8a + +COCOAPODS: 1.15.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 73cf3f6d..fda473ab 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 2515EF51F3937ECFBA186785 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B9F3910B042FF5884DAAB51 /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -32,8 +33,12 @@ 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 = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 5B9F3910B042FF5884DAAB51 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5F4D7F61268BBEDB2874F63F /* 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 = ""; }; + 609452AFA65DF558375F3C42 /* 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 = ""; }; 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 = ""; }; + 77BA09A735E2EF81A865356D /* 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 = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -49,6 +54,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 2515EF51F3937ECFBA186785 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -72,6 +78,8 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, + C85482E760C77116C8325CEE /* Pods */, + CD8DC65D6CE479AFD01AC8A5 /* Frameworks */, ); sourceTree = ""; }; @@ -98,6 +106,25 @@ path = Runner; sourceTree = ""; }; + C85482E760C77116C8325CEE /* Pods */ = { + isa = PBXGroup; + children = ( + 5F4D7F61268BBEDB2874F63F /* Pods-Runner.debug.xcconfig */, + 609452AFA65DF558375F3C42 /* Pods-Runner.release.xcconfig */, + 77BA09A735E2EF81A865356D /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + CD8DC65D6CE479AFD01AC8A5 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 5B9F3910B042FF5884DAAB51 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -105,12 +132,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + D03E5B4AA7E367F956112BE9 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 1A59419E5B0870DF672461B0 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -169,6 +198,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 1A59419E5B0870DF672461B0 /* [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; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -200,6 +246,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + D03E5B4AA7E367F956112BE9 /* [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; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -275,7 +343,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -352,7 +420,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -401,7 +469,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16..21a3cc14 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 00000000..855b4d50 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,22 @@ +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/theme/theme.dart'; + +class App extends StatelessWidget { + const App({super.key}); + + @override + Widget build(BuildContext context) { + final goRouter = inject(); + return MaterialApp.router( + routerConfig: goRouter.router, + debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + theme: RTThemeData.themeData, + ); + } +} diff --git a/lib/components/rt_components.dart b/lib/components/rt_components.dart new file mode 100644 index 00000000..1097c598 --- /dev/null +++ b/lib/components/rt_components.dart @@ -0,0 +1,6 @@ +export 'package:restaurantour/components/rt_empty_widget.dart'; +export 'package:restaurantour/components/rt_error_widget.dart'; +export 'package:restaurantour/components/rt_image_network.dart'; +export 'package:restaurantour/components/rt_item_widget.dart'; +export 'package:restaurantour/components/rt_review_widget.dart'; +export 'package:restaurantour/components/rt_shimmer_loading.dart'; diff --git a/lib/components/rt_empty_widget.dart b/lib/components/rt_empty_widget.dart new file mode 100644 index 00000000..6a200e0d --- /dev/null +++ b/lib/components/rt_empty_widget.dart @@ -0,0 +1,27 @@ +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/theme/theme.dart'; + +class RTEmptyWidget extends StatelessWidget { + const RTEmptyWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: RTSizesType.xxl.size), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: RTSizesType.xxxg.size, + child: Icon(Icons.search, size: RTSizesType.xxxg.size), + ), + RTSizesType.xxxl.spacer, + Text( + AppLocalizations.of(context)!.emptyMessage, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/lib/components/rt_error_widget.dart b/lib/components/rt_error_widget.dart new file mode 100644 index 00000000..3b632e08 --- /dev/null +++ b/lib/components/rt_error_widget.dart @@ -0,0 +1,36 @@ +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/theme/theme.dart'; + +class RTErrorWidget extends StatelessWidget { + final Future Function()? onTryAgain; + const RTErrorWidget({super.key, this.onTryAgain}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.all(RTSizesType.xxl.size), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: RTSizesType.xxxg.size, child: SvgPicture.asset(Assets.svg.notFound)), + RTSizesType.xg.spacer, + Text( + AppLocalizations.of(context)!.errorMessage, + textAlign: TextAlign.center, + ), + if (onTryAgain != null) + Padding( + padding: EdgeInsets.only(top: RTSizesType.m.size), + child: TextButton( + onPressed: () => onTryAgain!(), + child: Text( + AppLocalizations.of(context)!.rtTryAgain, + style: RTTextStyle.textButton(), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/rt_image_network.dart b/lib/components/rt_image_network.dart new file mode 100644 index 00000000..104ef0eb --- /dev/null +++ b/lib/components/rt_image_network.dart @@ -0,0 +1,7 @@ +import 'package:restaurantour/core/core.dart'; + +class RTImageNetwork { + Widget build({required String location, required Widget errorWidget}) { + return Image.network(fit: BoxFit.cover, location, errorBuilder: (_, __, ___) => errorWidget); + } +} diff --git a/lib/components/rt_item_widget.dart b/lib/components/rt_item_widget.dart new file mode 100644 index 00000000..15b39061 --- /dev/null +++ b/lib/components/rt_item_widget.dart @@ -0,0 +1,115 @@ +import 'package:restaurantour/components/rt_image_network.dart'; +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/theme/theme.dart'; + +class RTItemWidget extends StatelessWidget { + const RTItemWidget({ + super.key, + required this.restaurant, + required this.isFirstItem, + required this.imageNetwork, + required this.openDetails, + }); + final bool isFirstItem; + final RestaurantDto restaurant; + final Function() openDetails; + final RTImageNetwork imageNetwork; + + double get rating => restaurant.rating ?? 0; + String get heroImage => restaurant.heroImage; + String get priceLabel => restaurant.price ?? ''; + List get starRate => List.generate(rating > 5 ? 5 : rating.toInt(), (_) => SvgPicture.asset(Assets.svg.star)); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + return Padding( + padding: EdgeInsets.all(RTSizesType.m.size).copyWith(top: isFirstItem ? RTSizesType.xl.size : 0), + child: Material( + color: theme.whiteColor, + borderRadius: BorderRadius.all(RTSizesType.s.radius), + elevation: RTSizesType.xxxs.size, + child: InkWell( + onTap: openDetails, + child: SizedBox( + height: RTSizesType.xgg.size, + child: Padding( + padding: EdgeInsets.all(RTSizesType.s.size), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.all(RTSizesType.s.radius), + child: SizedBox( + width: RTSizesType.gg.size, + height: RTSizesType.gg.size, + child: Hero( + tag: restaurant.name ?? '', + child: imageNetwork.build( + location: heroImage, + errorWidget: Container( + color: theme.placeholderColor, + child: const Icon(Icons.image_not_supported_rounded), + ), + ), + ), + ), + ), + RTSizesType.m.spacer, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + restaurant.name ?? '', + maxLines: 2, + style: RTTextStyle.subtitle1(), + ), + ), + Text( + '$priceLabel ${restaurant.displayCategory}', + style: RTTextStyle.caption(), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: starRate), + Row( + children: [ + Text( + restaurant.isOpen + ? AppLocalizations.of(context)!.restaurantListAllRestaurantsTabOpenNow + : AppLocalizations.of(context)!.restaurantListAllRestaurantsTabClosed, + style: RTTextStyle.overline(), + ), + RTSizesType.s.spacer, + Padding( + padding: EdgeInsets.only(top: RTSizesType.xxxs.size), + child: Container( + width: RTSizesType.s.size, + height: RTSizesType.s.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: restaurant.isOpen ? theme.openColor : theme.closedColor, + ), + ), + ), + ], + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/components/rt_review_widget.dart b/lib/components/rt_review_widget.dart new file mode 100644 index 00000000..34d6511d --- /dev/null +++ b/lib/components/rt_review_widget.dart @@ -0,0 +1,67 @@ +import 'package:restaurantour/components/rt_image_network.dart'; +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/theme/theme.dart'; + +class RTReviewWidget extends StatelessWidget { + const RTReviewWidget({super.key, required this.review, required this.isFirstItem, required this.imageNetwork}); + final bool isFirstItem; + final ReviewDto review; + final RTImageNetwork imageNetwork; + int get rating => review.rating ?? 0; + List get starRate => List.generate(rating > 5 ? 5 : rating.toInt(), (_) => SvgPicture.asset(Assets.svg.star)); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return Column( + children: [ + if (!isFirstItem) Divider(color: theme.dividerLineColor), + Padding( + padding: EdgeInsets.symmetric(vertical: RTSizesType.xl.size), + child: SizedBox( + height: RTSizesType.xxxgg.size, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: starRate), + RTSizesType.s.spacer, + Text( + review.text ?? '', + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: RTTextStyle.body1(), + ), + RTSizesType.s.spacer, + Row( + children: [ + SizedBox( + width: RTSizesType.xg.size, + height: RTSizesType.xg.size, + child: ClipRRect( + borderRadius: BorderRadius.circular(RTSizesType.xxxxgg.size), + child: imageNetwork.build( + location: review.user?.imageUrl ?? '', + errorWidget: Container( + color: theme.placeholderColor, + child: const Icon(Icons.person), + ), + ), + ), + ), + RTSizesType.s.spacer, + Text( + review.user?.name ?? '', + style: RTTextStyle.caption(), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/components/rt_shimmer_loading.dart b/lib/components/rt_shimmer_loading.dart new file mode 100644 index 00000000..a1e5d9c1 --- /dev/null +++ b/lib/components/rt_shimmer_loading.dart @@ -0,0 +1,105 @@ +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/theme/theme.dart'; +import 'package:shimmer/shimmer.dart'; + +class RTShimmerLoading extends StatelessWidget { + const RTShimmerLoading({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + return IgnorePointer( + child: Shimmer.fromColors( + baseColor: theme.shimmerBaseColor, + highlightColor: theme.shimmerHighlightColor, + child: Padding( + padding: EdgeInsets.only(top: RTSizesType.xs.size), + child: ListView( + children: List.generate(15, (index) => const TileLoading()), + ), + ), + ), + ); + } +} + +class TileLoading extends StatelessWidget { + const TileLoading({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + + return Padding( + padding: EdgeInsets.all(RTSizesType.m.size), + child: Container( + decoration: BoxDecoration( + border: Border.all(strokeAlign: RTSizesType.xxxxs.size, color: theme.dividerLineColor), + borderRadius: BorderRadius.all(RTSizesType.s.radius), + ), + height: RTSizesType.xgg.size, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.all(RTSizesType.s.size), + child: Container( + height: RTSizesType.gg.size, + width: RTSizesType.gg.size, + decoration: BoxDecoration( + color: theme.placeholderColor, + borderRadius: BorderRadius.all(RTSizesType.s.radius), + ), + ), + ), + Expanded( + child: Padding( + padding: EdgeInsets.all(RTSizesType.m.size), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: RTSizesType.xxl.size, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(RTSizesType.s.radius), + color: theme.placeholderColor, + ), + ), + RTSizesType.xxs.spacer, + Container( + height: RTSizesType.xxl.size, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(RTSizesType.s.radius), + color: theme.placeholderColor, + ), + ), + RTSizesType.xxs.spacer, + Expanded( + child: Container( + height: RTSizesType.xxl.size, + width: RTSizesType.xg.size, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(RTSizesType.s.radius), + color: theme.placeholderColor, + ), + ), + ), + RTSizesType.xxs.spacer, + Container( + height: RTSizesType.m.size, + width: RTSizesType.xxxg.size, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(RTSizesType.s.radius), + color: theme.placeholderColor, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/core/core.dart b/lib/core/core.dart new file mode 100644 index 00000000..4604db89 --- /dev/null +++ b/lib/core/core.dart @@ -0,0 +1,12 @@ +export 'package:flutter/material.dart'; +export 'package:flutter_gen/gen_l10n/app_localizations.dart'; +export 'package:flutter_localizations/flutter_localizations.dart'; +export 'package:flutter_svg/svg.dart'; +export 'package:get_it/get_it.dart'; +export 'package:go_router/go_router.dart'; +export 'package:provider/provider.dart'; +export 'package:restaurantour/core/inject.dart'; +export 'package:restaurantour/core/interceptor.dart'; +export 'package:restaurantour/core/logger.dart'; +export 'package:restaurantour/core/routes.dart'; +export 'package:restaurantour/gen/assets.gen.dart'; diff --git a/lib/core/inject.dart b/lib/core/inject.dart new file mode 100644 index 00000000..350b5989 --- /dev/null +++ b/lib/core/inject.dart @@ -0,0 +1,56 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:get_it/get_it.dart'; +import 'package:restaurantour/components/rt_components.dart'; +import 'package:restaurantour/core/interceptor.dart'; +import 'package:restaurantour/core/logger.dart'; +import 'package:restaurantour/core/routes.dart'; +import 'package:restaurantour/main.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +GetIt inject = GetIt.instance; + +Future setupInjection() async { + RTLogger.i(message: 'Setup injection'); + + String _apiKey = ''; + const String fileName = kIsProd ? '.env' : '.env_stage'; + + try { + await dotenv.load(fileName: fileName); + _apiKey = dotenv.env['API_KEY'] ?? ''; + if (_apiKey.isEmpty) RTLogger.w(message: 'API_KEY is empty'); + } catch (exception) { + RTLogger.e(message: 'Fail to load API_KEY, looking for $fileName file', exception: exception); + } + + await inject.reset(); + SharedPreferences sharedPref = await SharedPreferences.getInstance(); + + inject.registerLazySingleton( + () => Dio( + BaseOptions( + baseUrl: 'https://api.yelp.com', + headers: { + 'Authorization': 'Bearer $_apiKey', + 'Content-Type': 'application/graphql', + }, + ), + )..interceptors.add(LoggerInterceptor()), + ); + + inject.registerLazySingleton(() => RestaurantRepository(dio: inject())); + + inject.registerLazySingleton(() => EventBusService()); + + inject.registerLazySingleton(() => sharedPref); + + inject.registerLazySingleton(() => FavoriteService(sharedPreferences: inject())); + + inject.registerLazySingleton(() => RTImageNetwork()); + + inject.registerLazySingleton(() => GoRouterConfig()); +} diff --git a/lib/core/interceptor.dart b/lib/core/interceptor.dart new file mode 100644 index 00000000..d3c64da8 --- /dev/null +++ b/lib/core/interceptor.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:restaurantour/core/logger.dart'; + +class LoggerInterceptor extends Interceptor { + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + final options = err.requestOptions; + final requestPath = '${options.baseUrl}${options.path}'; + + RTLogger.e(message: 'onError: ${options.method} request => $requestPath', exception: err); + RTLogger.e(message: 'onError: ${err.error}, Message: ${err.message}', exception: err); + + return super.onError(err, handler); + } + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final requestPath = '${options.baseUrl}${options.path}'; + RTLogger.l(message: 'onRequest: ${options.method} request => $requestPath'); + RTLogger.l(message: 'onRequest: Request Headers => ${options.headers}'); + RTLogger.l(message: 'onRequest: Request Data => ${_prettyJsonEncode(options.data)}'); + + return super.onRequest(options, handler); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + RTLogger.i(message: 'onResponse: StatusCode: ${response.statusCode}, Data: ${_prettyJsonEncode(response.data)}'); + + return super.onResponse(response, handler); + } + + String _prettyJsonEncode(dynamic data) { + try { + const encoder = JsonEncoder.withIndent(' '); + final jsonString = encoder.convert(data); + return jsonString; + } catch (e) { + return data.toString(); + } + } +} diff --git a/lib/core/logger.dart b/lib/core/logger.dart new file mode 100644 index 00000000..35d13f02 --- /dev/null +++ b/lib/core/logger.dart @@ -0,0 +1,78 @@ +import 'dart:developer' as dev; + +import 'package:flutter/foundation.dart'; + +enum RTLoggerType { + debug, + error, + info, + log, + warning, +} + +extension RTLoggerTypeExt on RTLoggerType { + String get icon => [ + '🐛', // Debug + '🚨', // Error + '💡', // Info + '🪧', // Log + '🚧', // Warning + ][index]; + + String get label => [ + 'Debug', + 'Error', + 'Info', + 'Log', + 'Warning', + ][index]; + + String get _color => [ + "\u001b[32m", // Debug + "\u001b[31m", // Error + "\u001b[36m", // Info + "\u001b[37m", // Log + "\u001b[33m", // Warning + ][index]; + + String get _end => "\x1B[0m"; + + void log(String message) => dev.log(_color + icon + ' ' + label + ': ' + message + _end); +} + +class RTLogger { + static void _log({ + required RTLoggerType type, + required String message, + Object? exception, + Object? stackTrace, + Object? extraData, + }) { + if (kDebugMode) { + final exceptionText = exception != null ? '\nException:\n$exception' : ''; + final stackTraceText = stackTrace != null ? '\nStackTrace:\n$stackTrace' : ''; + final extraDataText = extraData != null ? '\nExtraData:\n$extraData' : ''; + type.log('$message $exceptionText $stackTraceText $extraDataText'); + } + } + + static void d({required String message, Object? exception, Object? stackTrace, Object? extraData}) { + _log(type: RTLoggerType.debug, message: message, exception: exception, stackTrace: stackTrace, extraData: extraData); + } + + static void e({required String message, required Object exception, Object? stackTrace, Object? extraData}) { + _log(type: RTLoggerType.error, message: message, exception: exception, stackTrace: stackTrace, extraData: extraData); + } + + static void i({required String message, Object? extraData}) { + _log(type: RTLoggerType.info, message: message, extraData: extraData); + } + + static void l({required String message, Object? extraData}) { + _log(type: RTLoggerType.log, message: message, extraData: extraData); + } + + static void w({required String message, Object? extraData}) { + _log(type: RTLoggerType.warning, message: message, extraData: extraData); + } +} diff --git a/lib/core/queries.dart b/lib/core/queries.dart new file mode 100644 index 00000000..682a738a --- /dev/null +++ b/lib/core/queries.dart @@ -0,0 +1,101 @@ +class RTQueries { + static String getRestaurantsQuery(int offset) { + return ''' +query restaurantsQuery { + search(location: "Las Vegas", limit: 20, offset: $offset) { + business { + id + name + price + rating + photos + categories { + title + alias + } + hours { + is_open_now + } + } + total + } +} +'''; + } + + static String getReviewsQuery({required String restaurantId, required int offset}) { + return ''' +query reviewsQuery { + reviews(business: "$restaurantId", limit: 10, offset: $offset) { + total + review { + text + rating + user { + id + name + image_url + } + } + } +} +'''; + } + + static String getRestaurantDetailsQuery({required String restaurantId}) { + return ''' +query restaurantDetailsQuery { + business(id: "$restaurantId") { + alias + categories { + alias + title + } + hours { + is_open_now + } + location { + formatted_address + } + rating + id + photos + name + price + } +} +'''; + } + + static String getSingleRestaurantQuery({required String restaurantId}) { + return ''' +query singleRestaurantQuery { + business(id: "$restaurantId") { + alias + categories { + alias + title + } + hours { + is_open_now + } + rating + id + photos + name + price + } +} +'''; + } + + static String getTotalReviewForRestaurantQuery({required String restaurantId}) { + return ''' +query totalReviewForRestaurantQuery { + reviews(business: "$restaurantId") { + total + } +} +'''; + } +} diff --git a/lib/core/routes.dart b/lib/core/routes.dart new file mode 100644 index 00000000..3b1bebf4 --- /dev/null +++ b/lib/core/routes.dart @@ -0,0 +1,35 @@ +import 'package:go_router/go_router.dart'; +import 'package:restaurantour/features/details/details_screen.dart'; +import 'package:restaurantour/features/restaurants/restaurants_screen.dart'; + +class RTRoute { + final String name; + final String path; + final List params; + + RTRoute({required this.name, required this.path, this.params = const []}); +} + +class GoRouterConfig { + static RTRoute home = RTRoute(name: 'home', path: '/'); + static RTRoute details = RTRoute(name: 'details', path: '/details/:restaurant_id', params: ['restaurant_id']); + + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + name: GoRouterConfig.home.name, + path: GoRouterConfig.home.path, + builder: (context, state) => const RestaurantsScreen(), + ), + GoRoute( + name: GoRouterConfig.details.name, + path: GoRouterConfig.details.path, + builder: (context, state) { + final restaurantId = state.pathParameters[details.params[0]]; + return DetailsScreen.create(restaurantId: restaurantId); + }, + ), + ], + ); +} diff --git a/lib/features/details/details_screen.dart b/lib/features/details/details_screen.dart new file mode 100644 index 00000000..ad96fe3b --- /dev/null +++ b/lib/features/details/details_screen.dart @@ -0,0 +1,258 @@ +import 'package:restaurantour/components/rt_components.dart'; +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/features/details/details_view_model.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; +import 'package:restaurantour/theme/theme.dart'; + +class DetailsScreen extends StatefulWidget { + const DetailsScreen({super.key}); + static String route = GoRouterConfig.details.name; + static String restaurantIdParams = GoRouterConfig.details.params.first; + + static Widget create({required String? restaurantId}) => ChangeNotifierProvider( + create: (context) => DetailsViewModel( + restaurantId: restaurantId ?? '', + restaurantRepository: inject(), + favoriteService: inject(), + eventBus: inject(), + ), + child: const DetailsScreen(), + ); + + @override + State createState() => _DetailsScreenState(); +} + +class _DetailsScreenState extends State { + final RTImageNetwork imageNetwork = inject(); + + int get reviewsCount => model!.reviews.length; + List get reviewsList => model!.reviews; + RestaurantDto get restaurant => model!.restaurant; + + DetailsViewModel? model; + final ScrollController scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + model = context.read(); + WidgetsBinding.instance.addPostFrameCallback((_) async => await model!.load()); + scrollController.addListener(_scrollListener); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + _scrollListener() { + final spaceBeforeEndOfScroll = RTSizesType.xxg.size; + if (scrollController.offset >= scrollController.position.maxScrollExtent - spaceBeforeEndOfScroll && !scrollController.position.outOfRange) { + model!.paginateReviews(); + } + } + + @override + Widget build(BuildContext context) { + model = context.watch(); + final theme = Theme.of(context).extension()!; + + if (model!.status.isLoading) { + return Scaffold( + appBar: AppBar(), + body: const Center(child: CircularProgressIndicator()), + ); + } + + if (model!.status.isError) { + return Scaffold( + appBar: AppBar(), + body: const RTErrorWidget(), + ); + } + + return Scaffold( + appBar: AppBar( + title: Text( + model!.restaurant.name ?? '', + style: RTTextStyle.headingH6(), + ), + actions: [ + model!.status.isAddingFavorite + ? Padding( + padding: EdgeInsets.only(right: RTSizesType.l.size), + child: SizedBox( + width: RTSizesType.xxl.size, + height: RTSizesType.xxl.size, + child: CircularProgressIndicator(strokeWidth: RTSizesType.xxxs.size), + ), + ) + : IconButton( + key: const Key('favorite-button'), + icon: Icon( + (model!.isFavorite) ? Icons.favorite : Icons.favorite_border, + color: theme.primaryFillColor, + ), + onPressed: () => model!.toggleFavorite(), + ), + ], + ), + body: ListView( + controller: scrollController, + children: [ + SizedBox( + height: RTSizesType.xxxxxgg.size, + child: Hero( + tag: restaurant.name ?? '', + child: imageNetwork.build( + location: restaurant.heroImage, + errorWidget: Icon(Icons.image_not_supported_rounded, size: RTSizesType.xxgg.size), + ), + ), + ), + Padding( + padding: EdgeInsets.all(RTSizesType.g.size), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + restaurant.price ?? '', + style: RTTextStyle.caption(), + ), + RTSizesType.xxs.spacer, + Text( + restaurant.displayCategory, + style: RTTextStyle.caption(), + ), + ], + ), + Row( + children: [ + Text( + restaurant.isOpen + ? AppLocalizations.of(context)!.restaurantListAllRestaurantsTabOpenNow + : AppLocalizations.of(context)!.restaurantListAllRestaurantsTabClosed, + style: RTTextStyle.overline(), + ), + RTSizesType.s.spacer, + Padding( + padding: EdgeInsets.only(top: RTSizesType.xxxs.size), + child: Container( + width: RTSizesType.s.size, + height: RTSizesType.s.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: restaurant.isOpen ? theme.openColor : theme.closedColor, + ), + ), + ), + ], + ), + ], + ), + const _Divider(), + Text( + AppLocalizations.of(context)!.restaurantDetailAddress, + style: RTTextStyle.caption(), + ), + RTSizesType.g.spacer, + Text( + restaurant.location?.formattedAddress ?? '', + style: RTTextStyle.body2(), + ), + const _Divider(), + Text( + AppLocalizations.of(context)!.restaurantDetailOverallRating, + style: RTTextStyle.caption(), + ), + Padding( + padding: EdgeInsets.only(top: RTSizesType.xl.size), + child: Row( + children: [ + Text( + '${restaurant.rating ?? 0.0}', + style: RTTextStyle.headingH4(), + ), + Padding( + padding: EdgeInsets.only(top: RTSizesType.m.size), + child: SvgPicture.asset(Assets.svg.star), + ), + ], + ), + ), + const _Divider(), + Text( + AppLocalizations.of(context)!.restaurantDetailReviews(model!.totalReviews), + style: RTTextStyle.caption(), + ), + _Reviews(reviewsList: reviewsList, isPaginating: model!.status.isPaginating), + ], + ), + ), + ], + ), + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).extension()!; + return Padding( + padding: EdgeInsets.symmetric(vertical: RTSizesType.g.size), + child: Divider(color: theme.dividerLineColor), + ); + } +} + +class _Reviews extends StatefulWidget { + final List reviewsList; + final bool isPaginating; + const _Reviews({required this.reviewsList, required this.isPaginating}); + @override + State<_Reviews> createState() => _ReviewsState(); +} + +class _ReviewsState extends State<_Reviews> { + final RTImageNetwork imageNetwork = inject(); + + @override + Widget build(BuildContext context) { + return Column( + children: List.generate(widget.reviewsList.length, (index) { + final bool isFirstItem = index == 0; + return Column( + children: [ + RTReviewWidget(imageNetwork: imageNetwork, isFirstItem: isFirstItem, review: widget.reviewsList[index]), + if (index + 1 == widget.reviewsList.length) + Visibility( + visible: widget.isPaginating, + child: Padding( + padding: EdgeInsets.only(bottom: RTSizesType.m.size), + child: SizedBox( + height: RTSizesType.xxg.size, + width: RTSizesType.xxg.size, + child: const Center(child: CircularProgressIndicator()), + ), + ), + ), + ], + ); + }), + ); + } +} diff --git a/lib/features/details/details_view_model.dart b/lib/features/details/details_view_model.dart new file mode 100644 index 00000000..4d208339 --- /dev/null +++ b/lib/features/details/details_view_model.dart @@ -0,0 +1,124 @@ +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; + +enum DetailsStatus { loading, paginating, content, error, updatingFavorite } + +extension DetailsStatusExt on DetailsStatus { + bool get isLoading => this == DetailsStatus.loading; + bool get isPaginating => this == DetailsStatus.paginating; + bool get isAddingFavorite => this == DetailsStatus.updatingFavorite; + bool get isError => this == DetailsStatus.error; +} + +class DetailsViewModel with ChangeNotifier { + DetailsStatus status = DetailsStatus.loading; + bool isFavorite = false; + RestaurantDto restaurant = const RestaurantDto(); + ReviewQueryResultDto? reviewsQuery; + List reviews = []; + + final paginationSize = 20; + final String restaurantId; + final FavoriteService favoriteService; + final RestaurantRepository restaurantRepository; + final EventBusService eventBus; + List _favoriteList = []; + + int get totalReviews => reviewsQuery?.total ?? 0; + bool get shouldPaginate => reviews.length < totalReviews && totalReviews > paginationSize; + + DetailsViewModel({ + required this.favoriteService, + required this.restaurantRepository, + required this.restaurantId, + required this.eventBus, + }); + Future toggleFavorite() async { + try { + _emitChangingFavorite(); + isFavorite ? await favoriteService.removeFavorite(restaurantId) : await favoriteService.addFavorite(restaurantId); + + isFavorite = !isFavorite; + eventBus.fire(EventBusType.toggleFavorite); + } catch (exception, stackTrace) { + RTLogger.e(message: 'Fail to toggle favorite', exception: exception, stackTrace: stackTrace); + } finally { + _emitContent(); + } + } + + Future load() async { + try { + _emitLoading(); + await Future.wait([ + _getRestaurantDetails(), + _getReviews(), + ]); + _favoriteList = await favoriteService.getFavorites(); + isFavorite = _favoriteList.contains(restaurantId); + _emitContent(); + } catch (exception, stackTrace) { + RTLogger.e(message: 'Fail to Load Restaurant Details', exception: exception, stackTrace: stackTrace); + _emitError(); + } + } + + Future _getRestaurantDetails() async { + try { + restaurant = await restaurantRepository.getRestaurantDetails(restaurantId: restaurantId); + } catch (exception, stackTrace) { + RTLogger.e(message: 'Fail to get Restaurant Details', exception: exception, stackTrace: stackTrace); + rethrow; + } + } + + Future _getReviews() async { + try { + reviewsQuery = await restaurantRepository.getReviews(restaurantId: restaurantId); + reviews.addAll(reviewsQuery?.review ?? []); + } catch (exception, stackTrace) { + RTLogger.e(message: 'Fail to get Restaurant Reviews', exception: exception, stackTrace: stackTrace); + } + } + + Future paginateReviews() async { + if (status.isPaginating || !shouldPaginate) return; + try { + _emitIsPaginating(); + final paginated = await restaurantRepository.getReviews(restaurantId: restaurantId, offset: reviews.length); + reviews.addAll(paginated?.review ?? []); + } catch (exception, stackTrace) { + RTLogger.e(message: 'Fail to paginate reviews', exception: exception, stackTrace: stackTrace); + } finally { + _emitContent(); + } + } + + void _emitContent() { + status = DetailsStatus.content; + notifyListeners(); + } + + void _emitChangingFavorite() { + status = DetailsStatus.updatingFavorite; + notifyListeners(); + } + + void _emitLoading() { + status = DetailsStatus.loading; + notifyListeners(); + } + + void _emitError() { + status = DetailsStatus.error; + notifyListeners(); + } + + void _emitIsPaginating() { + status = DetailsStatus.paginating; + notifyListeners(); + } +} diff --git a/lib/features/restaurants/pages/favorites/favorites_page.dart b/lib/features/restaurants/pages/favorites/favorites_page.dart new file mode 100644 index 00000000..ef98a20a --- /dev/null +++ b/lib/features/restaurants/pages/favorites/favorites_page.dart @@ -0,0 +1,52 @@ +import 'package:restaurantour/components/rt_components.dart'; +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/features/details/details_screen.dart'; +import 'package:restaurantour/features/restaurants/pages/favorites/favorites_view_model.dart'; + +class FavoritesPage extends StatefulWidget { + const FavoritesPage({super.key}); + + @override + State createState() => _FavoritesPageState(); +} + +class _FavoritesPageState extends State { + FavoritesViewModel? model; + + @override + void initState() { + super.initState(); + model = context.read(); + } + + @override + Widget build(BuildContext context) { + model = context.watch(); + + if (model!.status.isLoading) return const RTShimmerLoading(); + + if (model!.status.isError) return const RTErrorWidget(); + + if (model!.status.isEmpty) return const RTEmptyWidget(); + + return ListView.builder( + itemCount: model!.favoritesRestaurantList.length, + itemBuilder: (context, index) { + final isFirstItem = index == 0; + + return RTItemWidget( + key: Key('favorite-restaurant-$index'), + isFirstItem: isFirstItem, + imageNetwork: inject(), + restaurant: model!.favoritesRestaurantList[index], + openDetails: () => context.pushNamed( + DetailsScreen.route, + pathParameters: { + DetailsScreen.restaurantIdParams: model!.favoritesRestaurantList[index].id ?? '', + }, + ), + ); + }, + ); + } +} diff --git a/lib/features/restaurants/pages/favorites/favorites_view_model.dart b/lib/features/restaurants/pages/favorites/favorites_view_model.dart new file mode 100644 index 00000000..04752dd3 --- /dev/null +++ b/lib/features/restaurants/pages/favorites/favorites_view_model.dart @@ -0,0 +1,131 @@ +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; + +enum FavoritesStatus { loading, content, error, empty } + +extension FavoritesStatusExt on FavoritesStatus { + bool get isLoading => this == FavoritesStatus.loading; + bool get isError => this == FavoritesStatus.error; + bool get isEmpty => this == FavoritesStatus.empty; +} + +class FavoritesViewModel with ChangeNotifier { + final RestaurantRepository restaurantRepository; + final FavoriteService favoritesService; + final EventBusService eventBus; + + final List _restaurantsCache = []; + + FavoritesViewModel({required this.favoritesService, required this.restaurantRepository, required this.eventBus}); + FavoritesViewModel.create({required this.favoritesService, required this.restaurantRepository, required this.eventBus}) { + _onCreate(); + } + + FavoritesStatus status = FavoritesStatus.loading; + + RestaurantQueryResultDto? _restaurantsQuery; + List _favorites = []; + + int get allRestaurantsQueryTotal => _restaurantsQuery?.total ?? 0; + List get favoritesRestaurantList => _favorites; + List get restaurantsList => _restaurantsQuery?.restaurants ?? []; + + StreamSubscription? eventBusSubscription; + + Future _onCreate() async { + await getRestaurants(); + await getFavorites(); + } + + @override + void addListener(VoidCallback listener) { + super.addListener(listener); + eventBusSubscription = eventBus.stream.listen((event) => event.onEvent(eventType: EventBusType.toggleFavorite, function: () => getFavorites())); + } + + @override + dispose() { + if (eventBusSubscription != null) eventBusSubscription!.cancel(); + super.dispose(); + } + + Future getRestaurants() async { + try { + _emitLoading(); + _restaurantsQuery = await restaurantRepository.getRestaurants(); + restaurantsList.isEmpty ? _emitEmpty() : _emitContent(); + } catch (exception, stackTrace) { + RTLogger.e(message: 'Fail to load Restaurants', exception: exception, stackTrace: stackTrace); + _emitError(); + } + } + + Future getFavorites() async { + try { + _emitLoading(); + final favoritesIds = await favoritesService.getFavorites(); + + _favorites = restaurantsList.where((restaurant) => favoritesIds.contains(restaurant.id)).toList(); + final favoritesToFetchInCache = await _findFavoritesNotLoadedInMemory(inMemory: restaurantsList, favoritesIds: favoritesIds); + + _favorites.addAll(_restaurantsCache.where((restaurant) => favoritesToFetchInCache.contains(restaurant.id)).toList()); + final favoritesToFetch = await _findFavoritesNotLoadedInMemory(inMemory: _restaurantsCache, favoritesIds: favoritesIds); + + await _loadFavoritesInMemory(favoritesToFetch); + _favorites.isEmpty ? _emitEmpty() : _emitContent(); + } catch (exception, stackTrace) { + RTLogger.e(message: 'Fail to load Favorites', exception: exception, stackTrace: stackTrace); + _emitError(); + } + } + + Future> _findFavoritesNotLoadedInMemory({required List inMemory, required List favoritesIds}) async { + inMemory.map((restaurant) { + if (favoritesIds.contains(restaurant.id)) favoritesIds.remove(restaurant.id); + }).toList(); + return favoritesIds; + } + + Future _loadFavoritesInMemory(List favorites) async { + List loadFavoritesList = []; + for (var favoriteId in favorites) { + loadFavoritesList.add(_loadSingleFavorite(favoriteId)); + } + await Future.wait(loadFavoritesList); + } + + Future _loadSingleFavorite(String favoriteId) async { + try { + RTLogger.w(message: 'Loading single favorite $favoriteId'); + final _restaurant = await restaurantRepository.getSingleRestaurant(restaurantId: favoriteId); + _favorites.add(_restaurant); + _restaurantsCache.add(_restaurant); + } catch (exception, stackTrace) { + RTLogger.e(message: 'Fail to load detail of Favorite', exception: exception, stackTrace: stackTrace); + _favorites.add(RestaurantDto(id: favoriteId)); + } + } + + void _emitContent() { + status = FavoritesStatus.content; + notifyListeners(); + } + + void _emitLoading() { + status = FavoritesStatus.loading; + notifyListeners(); + } + + void _emitError() { + status = FavoritesStatus.error; + notifyListeners(); + } + + void _emitEmpty() { + status = FavoritesStatus.empty; + notifyListeners(); + } +} diff --git a/lib/features/restaurants/pages/restaurants/restaurants_page.dart b/lib/features/restaurants/pages/restaurants/restaurants_page.dart new file mode 100644 index 00000000..8907f22f --- /dev/null +++ b/lib/features/restaurants/pages/restaurants/restaurants_page.dart @@ -0,0 +1,87 @@ +import 'package:restaurantour/components/rt_components.dart'; +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/features/details/details_screen.dart'; +import 'package:restaurantour/features/restaurants/pages/restaurants/restaurants_view_model.dart'; +import 'package:restaurantour/theme/theme.dart'; + +class RestaurantsPage extends StatefulWidget { + const RestaurantsPage({super.key}); + + @override + State createState() => _RestaurantsPageState(); +} + +class _RestaurantsPageState extends State { + RestaurantsViewModel? model; + final ScrollController scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + model = context.read(); + scrollController.addListener(_scrollListener); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + _scrollListener() { + final spaceBeforeEndOfScroll = RTSizesType.xxg.size; + if (scrollController.offset >= scrollController.position.maxScrollExtent - spaceBeforeEndOfScroll && !scrollController.position.outOfRange) { + model!.paginateRestaurants(); + } + } + + @override + Widget build(BuildContext context) { + model = context.watch(); + + if (model!.restaurantsStatus.isLoading) return const RTShimmerLoading(); + + if (model!.restaurantsStatus.isError) return RTErrorWidget(onTryAgain: model!.loadRestaurants); + + if (model!.restaurantsStatus.isEmpty) return const RTEmptyWidget(); + + return RefreshIndicator( + onRefresh: model!.loadRestaurants, + child: ListView.builder( + controller: scrollController, + itemCount: model!.restaurantsList.length, + itemBuilder: (context, index) { + final isFirstItem = index == 0; + return Column( + children: [ + RTItemWidget( + key: Key('restaurant-$index'), + isFirstItem: isFirstItem, + imageNetwork: inject(), + restaurant: model!.restaurantsList[index], + openDetails: () { + context.pushNamed( + DetailsScreen.route, + pathParameters: {DetailsScreen.restaurantIdParams: model!.restaurantsList[index].id ?? ''}, + ); + }, + ), + if (index + 1 == model!.restaurantsList.length) + Visibility( + visible: model!.restaurantsStatus.isPaginating, + child: Padding( + padding: EdgeInsets.only(bottom: RTSizesType.m.size), + child: SizedBox( + height: RTSizesType.xxg.size, + width: RTSizesType.xxg.size, + child: const Center(child: CircularProgressIndicator()), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/features/restaurants/pages/restaurants/restaurants_view_model.dart b/lib/features/restaurants/pages/restaurants/restaurants_view_model.dart new file mode 100644 index 00000000..6060e572 --- /dev/null +++ b/lib/features/restaurants/pages/restaurants/restaurants_view_model.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/core/logger.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; + +enum RestaurantsStatus { paginating, loading, content, error, empty } + +extension RestaurantsStatusExt on RestaurantsStatus { + bool get isLoading => this == RestaurantsStatus.loading; + bool get isPaginating => this == RestaurantsStatus.paginating; + bool get isError => this == RestaurantsStatus.error; + bool get isEmpty => this == RestaurantsStatus.empty; +} + +class RestaurantsViewModel with ChangeNotifier { + final RestaurantRepository restaurantRepository; + + final int paginationSize = 20; + + RestaurantsViewModel({required this.restaurantRepository}); + + RestaurantsViewModel.create({required this.restaurantRepository}) { + _onCreate(); + } + + RestaurantsStatus restaurantsStatus = RestaurantsStatus.loading; + + RestaurantQueryResultDto? _restaurantsQuery; + + int get allRestaurantsQueryTotal => _restaurantsQuery?.total ?? 0; + + List get restaurantsList => _restaurantsQuery?.restaurants ?? []; + bool get shouldPaginate => restaurantsList.length < allRestaurantsQueryTotal && allRestaurantsQueryTotal > paginationSize; + + Future _onCreate() async { + await loadRestaurants(); + } + + Future loadRestaurants() async { + try { + _emitRestaurantLoading(); + _restaurantsQuery = await restaurantRepository.getRestaurants(); + restaurantsList.isEmpty ? _emitRestaurantEmpty() : _emitRestaurantContent(); + } catch (exception, stackTrace) { + RTLogger.e(message: 'Fail to load Restaurants', exception: exception, stackTrace: stackTrace); + _emitRestaurantError(); + } + } + + Future paginateRestaurants() async { + if (restaurantsStatus.isPaginating || !shouldPaginate) return; + try { + _emitRestaurantPaginating(); + final paginated = await restaurantRepository.getRestaurants(offset: restaurantsList.length); + restaurantsList.addAll(paginated?.restaurants ?? []); + } catch (exception, stackTrace) { + RTLogger.e(message: 'Fail to paginate Restaurants', exception: exception, stackTrace: stackTrace); + } finally { + _emitRestaurantContent(); + } + } + + void _emitRestaurantContent() { + restaurantsStatus = RestaurantsStatus.content; + notifyListeners(); + } + + void _emitRestaurantLoading() { + restaurantsStatus = RestaurantsStatus.loading; + notifyListeners(); + } + + void _emitRestaurantPaginating() { + restaurantsStatus = RestaurantsStatus.paginating; + notifyListeners(); + } + + void _emitRestaurantError() { + restaurantsStatus = RestaurantsStatus.error; + notifyListeners(); + } + + void _emitRestaurantEmpty() { + restaurantsStatus = RestaurantsStatus.empty; + notifyListeners(); + } +} diff --git a/lib/features/restaurants/restaurants_screen.dart b/lib/features/restaurants/restaurants_screen.dart new file mode 100644 index 00000000..94090f43 --- /dev/null +++ b/lib/features/restaurants/restaurants_screen.dart @@ -0,0 +1,78 @@ +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/features/restaurants/pages/favorites/favorites_page.dart'; +import 'package:restaurantour/features/restaurants/pages/favorites/favorites_view_model.dart'; +import 'package:restaurantour/features/restaurants/pages/restaurants/restaurants_page.dart'; +import 'package:restaurantour/features/restaurants/pages/restaurants/restaurants_view_model.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; +import 'package:restaurantour/theme/theme.dart'; + +class RestaurantsScreen extends StatefulWidget { + const RestaurantsScreen({super.key}); + static RTRoute route = GoRouterConfig.home; + + @override + State createState() => _RestaurantsScreenState(); +} + +class _RestaurantsScreenState extends State { + RestaurantsViewModel? model; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: Text( + AppLocalizations.of(context)!.restaurantListRestauranTour, + style: RTTextStyle.headingH6(), + ), + bottom: TabBar( + tabs: [ + Tab( + key: const Key('all-restaurants-tab'), + child: Text( + AppLocalizations.of(context)!.restaurantListAllRestaurantsTab, + style: RTTextStyle.button(), + ), + ), + Tab( + key: const Key('my-favorites-tab'), + child: Text( + AppLocalizations.of(context)!.restaurantListMyFavoritesTab, + style: RTTextStyle.button(), + ), + ), + ], + ), + ), + body: MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (context) => FavoritesViewModel.create( + eventBus: inject(), + favoritesService: inject(), + restaurantRepository: inject(), + ), + ), + ChangeNotifierProvider( + create: (context) => RestaurantsViewModel.create( + restaurantRepository: inject(), + ), + ), + ], + child: const TabBarView( + children: [ + RestaurantsPage(), + FavoritesPage(), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart new file mode 100644 index 00000000..d7babd6a --- /dev/null +++ b/lib/gen/assets.gen.dart @@ -0,0 +1,27 @@ +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: directives_ordering,unnecessary_import,implicit_dynamic_list_literal,deprecated_member_use + +class $AssetsSvgGen { + const $AssetsSvgGen(); + + /// File path: assets/svg/not_found.svg + String get notFound => 'assets/svg/not_found.svg'; + + /// File path: assets/svg/star.svg + String get star => 'assets/svg/star.svg'; + + /// List of all assets + List get values => [notFound, star]; +} + +class Assets { + Assets._(); + + static const $AssetsSvgGen svg = $AssetsSvgGen(); +} diff --git a/lib/gen/fonts.gen.dart b/lib/gen/fonts.gen.dart new file mode 100644 index 00000000..a993a752 --- /dev/null +++ b/lib/gen/fonts.gen.dart @@ -0,0 +1,18 @@ +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: directives_ordering,unnecessary_import,implicit_dynamic_list_literal,deprecated_member_use + +class FontFamily { + FontFamily._(); + + /// Font family: Lora + static const String lora = 'Lora'; + + /// Font family: OpenSans + static const String openSans = 'OpenSans'; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 00000000..5395a841 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,20 @@ +{ + "emptyMessage": "Oops! It seems like there are no items to display at the moment. Check back later for updates or add some items to your list to get started!", + "errorMessage": "Oops! Looks like we couldn't fetch the data. Please check your connection and try again later.", + "restaurantDetailAddress": "Address", + "restaurantDetailOverallRating": "Overall Rating", + "restaurantDetailReviews": "{count,plural, =1{{count} Review} other{{count} Reviews}}", + "@restaurantDetailReviews": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "restaurantListAllRestaurantsTab": "All Restaurants", + "restaurantListAllRestaurantsTabClosed": "Closed", + "restaurantListAllRestaurantsTabOpenNow": "Open Now", + "restaurantListMyFavoritesTab": "My Favorites", + "restaurantListRestauranTour": "RestauranTour", + "rtTryAgain": "Try Again ?" +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index c6ce7473..5702a2c5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,57 +1,27 @@ import 'package:flutter/material.dart'; -import 'package:restaurantour/repositories/yelp_repository.dart'; +import 'package:restaurantour/app.dart'; +import 'package:restaurantour/core/inject.dart'; +import 'package:restaurantour/core/logger.dart'; -void main() { - runApp(const Restaurantour()); -} +const String kAppFlavor = String.fromEnvironment('app.flavor', defaultValue: "prod"); -class Restaurantour extends StatelessWidget { - // This widget is the root of your application. - const Restaurantour({Key? key}) : super(key: key); +const String kFlavorStage = "stage"; +const String kFlavorProd = "prod"; +const String kFlavorLocal = "local"; - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'RestauranTour', - theme: ThemeData( - visualDensity: VisualDensity.adaptivePlatformDensity, - ), - home: const HomePage(), - ); - } -} +const bool kIsStage = kAppFlavor == kFlavorStage; +const bool kIsProd = kAppFlavor == kFlavorProd; +const bool kIsLocal = kAppFlavor == kFlavorLocal; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); -class HomePage extends StatelessWidget { - const HomePage({Key? key}) : super(key: key); + if (kIsStage) RTLogger.d(message: 'Running in Stage mode'); + if (kIsProd) RTLogger.w(message: 'Running in Prod mode'); + if (kIsLocal) RTLogger.i(message: 'Running in Local mode'); - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Restaurantour'), - ElevatedButton( - child: const Text('Fetch Restaurants'), - onPressed: () async { - final yelpRepo = YelpRepository(); + await setupInjection(); - try { - final result = await yelpRepo.getRestaurants(); - if (result != null) { - print('Fetched ${result.restaurants!.length} restaurants'); - } else { - print('No restaurants fetched'); - } - } catch (e) { - print('Failed to fetch restaurants: $e'); - } - }, - ), - ], - ), - ), - ); - } + RTLogger.i(message: 'Start app'); + runApp(const App()); } diff --git a/lib/models/category_dto.dart b/lib/models/category_dto.dart new file mode 100644 index 00000000..5f081057 --- /dev/null +++ b/lib/models/category_dto.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'category_dto.g.dart'; + +@JsonSerializable() +class CategoryDto { + final String? alias; + final String? title; + + CategoryDto({ + this.alias, + this.title, + }); + + factory CategoryDto.fromJson(Map json) => _$CategoryDtoFromJson(json); + factory CategoryDto.fixture() => CategoryDto(alias: 'newamerican', title: 'New American'); + + Map toJson() => _$CategoryDtoToJson(this); +} diff --git a/lib/models/category_dto.g.dart b/lib/models/category_dto.g.dart new file mode 100644 index 00000000..5baf0144 --- /dev/null +++ b/lib/models/category_dto.g.dart @@ -0,0 +1,18 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'category_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CategoryDto _$CategoryDtoFromJson(Map json) => CategoryDto( + alias: json['alias'] as String?, + title: json['title'] as String?, + ); + +Map _$CategoryDtoToJson(CategoryDto instance) => + { + 'alias': instance.alias, + 'title': instance.title, + }; diff --git a/lib/models/dto.dart b/lib/models/dto.dart new file mode 100644 index 00000000..4311cbea --- /dev/null +++ b/lib/models/dto.dart @@ -0,0 +1,8 @@ +export 'package:restaurantour/models/category_dto.dart'; +export 'package:restaurantour/models/hours_dto.dart'; +export 'package:restaurantour/models/location_dto.dart'; +export 'package:restaurantour/models/restaurant_dto.dart'; +export 'package:restaurantour/models/restaurant_query_result_dto.dart'; +export 'package:restaurantour/models/review_dto.dart'; +export 'package:restaurantour/models/review_query_result_dto.dart'; +export 'package:restaurantour/models/user_dto.dart'; diff --git a/lib/models/hours_dto.dart b/lib/models/hours_dto.dart new file mode 100644 index 00000000..6b0a0ca5 --- /dev/null +++ b/lib/models/hours_dto.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'hours_dto.g.dart'; + +@JsonSerializable() +class HoursDto { + @JsonKey(name: 'is_open_now') + final bool? isOpenNow; + + const HoursDto({this.isOpenNow}); + + factory HoursDto.fromJson(Map json) => _$HoursDtoFromJson(json); + factory HoursDto.fixture() => const HoursDto(isOpenNow: true); + + Map toJson() => _$HoursDtoToJson(this); +} diff --git a/lib/models/hours_dto.g.dart b/lib/models/hours_dto.g.dart new file mode 100644 index 00000000..de420029 --- /dev/null +++ b/lib/models/hours_dto.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'hours_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +HoursDto _$HoursDtoFromJson(Map json) => HoursDto( + isOpenNow: json['is_open_now'] as bool?, + ); + +Map _$HoursDtoToJson(HoursDto instance) => { + 'is_open_now': instance.isOpenNow, + }; diff --git a/lib/models/location_dto.dart b/lib/models/location_dto.dart new file mode 100644 index 00000000..dea210a2 --- /dev/null +++ b/lib/models/location_dto.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'location_dto.g.dart'; + +@JsonSerializable() +class LocationDto { + @JsonKey(name: 'formatted_address') + final String? formattedAddress; + + LocationDto({this.formattedAddress}); + + factory LocationDto.fromJson(Map json) => _$LocationDtoFromJson(json); + factory LocationDto.fixture() => LocationDto(formattedAddress: 'address'); + + Map toJson() => _$LocationDtoToJson(this); +} diff --git a/lib/models/location_dto.g.dart b/lib/models/location_dto.g.dart new file mode 100644 index 00000000..03018bd4 --- /dev/null +++ b/lib/models/location_dto.g.dart @@ -0,0 +1,16 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'location_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LocationDto _$LocationDtoFromJson(Map json) => LocationDto( + formattedAddress: json['formatted_address'] as String?, + ); + +Map _$LocationDtoToJson(LocationDto instance) => + { + 'formatted_address': instance.formattedAddress, + }; diff --git a/lib/models/restaurant.dart b/lib/models/restaurant.dart deleted file mode 100644 index 87c7aab5..00000000 --- a/lib/models/restaurant.dart +++ /dev/null @@ -1,155 +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 User? user; - - const Review({ - this.id, - this.rating, - this.user, - }); - - 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 3ed33f9a..00000000 --- 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/models/restaurant_dto.dart b/lib/models/restaurant_dto.dart new file mode 100644 index 00000000..416e1eb5 --- /dev/null +++ b/lib/models/restaurant_dto.dart @@ -0,0 +1,72 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:restaurantour/models/category_dto.dart'; +import 'package:restaurantour/models/hours_dto.dart'; +import 'package:restaurantour/models/location_dto.dart'; +import 'package:restaurantour/models/review_dto.dart'; + +part 'restaurant_dto.g.dart'; + +@JsonSerializable() +class RestaurantDto { + 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 LocationDto? location; + + const RestaurantDto({ + this.id, + this.name, + this.price, + this.rating, + this.photos, + this.categories, + this.hours, + this.reviews, + this.location, + }); + + factory RestaurantDto.fromJson(Map json) => _$RestaurantDtoFromJson(json); + factory RestaurantDto.fixture() => RestaurantDto( + id: 'restaurantId', + name: 'Restaurant Name', + price: '\$\$', + rating: 3.5, + photos: ['http://placeimg.com/640/480/business'], + categories: [CategoryDto.fixture()], + hours: [HoursDto.fixture()], + reviews: [ReviewDto.fixture()], + location: LocationDto.fixture(), + ); + + Map toJson() => _$RestaurantDtoToJson(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; + } +} diff --git a/lib/models/restaurant_dto.g.dart b/lib/models/restaurant_dto.g.dart new file mode 100644 index 00000000..1a4bf1dd --- /dev/null +++ b/lib/models/restaurant_dto.g.dart @@ -0,0 +1,42 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'restaurant_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RestaurantDto _$RestaurantDtoFromJson(Map json) => + RestaurantDto( + 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) => CategoryDto.fromJson(e as Map)) + .toList(), + hours: (json['hours'] as List?) + ?.map((e) => HoursDto.fromJson(e as Map)) + .toList(), + reviews: (json['reviews'] as List?) + ?.map((e) => ReviewDto.fromJson(e as Map)) + .toList(), + location: json['location'] == null + ? null + : LocationDto.fromJson(json['location'] as Map), + ); + +Map _$RestaurantDtoToJson(RestaurantDto 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, + }; diff --git a/lib/models/restaurant_query_result_dto.dart b/lib/models/restaurant_query_result_dto.dart new file mode 100644 index 00000000..16eb2896 --- /dev/null +++ b/lib/models/restaurant_query_result_dto.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:restaurantour/models/restaurant_dto.dart'; + +part 'restaurant_query_result_dto.g.dart'; + +@JsonSerializable() +class RestaurantQueryResultDto { + final int? total; + @JsonKey(name: 'business') + final List? restaurants; + + const RestaurantQueryResultDto({this.total, this.restaurants}); + + factory RestaurantQueryResultDto.fromJson(Map json) => _$RestaurantQueryResultDtoFromJson(json); + factory RestaurantQueryResultDto.fixture() => RestaurantQueryResultDto(restaurants: [RestaurantDto.fixture()]); + + Map toJson() => _$RestaurantQueryResultDtoToJson(this); +} diff --git a/lib/models/restaurant_query_result_dto.g.dart b/lib/models/restaurant_query_result_dto.g.dart new file mode 100644 index 00000000..85c7d90d --- /dev/null +++ b/lib/models/restaurant_query_result_dto.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'restaurant_query_result_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RestaurantQueryResultDto _$RestaurantQueryResultDtoFromJson( + Map json) => + RestaurantQueryResultDto( + total: json['total'] as int?, + restaurants: (json['business'] as List?) + ?.map((e) => RestaurantDto.fromJson(e as Map)) + .toList(), + ); + +Map _$RestaurantQueryResultDtoToJson( + RestaurantQueryResultDto instance) => + { + 'total': instance.total, + 'business': instance.restaurants, + }; diff --git a/lib/models/review_dto.dart b/lib/models/review_dto.dart new file mode 100644 index 00000000..86836705 --- /dev/null +++ b/lib/models/review_dto.dart @@ -0,0 +1,24 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:restaurantour/models/user_dto.dart'; + +part 'review_dto.g.dart'; + +@JsonSerializable() +class ReviewDto { + final String? id; + final int? rating; + final String? text; + final UserDto? user; + + const ReviewDto({this.text, this.id, this.rating, this.user}); + + factory ReviewDto.fromJson(Map json) => _$ReviewDtoFromJson(json); + factory ReviewDto.fixture() => ReviewDto( + id: 'reviewId', + rating: 3, + text: 'Review text goes here. Review text goes here. This is a review. This is a review that is 3 lines long.', + user: UserDto.fixture(), + ); + + Map toJson() => _$ReviewDtoToJson(this); +} diff --git a/lib/models/review_dto.g.dart b/lib/models/review_dto.g.dart new file mode 100644 index 00000000..f8d6977e --- /dev/null +++ b/lib/models/review_dto.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'review_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ReviewDto _$ReviewDtoFromJson(Map json) => ReviewDto( + text: json['text'] as String?, + id: json['id'] as String?, + rating: json['rating'] as int?, + user: json['user'] == null + ? null + : UserDto.fromJson(json['user'] as Map), + ); + +Map _$ReviewDtoToJson(ReviewDto instance) => { + 'id': instance.id, + 'rating': instance.rating, + 'text': instance.text, + 'user': instance.user, + }; diff --git a/lib/models/review_query_result_dto.dart b/lib/models/review_query_result_dto.dart new file mode 100644 index 00000000..5c50690b --- /dev/null +++ b/lib/models/review_query_result_dto.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:restaurantour/models/review_dto.dart'; + +part 'review_query_result_dto.g.dart'; + +@JsonSerializable() +class ReviewQueryResultDto { + final int? total; + @JsonKey(name: 'review') + final List? review; + + const ReviewQueryResultDto({this.total, this.review}); + + factory ReviewQueryResultDto.fromJson(Map json) => _$ReviewQueryResultDtoFromJson(json); + factory ReviewQueryResultDto.fixture() => ReviewQueryResultDto(total: 1, review: [ReviewDto.fixture()]); + + Map toJson() => _$ReviewQueryResultDtoToJson(this); +} diff --git a/lib/models/review_query_result_dto.g.dart b/lib/models/review_query_result_dto.g.dart new file mode 100644 index 00000000..15e9e151 --- /dev/null +++ b/lib/models/review_query_result_dto.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'review_query_result_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ReviewQueryResultDto _$ReviewQueryResultDtoFromJson( + Map json) => + ReviewQueryResultDto( + total: json['total'] as int?, + review: (json['review'] as List?) + ?.map((e) => ReviewDto.fromJson(e as Map)) + .toList(), + ); + +Map _$ReviewQueryResultDtoToJson( + ReviewQueryResultDto instance) => + { + 'total': instance.total, + 'review': instance.review, + }; diff --git a/lib/models/user_dto.dart b/lib/models/user_dto.dart new file mode 100644 index 00000000..e7193986 --- /dev/null +++ b/lib/models/user_dto.dart @@ -0,0 +1,22 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'user_dto.g.dart'; + +@JsonSerializable() +class UserDto { + final String? id; + @JsonKey(name: 'image_url') + final String? imageUrl; + final String? name; + + const UserDto({this.id, this.imageUrl, this.name}); + + factory UserDto.fromJson(Map json) => _$UserDtoFromJson(json); + factory UserDto.fixture() => const UserDto( + id: 'userId', + name: 'George M.', + imageUrl: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/400.jpg', + ); + + Map toJson() => _$UserDtoToJson(this); +} diff --git a/lib/models/user_dto.g.dart b/lib/models/user_dto.g.dart new file mode 100644 index 00000000..f00ef06f --- /dev/null +++ b/lib/models/user_dto.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +UserDto _$UserDtoFromJson(Map json) => UserDto( + id: json['id'] as String?, + imageUrl: json['image_url'] as String?, + name: json['name'] as String?, + ); + +Map _$UserDtoToJson(UserDto instance) => { + 'id': instance.id, + 'image_url': instance.imageUrl, + 'name': instance.name, + }; diff --git a/lib/repositories/restaurant_repository.dart b/lib/repositories/restaurant_repository.dart new file mode 100644 index 00000000..d8a462a6 --- /dev/null +++ b/lib/repositories/restaurant_repository.dart @@ -0,0 +1,45 @@ +import 'package:dio/dio.dart'; +import 'package:restaurantour/core/queries.dart'; +import 'package:restaurantour/models/dto.dart'; + +class RestaurantRepository { + final Dio dio; + + RestaurantRepository({required this.dio}); + + Future getRestaurants({int offset = 0}) async { + final response = await dio.post>( + '/v3/graphql', + data: RTQueries.getRestaurantsQuery(offset), + ); + return RestaurantQueryResultDto.fromJson(response.data!['data']['search']); + } + + Future getReviews({required String restaurantId, int offset = 0}) async { + final response = await dio.post>( + '/v3/graphql', + data: RTQueries.getReviewsQuery(restaurantId: restaurantId, offset: offset), + ); + final result = response.data!['data']['reviews']; + + return ReviewQueryResultDto.fromJson(result); + } + + Future getRestaurantDetails({required String restaurantId, int offset = 0}) async { + final response = await dio.post>( + '/v3/graphql', + data: RTQueries.getRestaurantDetailsQuery(restaurantId: restaurantId), + ); + final result = response.data!['data']['business']; + return RestaurantDto.fromJson(result); + } + + Future getSingleRestaurant({required String restaurantId, int offset = 0}) async { + final response = await dio.post>( + '/v3/graphql', + data: RTQueries.getSingleRestaurantQuery(restaurantId: restaurantId), + ); + final result = response.data!['data']['business']; + return RestaurantDto.fromJson(result); + } +} diff --git a/lib/repositories/yelp_repository.dart b/lib/repositories/yelp_repository.dart deleted file mode 100644 index f251d7b4..00000000 --- a/lib/repositories/yelp_repository.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:restaurantour/models/restaurant.dart'; - -const _apiKey = ''; - -class YelpRepository { - late Dio dio; - - YelpRepository({ - @visibleForTesting Dio? dio, - }) : dio = dio ?? - Dio( - BaseOptions( - baseUrl: 'https://api.yelp.com', - headers: { - 'Authorization': 'Bearer $_apiKey', - 'Content-Type': 'application/graphql', - }, - ), - ); - - /// Returns a response in this shape - /// { - /// "data": { - /// "search": { - /// "total": 5056, - /// "business": [ - /// { - /// "id": "faPVqws-x-5k2CQKDNtHxw", - /// "name": "Yardbird Southern Table & Bar", - /// "price": "$$", - /// "rating": 4.5, - /// "photos": [ - /// "https:///s3-media4.fl.yelpcdn.com/bphoto/_zXRdYX4r1OBfF86xKMbDw/o.jpg" - /// ], - /// "reviews": [ - /// { - /// "id": "sjZoO8wcK1NeGJFDk5i82Q", - /// "rating": 5, - /// "user": { - /// "id": "BuBCkWFNT_O2dbSnBZvpoQ", - /// "image_url": "https:///s3-media2.fl.yelpcdn.com/photo/v8tbTjYaFvkzh1d7iE-pcQ/o.jpg", - /// "name": "Gina T." - /// } - /// }, - /// { - /// "id": "okpO9hfpxQXssbTZTKq9hA", - /// "rating": 5, - /// "user": { - /// "id": "0x9xu_b0Ct_6hG6jaxpztw", - /// "image_url": "https:///s3-media3.fl.yelpcdn.com/photo/gjz8X6tqE3e4praK4HfCiA/o.jpg", - /// "name": "Crystal L." - /// } - /// }, - /// ... - /// ] - /// } - /// } - /// - Future getRestaurants({int offset = 0}) async { - try { - final response = await dio.post>( - '/v3/graphql', - data: _getQuery(offset), - ); - return RestaurantQueryResult.fromJson(response.data!['data']['search']); - } catch (e) { - return null; - } - } - - String _getQuery(int offset) { - return ''' -query getRestaurants { - search(location: "Las Vegas", limit: 20, offset: $offset) { - total - business { - id - name - price - rating - photos - reviews { - id - rating - user { - id - image_url - name - } - } - categories { - title - alias - } - hours { - is_open_now - } - location { - formatted_address - } - } - } -} -'''; - } -} diff --git a/lib/services/event_bus_service.dart b/lib/services/event_bus_service.dart new file mode 100644 index 00000000..db61f4ca --- /dev/null +++ b/lib/services/event_bus_service.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +export 'dart:async'; + +enum EventBusType { toggleFavorite } + +extension EventBusTypeExt on EventBusType { + void onEvent({required EventBusType eventType, dynamic function}) { + if (this == eventType) function(); + } +} + +class EventBusService { + final StreamController _streamController = StreamController.broadcast(); + + StreamController get streamController => _streamController; + + Stream get stream => _streamController.stream; + + void fire(EventBusType event) => _streamController.add(event); +} diff --git a/lib/services/favorite_service.dart b/lib/services/favorite_service.dart new file mode 100644 index 00000000..a677bc20 --- /dev/null +++ b/lib/services/favorite_service.dart @@ -0,0 +1,28 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class FavoriteService { + final SharedPreferences sharedPreferences; + final String favoritesKey = 'favorites'; + + FavoriteService({required this.sharedPreferences}); + + Future> getFavorites() async { + List? favoriteItems = sharedPreferences.getStringList(favoritesKey); + favoriteItems ??= []; + return favoriteItems; + } + + Future addFavorite(String restaurantId) async { + List? favoriteItems = sharedPreferences.getStringList(favoritesKey); + favoriteItems ??= []; + favoriteItems.add(restaurantId); + await sharedPreferences.setStringList(favoritesKey, favoriteItems); + } + + Future removeFavorite(String restaurantId) async { + List? favoriteItems = sharedPreferences.getStringList(favoritesKey); + favoriteItems ??= []; + favoriteItems.remove(restaurantId); + await sharedPreferences.setStringList(favoritesKey, favoriteItems); + } +} diff --git a/lib/theme/rt_colors.dart b/lib/theme/rt_colors.dart new file mode 100644 index 00000000..ef7f8539 --- /dev/null +++ b/lib/theme/rt_colors.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +class RTColors { + static const open = Color(0xff5cd512); + static const closed = Color(0xffd96762); + static const background = Color(0xfffafafa); + static const placeholder = Color(0xffeeeeee); + static const white = Color(0xffffffff); + static const dividerLine = Color(0xffeeeeee); + static const primaryFill = Color(0xff000000); + static const defaultText = Color(0xff000000); + static const defaultTextButtonColor = Color(0xFF2196F3); + static const secondaryText = Color(0xff606060); + static const shimmerBaseColor = Color(0xFFD8D8D8); + static const shimmerHighlightColor = Color(0xFFFFFFFF); +} diff --git a/lib/theme/rt_sizes.dart b/lib/theme/rt_sizes.dart new file mode 100644 index 00000000..1738bd2a --- /dev/null +++ b/lib/theme/rt_sizes.dart @@ -0,0 +1,93 @@ +import 'package:restaurantour/core/core.dart'; + +/// Sizes available: XXXXS-1px, XXXS-2px, XXS-4px, XS-6px, S-8px, M-12px, L-14px, XL-16px, XXL-18px, XXXL-20px, G-24, XG-40px, XXG-50px, XXXG-80px, GG-88px, XGG-104px, XXGG-120px, XXXGG-140px, XXXXGG-300px, XXXXXGG-360px +enum RTSizesType { + /// 1 + xxxxs, + + /// 2 + xxxs, + + /// 4 + xxs, + + /// 6 + xs, + + /// 8 + s, + + /// 12 + m, + + /// 14 + l, + + /// 16 + xl, + + /// 18 + xxl, + + /// 20 + xxxl, + + /// 24 + g, + + /// 40 + xg, + + /// 50 + xxg, + + /// 80 + xxxg, + + /// 88 + gg, + + /// 104 + xgg, + + /// 120 + xxgg, + + /// 140 + xxxgg, + + /// 300 + xxxxgg, + + /// 360 + xxxxxgg, +} + +extension RTSizesTypeExt on RTSizesType { + double get size => [ + 1.0, // xxxxs + 2.0, // xxxs + 4.0, // xxs + 6.0, // xs + 8.0, // s + 12.0, // m + 14.0, // l + 16.0, // xl + 18.0, // xxl + 20.0, // xxxl + 24.0, // g + 40.0, // xg + 50.0, // xxg + 80.0, // xxxg + 88.0, // gg + 104.0, // xgg + 120.0, // xxgg + 140.0, // xxxgg, + 300.0, // xxxxgg, + 360.0, // xxxxxgg, + ][index]; + + Widget get spacer => SizedBox(height: size, width: size); + + Radius get radius => Radius.circular(size); +} diff --git a/lib/theme/rt_text_style.dart b/lib/theme/rt_text_style.dart new file mode 100644 index 00000000..8eab8476 --- /dev/null +++ b/lib/theme/rt_text_style.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/theme/rt_colors.dart'; + +class RTTextStyle { + static headingH4({Color color = RTColors.defaultText}) => TextStyle(fontFamily: 'Lora', fontSize: 28.0, fontWeight: FontWeight.bold, color: color); + static headingH6({Color color = RTColors.defaultText}) => TextStyle(fontFamily: 'Lora', fontSize: 18.0, fontWeight: FontWeight.bold, color: color); + static subtitle1({Color color = RTColors.defaultText}) => TextStyle(fontFamily: 'Lora', fontSize: 16.0, fontWeight: FontWeight.w500, color: color); + static button({Color? color}) => TextStyle(fontFamily: 'OpenSans', fontSize: 14.0, fontWeight: FontWeight.w600, color: color); + static textButton({Color color = RTColors.defaultTextButtonColor}) => TextStyle(fontFamily: 'OpenSans', fontSize: 14.0, fontWeight: FontWeight.w600, color: color); + static caption({Color color = RTColors.defaultText}) => TextStyle(fontFamily: 'OpenSans', fontSize: 12.0, fontWeight: FontWeight.w400, color: color); + static body1({Color color = RTColors.defaultText}) => TextStyle(fontFamily: 'OpenSans', fontSize: 16.0, fontWeight: FontWeight.w400, color: color); + static body2({Color color = RTColors.defaultText}) => TextStyle(fontFamily: 'OpenSans', fontSize: 14.0, fontWeight: FontWeight.w600, color: color); + static overline({Color color = RTColors.defaultText}) => TextStyle( + fontFamily: 'OpenSans', + fontSize: 12.0, + fontWeight: FontWeight.w400, + fontStyle: FontStyle.italic, + color: color, + ); +} diff --git a/lib/theme/rt_theme.dart b/lib/theme/rt_theme.dart new file mode 100644 index 00000000..8422fad1 --- /dev/null +++ b/lib/theme/rt_theme.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/theme/rt_colors.dart'; +import 'package:restaurantour/theme/rt_sizes.dart'; + +class RTThemeData { + static get _themeExtension => RTThemeExtension( + openColor: RTColors.open, + closedColor: RTColors.closed, + backgroundColor: RTColors.background, + placeholderColor: RTColors.placeholder, + whiteColor: RTColors.white, + dividerLineColor: RTColors.dividerLine, + primaryFillColor: RTColors.primaryFill, + defaultTextColor: RTColors.defaultText, + secondaryTextColor: RTColors.secondaryText, + shimmerBaseColor: RTColors.shimmerBaseColor, + shimmerHighlightColor: RTColors.shimmerHighlightColor, + ); + + static final themeData = ThemeData( + visualDensity: VisualDensity.adaptivePlatformDensity, + useMaterial3: true, + appBarTheme: AppBarTheme( + elevation: RTSizesType.xxxs.size, + centerTitle: true, + surfaceTintColor: RTColors.background, + shadowColor: RTColors.primaryFill, + ), + colorScheme: const ColorScheme.light( + primary: RTColors.primaryFill, + onSurface: RTColors.secondaryText, + background: RTColors.background, + ), + extensions: [ + _themeExtension, + ], + ); +} + +class RTThemeExtension extends ThemeExtension { + RTThemeExtension({ + required this.openColor, + required this.closedColor, + required this.backgroundColor, + required this.placeholderColor, + required this.whiteColor, + required this.dividerLineColor, + required this.primaryFillColor, + required this.defaultTextColor, + required this.secondaryTextColor, + required this.shimmerBaseColor, + required this.shimmerHighlightColor, + }); + + final Color openColor; + final Color closedColor; + final Color backgroundColor; + final Color placeholderColor; + final Color whiteColor; + final Color dividerLineColor; + final Color primaryFillColor; + final Color defaultTextColor; + final Color secondaryTextColor; + final Color shimmerBaseColor; + final Color shimmerHighlightColor; + + @override + ThemeExtension copyWith({ + Color? openColor, + Color? closedColor, + Color? backgroundColor, + Color? placeholderColor, + Color? whiteColor, + Color? dividerLineColor, + Color? primaryFillColor, + Color? defaultTextColor, + Color? secondaryTextColor, + Color? shimmerBaseColor, + Color? shimmerHighlightColor, + }) { + return RTThemeExtension( + openColor: openColor ?? this.openColor, + closedColor: closedColor ?? this.closedColor, + backgroundColor: backgroundColor ?? this.backgroundColor, + placeholderColor: placeholderColor ?? this.placeholderColor, + whiteColor: whiteColor ?? this.whiteColor, + dividerLineColor: dividerLineColor ?? this.dividerLineColor, + primaryFillColor: primaryFillColor ?? this.primaryFillColor, + defaultTextColor: defaultTextColor ?? this.defaultTextColor, + secondaryTextColor: secondaryTextColor ?? this.secondaryTextColor, + shimmerBaseColor: shimmerBaseColor ?? this.shimmerBaseColor, + shimmerHighlightColor: shimmerHighlightColor ?? this.shimmerHighlightColor, + ); + } + + @override + ThemeExtension lerp(covariant ThemeExtension? other, double t) { + if (other is! RTThemeExtension) { + return this; + } + + return RTThemeExtension( + openColor: Color.lerp(openColor, other.openColor, t)!, + closedColor: Color.lerp(closedColor, other.closedColor, t)!, + backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t)!, + placeholderColor: Color.lerp(placeholderColor, other.placeholderColor, t)!, + whiteColor: Color.lerp(whiteColor, other.whiteColor, t)!, + dividerLineColor: Color.lerp(dividerLineColor, other.dividerLineColor, t)!, + primaryFillColor: Color.lerp(primaryFillColor, other.primaryFillColor, t)!, + defaultTextColor: Color.lerp(defaultTextColor, other.defaultTextColor, t)!, + secondaryTextColor: Color.lerp(secondaryTextColor, other.secondaryTextColor, t)!, + shimmerBaseColor: Color.lerp(shimmerBaseColor, other.shimmerBaseColor, t)!, + shimmerHighlightColor: Color.lerp(shimmerHighlightColor, other.shimmerHighlightColor, t)!, + ); + } +} diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart new file mode 100644 index 00000000..28619b2f --- /dev/null +++ b/lib/theme/theme.dart @@ -0,0 +1,3 @@ +export 'package:restaurantour/theme/rt_sizes.dart'; +export 'package:restaurantour/theme/rt_text_style.dart'; +export 'package:restaurantour/theme/rt_theme.dart'; diff --git a/pubspec.lock b/pubspec.lock index 0b052c68..6740e0d9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 url: "https://pub.dev" source: hosted - version: "61.0.0" + version: "64.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "6.2.0" args: dependency: transitive description: name: args - sha256: "0bd9a99b6eb96f07af141f0eb53eace8983e8e5aa5de59777aca31684680ef22" + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.2" async: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: build - sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" build_config: dependency: transitive description: @@ -61,18 +61,18 @@ packages: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.1" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "6c4dd11d05d056e76320b828a1db0fc01ccd376922526f8e9d6c796a5adbac20" + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.4.2" build_runner: dependency: "direct dev" description: @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: build_runner_core - sha256: f4d6244cc071ba842c296cb1c4ee1b31596b9f924300647ac7a1445493471a3f + sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799" url: "https://pub.dev" source: hosted - version: "7.2.3" + version: "7.3.0" built_collection: dependency: transitive description: @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: built_value - sha256: b6c9911b2d670376918d5b8779bc27e0e612a94ec3ff0343689e991d8d0a3b8a + sha256: a3ec2e0f967bc47f69f95009bb93db936288d61d5343b9436e378b28a2f830c6 url: "https://pub.dev" source: hosted - version: "8.1.4" + version: "8.9.0" characters: dependency: transitive description: @@ -113,22 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 - url: "https://pub.dev" - source: hosted - version: "1.3.1" checked_yaml: dependency: transitive description: name: checked_yaml - sha256: dd007e4fb8270916820a0d66e24f619266b60773cddd082c6439341645af2659 + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.3" clock: dependency: transitive description: @@ -149,26 +141,26 @@ packages: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.2" convert: dependency: transitive description: name: convert - sha256: f08428ad63615f96a27e34221c65e1a451439b5f26030f78d790f461c686d65d + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.1" crypto: dependency: transitive description: name: crypto - sha256: cf75650c66c0316274e21d7c43d3dea246273af5955bd94e8184837cd577575c + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" cupertino_icons: dependency: "direct main" description: @@ -181,10 +173,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + sha256: "40ae61a5d43feea6d24bd22c0537a6629db858963b99b4bc1c3db80676f32368" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.4" dio: dependency: "direct main" description: @@ -201,27 +193,48 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + url: "https://pub.dev" + source: hosted + version: "2.1.0" file: dependency: transitive description: name: file - sha256: b69516f2c26a5bcac4eee2e32512e1a5205ab312b3536c1c1227b2b942b5f9ad + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "6.1.4" fixnum: dependency: transitive description: name: fixnum - sha256: "6a2ef17156f4dc49684f9d99aaf4a93aba8ac49f5eac861755f5730ddf6e2e4e" + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.0" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: "9357883bdd153ab78cbf9ffa07656e336b8bbb2b5a3ca596b0b27e119f7c7d77" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -230,6 +243,11 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_svg: dependency: "direct main" description: @@ -243,6 +261,11 @@ 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: @@ -251,14 +274,43 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: e6017ce7fdeaf218dc51a100344d8cb70134b80e28b760f8bb23c242437bafd7 + url: "https://pub.dev" + source: hosted + version: "7.6.7" glob: dependency: transitive description: name: glob - sha256: "8321dd2c0ab0683a91a51307fa844c6db4aa8e3981219b78961672aaab434658" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "170c46e237d6eb0e6e9f0e8b3f56101e14fb64f787016e42edd74c39cf8b176a" + url: "https://pub.dev" + source: hosted + version: "13.2.0" + golden_toolkit: + dependency: "direct main" + description: + name: golden_toolkit + sha256: "8f74adab33154fe7b731395782797021f97d2edc52f7bfb85ff4f1b5c4a215f0" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "0.15.0" graphs: dependency: transitive description: @@ -271,34 +323,47 @@ 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.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "0.18.1" io: dependency: transitive description: name: io - sha256: "0d4c73c3653ab85bf696d51a9657604c900a370549196a91f33e4c39af760852" + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" js: dependency: transitive description: name: js - sha256: d9bdfd70d828eeb352390f81b18d6a354ef2044aa28ef25682079797fa7cd174 + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf url: "https://pub.dev" source: hosted - version: "0.6.3" + version: "0.7.1" json_annotation: dependency: "direct main" description: @@ -327,10 +392,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: @@ -351,26 +416,42 @@ packages: dependency: transitive description: name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.9.1" mime: dependency: transitive description: name: mime - sha256: fd5f81041e6a9fc9b9d7fa2cb8a01123f9f5d5d49136e06cb9dc7d33689529f4 + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.4" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: c4b5007d91ca4f67256e720cb1b6d704e79a510183a12fa551021f652577dce6 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" 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,54 +468,174 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + 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: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.dev" + source: hosted + version: "2.2.1" petitparser: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + url: "https://pub.dev" + source: hosted + version: "5.4.0" + platform: + dependency: transitive + description: + name: platform + sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "6.0.2" + 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" + process: + dependency: transitive + description: + name: process + sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" + url: "https://pub.dev" + source: hosted + version: "4.2.4" + provider: + dependency: "direct main" + description: + name: provider + sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096" + url: "https://pub.dev" + source: hosted + version: "6.1.1" pub_semver: dependency: transitive description: name: pub_semver - sha256: b5a5fcc6425ea43704852ba4453ba94b08c2226c63418a260240c3a054579014 + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.4" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "3686efe4a4613a4449b1a4ae08670aadbd3376f2e78d93e3f8f0919db02a7256" + sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.3" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c" + url: "https://pub.dev" + source: hosted + version: "2.3.5" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + url: "https://pub.dev" + source: hosted + version: "2.3.2" 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_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" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter @@ -468,26 +669,26 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.11.0" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.1" stream_transform: dependency: transitive description: name: stream_transform - sha256: ed464977cb26a1f41537e177e190c67223dbd9f4f683489b6ab2e5d211ec564e + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" string_scanner: dependency: transitive description: @@ -496,6 +697,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -508,50 +717,50 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.0" timing: dependency: transitive description: name: timing - sha256: c386d07d7f5efc613479a7c4d9d64b03710b03cfaa7e8ad5f2bfb295a1f0dfad + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" typed_data: dependency: transitive description: name: typed_data - sha256: "53bdf7e979cfbf3e28987552fd72f637e63f3c8724c9e56d9246942dc2fa36ee" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.2" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: "18f6690295af52d081f6808f2f7c69f0eed6d7e23a71539d75f4aeb8f0062172" + sha256: "4ac59808bbfca6da38c99f415ff2d3a5d7ca0a6b4809c71d9cf30fba5daf9752" url: "https://pub.dev" source: hosted - version: "1.1.9+2" + version: "1.1.10+1" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: "531d20465c10dfac7f5cd90b60bbe4dd9921f1ec4ca54c83ebb176dbacb7bb2d" + sha256: f3247e7ab0ec77dc759263e68394990edc608fb2b480b80db8aa86ed09279e33 url: "https://pub.dev" source: hosted - version: "1.1.9+2" + version: "1.1.10+1" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: "03012b0a33775c5530576b70240308080e1d5050f0faf000118c20e6463bc0ad" + sha256: "18489bdd8850de3dd7ca8a34e0c446f719ec63e2bab2e7a8cc66a9028dd76c5a" url: "https://pub.dev" source: hosted - version: "1.1.9+2" + version: "1.1.10+1" vector_math: dependency: transitive description: @@ -560,46 +769,78 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: c620a6f783fa22436da68e42db7ebbf18b8c44b9a46ab911f666ff09ffd9153f + url: "https://pub.dev" + source: hosted + version: "11.7.1" watcher: dependency: transitive description: name: watcher - sha256: e42dfcc48f67618344da967b10f62de57e04bae01d9d3af4c2596f3712a88c99 + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" web: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.1.4-beta" web_socket_channel: 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" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + win32: + dependency: transitive + description: + name: win32 + sha256: b0f37db61ba2f2e9b7a78a1caece0052564d1bc70668156cf3a29d676fe4e574 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" xml: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.3.0" yaml: dependency: transitive description: name: yaml - sha256: "3cee79b1715110341012d27756d9bae38e650588acd38d3f3c610822e1337ace" + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.2" sdks: - dart: ">=3.2.0 <4.0.0" - flutter: ">=3.7.0-0" + dart: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" diff --git a/pubspec.yaml b/pubspec.yaml index be3055e0..c8cad618 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,30 +1,54 @@ name: restaurantour description: Flutter developer coding challenge starter project. -publish_to: 'none' +publish_to: "none" version: 1.0.0+1 - environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=2.17.0 <3.0.0" dependencies: - flutter: - sdk: flutter cupertino_icons: ^1.0.6 dio: ^5.4.0 - json_annotation: ^4.8.1 + flutter: + sdk: flutter + flutter_dotenv: ^5.1.0 + flutter_localizations: + sdk: flutter flutter_svg: ^2.0.9 + get_it: ^7.6.7 + go_router: ^13.2.0 + golden_toolkit: ^0.15.0 + intl: any + json_annotation: ^4.8.1 + provider: ^6.1.1 + shared_preferences: ^2.2.2 + shimmer: ^3.0.0 dev_dependencies: + build_runner: ^2.4.8 flutter_test: sdk: flutter flutter_lints: ^1.0.2 - build_runner: ^2.4.8 json_serializable: ^6.7.1 + mocktail: ^1.0.3 + integration_test: + sdk: flutter flutter: uses-material-design: true -# assets: -# - assets/svg/ \ No newline at end of file + generate: true + assets: + - assets/svg/ + - .env + - .env_stage + fonts: + - family: Lora + fonts: + - asset: assets/fonts/Lora-Bold.ttf + - asset: assets/fonts/Lora-Medium.ttf + - family: OpenSans + fonts: + - asset: assets/fonts/OpenSans-Regular.ttf + - asset: assets/fonts/OpenSans-SemiBold.ttf diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart new file mode 100644 index 00000000..d5d03e74 --- /dev/null +++ b/test/flutter_test_config.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'utils/local_file_comparator_with_threshold.dart'; + +const runningOnCICD = bool.fromEnvironment('runningOnCICD', defaultValue: false); + +const _kGoldenTestsThreshold = 0.01 / 100; + +Future testExecutable(FutureOr Function() testMain) async { + if (goldenFileComparator is LocalFileComparator) { + final testUrl = (goldenFileComparator as LocalFileComparator).basedir; + + goldenFileComparator = LocalFileComparatorWithThreshold(Uri.parse('$testUrl/test.dart'), runningOnCICD ? _kGoldenTestsThreshold : 0.0); + } else { + throw Exception( + 'Expected `goldenFileComparator` to be of type `LocalFileComparator`, ' + 'but it is of type `${goldenFileComparator.runtimeType}`', + ); + } + + await testMain(); +} diff --git a/test/golden_test/details_screen_test.dart b/test/golden_test/details_screen_test.dart new file mode 100644 index 00000000..05646aa3 --- /dev/null +++ b/test/golden_test/details_screen_test.dart @@ -0,0 +1,106 @@ +import 'package:restaurantour/components/rt_components.dart'; +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/features/details/details_screen.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; + +import '../test.dart'; + +void main() { + RestaurantRepository restaurantRepository = RestaurantRepositoryMock(); + FavoriteService favoritesService = FavoritesServiceMock(); + EventBusService eventBusService = EventBusServiceMock(); + + setUp(() { + GetIt.I.registerFactory(() => restaurantRepository); + GetIt.I.registerFactory(() => eventBusService); + GetIt.I.registerFactory(() => favoritesService); + GetIt.I.registerFactory(() => RTImageNetworkMock()); + }); + + tearDown(() { + reset(restaurantRepository); + reset(favoritesService); + GetIt.I.reset(); + }); + + final deviceBuilder = DeviceBuilder() + ..overrideDevicesForAllScenarios(devices: [Device.iphone11.copyWith(size: const Size(414, 1100))]) + ..addScenario( + widget: widgetBuilder(DetailsScreen.create(restaurantId: RestaurantDto.fixture().id)), + ); + + testGoldens('when [DetailsScreen] loads should show the title, favorite icon, and other details', (WidgetTester tester) async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([RestaurantDto.fixture().id ?? ''])); + when(() => restaurantRepository.getRestaurantDetails(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(RestaurantDto.fixture())); + when(() => restaurantRepository.getReviews(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(ReviewQueryResultDto.fixture())); + + await loadAppFonts(); + + await tester.pumpDeviceBuilder(deviceBuilder); + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, "details_screen_content"); + }); + + testGoldens('when [DetailsScreen] loads a favorite restaurant should show the favorite icon filled', (WidgetTester tester) async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([RestaurantDto.fixture().id ?? ''])); + when(() => restaurantRepository.getRestaurantDetails(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(RestaurantDto.fixture())); + when(() => restaurantRepository.getReviews(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(ReviewQueryResultDto.fixture())); + await loadAppFonts(); + + await tester.pumpDeviceBuilder(deviceBuilder); + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, "details_screen_favorite"); + }); + + testGoldens('when [DetailsScreen] loads a non favorite restaurant should show the favorite icon not filled', (WidgetTester tester) async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + when(() => restaurantRepository.getRestaurantDetails(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(RestaurantDto.fixture())); + when(() => restaurantRepository.getReviews(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(ReviewQueryResultDto.fixture())); + await loadAppFonts(); + + await tester.pumpDeviceBuilder(deviceBuilder); + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, "details_screen_not_favorite"); + }); + + group('toggleFavorite', () { + testGoldens('''when [DetailsScreen] loads a non favorite restaurant + and tap to favorite update the status and show the favorite icon filled''', (WidgetTester tester) async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + when(() => favoritesService.addFavorite(any())).thenAnswer((_) => Future.value()); + when(() => restaurantRepository.getRestaurantDetails(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(RestaurantDto.fixture())); + when(() => restaurantRepository.getReviews(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(ReviewQueryResultDto.fixture())); + await loadAppFonts(); + + await tester.pumpDeviceBuilder(deviceBuilder); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('favorite-button'))); + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, "details_screen_added_favorite"); + }); + + testGoldens('''when [DetailsScreen] loads a favorite restaurant + and tap to remove favorite update the status and show the favorite icon not filled''', (WidgetTester tester) async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([RestaurantDto.fixture().id ?? ''])); + when(() => favoritesService.removeFavorite(any())).thenAnswer((_) => Future.value()); + when(() => restaurantRepository.getRestaurantDetails(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(RestaurantDto.fixture())); + when(() => restaurantRepository.getReviews(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(ReviewQueryResultDto.fixture())); + + await loadAppFonts(); + + await tester.pumpDeviceBuilder(deviceBuilder); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('favorite-button'))); + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, "details_screen_removed_favorite"); + }); + }); +} diff --git a/test/golden_test/favorites_page_test.dart b/test/golden_test/favorites_page_test.dart new file mode 100644 index 00000000..ae09e8f4 --- /dev/null +++ b/test/golden_test/favorites_page_test.dart @@ -0,0 +1,76 @@ +import 'package:restaurantour/components/rt_components.dart'; +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/features/restaurants/pages/favorites/favorites_page.dart'; +import 'package:restaurantour/features/restaurants/pages/favorites/favorites_view_model.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; + +import '../test.dart'; + +void main() { + RestaurantRepository restaurantRepository = RestaurantRepositoryMock(); + FavoriteService favoritesService = FavoritesServiceMock(); + setUp(() { + GetIt.I.registerFactory(() => RTImageNetworkMock()); + }); + + tearDown(() { + reset(restaurantRepository); + reset(favoritesService); + GetIt.I.reset(); + }); + + final deviceBuilder = DeviceBuilder() + ..overrideDevicesForAllScenarios(devices: [Device.iphone11]) + ..addScenario( + widget: widgetBuilder( + ChangeNotifierProvider( + create: (context) => FavoritesViewModel.create( + favoritesService: favoritesService, + restaurantRepository: restaurantRepository, + eventBus: EventBusService(), + ), + child: const FavoritesPage(), + ), + ), + ); + + testGoldens('''when successfully fetch the [RestaurantQueryResult] and has data, and also load the favorites fetch the data + should create a [RTItemWidget] for each favorite [Restaurant]''', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([RestaurantDto.fixture().id ?? ''])); + await loadAppFonts(); + + await tester.pumpDeviceBuilder(deviceBuilder); + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, "favorite_page_content"); + }); + + testGoldens('''when get some error while fetch the favorites, + should render [RTErrorWidget] to inform to the user that something fails''', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenThrow('error mock'); + await loadAppFonts(); + + await tester.pumpDeviceBuilder(deviceBuilder); + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, "favorite_page_error"); + }); + + testGoldens('''when successfully fetch the [RestaurantQueryResult] and has no data, + and also when load the favorites and has no favorites, + should render [RTEmptyWidget] to inform to the user that has no results''', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(const RestaurantQueryResultDto(restaurants: []))); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + await loadAppFonts(); + + await tester.pumpDeviceBuilder(deviceBuilder); + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, "favorite_page_empty"); + }); +} diff --git a/test/golden_test/goldens/details_screen_added_favorite.png b/test/golden_test/goldens/details_screen_added_favorite.png new file mode 100644 index 00000000..111028e6 Binary files /dev/null and b/test/golden_test/goldens/details_screen_added_favorite.png differ diff --git a/test/golden_test/goldens/details_screen_content.png b/test/golden_test/goldens/details_screen_content.png new file mode 100644 index 00000000..111028e6 Binary files /dev/null and b/test/golden_test/goldens/details_screen_content.png differ diff --git a/test/golden_test/goldens/details_screen_favorite.png b/test/golden_test/goldens/details_screen_favorite.png new file mode 100644 index 00000000..111028e6 Binary files /dev/null and b/test/golden_test/goldens/details_screen_favorite.png differ diff --git a/test/golden_test/goldens/details_screen_not_favorite.png b/test/golden_test/goldens/details_screen_not_favorite.png new file mode 100644 index 00000000..9dab6a2c Binary files /dev/null and b/test/golden_test/goldens/details_screen_not_favorite.png differ diff --git a/test/golden_test/goldens/details_screen_removed_favorite.png b/test/golden_test/goldens/details_screen_removed_favorite.png new file mode 100644 index 00000000..9dab6a2c Binary files /dev/null and b/test/golden_test/goldens/details_screen_removed_favorite.png differ diff --git a/test/golden_test/goldens/favorite_page_content.png b/test/golden_test/goldens/favorite_page_content.png new file mode 100644 index 00000000..cfbd063e Binary files /dev/null and b/test/golden_test/goldens/favorite_page_content.png differ diff --git a/test/golden_test/goldens/favorite_page_empty.png b/test/golden_test/goldens/favorite_page_empty.png new file mode 100644 index 00000000..4a79edbb Binary files /dev/null and b/test/golden_test/goldens/favorite_page_empty.png differ diff --git a/test/golden_test/goldens/favorite_page_error.png b/test/golden_test/goldens/favorite_page_error.png new file mode 100644 index 00000000..562ea301 Binary files /dev/null and b/test/golden_test/goldens/favorite_page_error.png differ diff --git a/test/golden_test/goldens/restauran_tour_content.png b/test/golden_test/goldens/restauran_tour_content.png new file mode 100644 index 00000000..0c1b8a45 Binary files /dev/null and b/test/golden_test/goldens/restauran_tour_content.png differ diff --git a/test/golden_test/goldens/restauran_tour_favorites_tab.png b/test/golden_test/goldens/restauran_tour_favorites_tab.png new file mode 100644 index 00000000..7d84e243 Binary files /dev/null and b/test/golden_test/goldens/restauran_tour_favorites_tab.png differ diff --git a/test/golden_test/restauran_tour_test.dart b/test/golden_test/restauran_tour_test.dart new file mode 100644 index 00000000..93c6abad --- /dev/null +++ b/test/golden_test/restauran_tour_test.dart @@ -0,0 +1,59 @@ +import 'package:restaurantour/components/rt_components.dart'; +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/features/restaurants/restaurants_screen.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; + +import '../test.dart'; + +void main() { + RestaurantRepository restaurantRepository = RestaurantRepositoryMock(); + FavoriteService favoritesService = FavoritesServiceMock(); + EventBusService eventBusService = EventBusServiceMock(); + + setUp(() { + GetIt.I.registerFactory(() => eventBusService); + GetIt.I.registerFactory(() => restaurantRepository); + GetIt.I.registerFactory(() => favoritesService); + GetIt.I.registerFactory(() => RTImageNetworkMock()); + when(() => eventBusService.stream).thenAnswer((_) => const Stream.empty()); + }); + + tearDown(() { + reset(restaurantRepository); + reset(favoritesService); + GetIt.I.reset(); + }); + + final deviceBuilder = DeviceBuilder() + ..overrideDevicesForAllScenarios(devices: [Device.iphone11]) + ..addScenario( + widget: widgetBuilder(const RestaurantsScreen()), + ); + + testGoldens('when [RestaurantTour] loads should show the title on the page and fetch the data for all restaurants', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + + await loadAppFonts(); + + await tester.pumpDeviceBuilder(deviceBuilder); + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, "restauran_tour_content"); + }); + + testGoldens('when [RestaurantTour] loads should show the title on the page and fetch the data for all favorites', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([RestaurantDto.fixture().id ?? ''])); + await loadAppFonts(); + + await tester.pumpDeviceBuilder(deviceBuilder); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('my-favorites-tab'))); + + await screenMatchesGolden(tester, "restauran_tour_favorites_tab"); + }); +} diff --git a/test/mocks/mocks.dart b/test/mocks/mocks.dart new file mode 100644 index 00000000..19336db1 --- /dev/null +++ b/test/mocks/mocks.dart @@ -0,0 +1,28 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:restaurantour/components/rt_image_network.dart'; +import 'package:restaurantour/core/routes.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class RestaurantRepositoryMock extends Mock implements RestaurantRepository {} + +class FavoritesServiceMock extends Mock implements FavoriteService {} + +class DioMock extends Mock implements Dio {} + +class EventBusServiceMock extends Mock implements EventBusService {} + +class RequestOptionsMock extends Mock implements RequestOptions {} + +class GoRouterConfigMock extends Mock implements GoRouterConfig {} + +class SharedPreferencesMock extends Mock implements SharedPreferences {} + +class RTImageNetworkMock extends Mock implements RTImageNetwork { + @override + Widget build({required String location, required Widget errorWidget}) => const Icon(Icons.image); +} diff --git a/test/services/favorite_service_test.dart b/test/services/favorite_service_test.dart new file mode 100644 index 00000000..d17f6dbe --- /dev/null +++ b/test/services/favorite_service_test.dart @@ -0,0 +1,46 @@ +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/services/favorite_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../test.dart'; + +void main() { + SharedPreferences sharedPreferences = SharedPreferencesMock(); + + setUp(() { + GetIt.I.registerFactory(() => sharedPreferences); + }); + + tearDown(() { + reset(sharedPreferences); + GetIt.I.reset(); + }); + + test('when loadFavorites is called should get the value from service', () async { + when(() => sharedPreferences.getStringList(any())).thenReturn([]); + + final sut = FavoriteService(sharedPreferences: sharedPreferences); + final result = await sut.getFavorites(); + + expect(result, []); + }); + + test('when addFavorite is called should save the value on service', () async { + when(() => sharedPreferences.setStringList(any(), any())).thenAnswer((_) => Future.value(true)); + + final sut = FavoriteService(sharedPreferences: sharedPreferences); + + await sut.addFavorite('favoriteId'); + verify(() => sharedPreferences.setStringList(any(), ['favoriteId'])).called(1); + }); + + test('when remove is called should remove the value from service', () async { + when(() => sharedPreferences.getStringList(any())).thenReturn(['favoriteId']); + when(() => sharedPreferences.setStringList(any(), any())).thenAnswer((_) => Future.value(true)); + + final sut = FavoriteService(sharedPreferences: sharedPreferences); + + await sut.removeFavorite('favoriteId'); + verify(() => sharedPreferences.setStringList(any(), [])).called(1); + }); +} diff --git a/test/test.dart b/test/test.dart new file mode 100644 index 00000000..27ada411 --- /dev/null +++ b/test/test.dart @@ -0,0 +1,6 @@ +export 'package:flutter_test/flutter_test.dart'; +export 'package:golden_toolkit/golden_toolkit.dart'; +export 'package:mocktail/mocktail.dart'; + +export './mocks/mocks.dart'; +export './widget_for_tests.dart'; diff --git a/test/unit_tests/details/details_view_model_test.dart b/test/unit_tests/details/details_view_model_test.dart new file mode 100644 index 00000000..8f5956b7 --- /dev/null +++ b/test/unit_tests/details/details_view_model_test.dart @@ -0,0 +1,170 @@ +import 'package:restaurantour/components/rt_image_network.dart'; +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/features/details/details_view_model.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; + +import '../../test.dart'; + +void main() { + FavoriteService favoritesService = FavoritesServiceMock(); + RestaurantRepository restaurantRepository = RestaurantRepositoryMock(); + EventBusService eventBusService = EventBusServiceMock(); + + setUp(() { + GetIt.I.registerFactory(() => favoritesService); + GetIt.I.registerFactory(() => restaurantRepository); + GetIt.I.registerFactory(() => RTImageNetworkMock()); + }); + + tearDown(() { + reset(favoritesService); + GetIt.I.reset(); + }); + const String restaurantId = ''; + test('''when [DetailsViewModel] is created + the [status] should starts with [DetailsStatus.loading] + and no call to [favoritesService.loadFavorites] should be triggered''', () async { + final sut = DetailsViewModel( + favoriteService: favoritesService, + restaurantId: restaurantId, + restaurantRepository: restaurantRepository, + eventBus: eventBusService, + ); + + expect(sut.status, DetailsStatus.loading); + verifyNever(() => favoritesService.getFavorites()); + }); + + test('when [load] is called should call once time the [loadFavorites] on [FavoritesService]', () async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + when(() => restaurantRepository.getRestaurantDetails(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(RestaurantDto.fixture())); + when(() => restaurantRepository.getReviews(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(ReviewQueryResultDto.fixture())); + final sut = DetailsViewModel( + favoriteService: favoritesService, + restaurantId: restaurantId, + restaurantRepository: restaurantRepository, + eventBus: eventBusService, + ); + + await sut.load(); + + verify(() => favoritesService.getFavorites()).called(1); + }); + + test('''when [load] get successfully the data from favoriteService + the [status] should be [DetailsStatus.content]''', () async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + when(() => restaurantRepository.getRestaurantDetails(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(RestaurantDto.fixture())); + when(() => restaurantRepository.getReviews(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(ReviewQueryResultDto.fixture())); + final sut = DetailsViewModel( + favoriteService: favoritesService, + restaurantId: restaurantId, + restaurantRepository: restaurantRepository, + eventBus: eventBusService, + ); + + await sut.load(); + + expect(sut.status, DetailsStatus.content); + }); + + test('when [load] get some error from favoriteService the [status] should be [DetailsStatus.error]', () async { + when(() => favoritesService.getFavorites()).thenThrow('error mock'); + final sut = DetailsViewModel( + favoriteService: favoritesService, + restaurantId: restaurantId, + restaurantRepository: restaurantRepository, + eventBus: eventBusService, + ); + when(() => restaurantRepository.getRestaurantDetails(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(RestaurantDto.fixture())); + when(() => restaurantRepository.getReviews(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(ReviewQueryResultDto.fixture())); + + await sut.load(); + + expect(sut.status, DetailsStatus.error); + }); + + test('''when [load] get successfully the data from favoriteService and finds a matching ID in the favorite list + [detailsViewModel.isFavorite] should be [true]''', () async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([restaurantId])); + final sut = DetailsViewModel( + favoriteService: favoritesService, + restaurantId: restaurantId, + restaurantRepository: restaurantRepository, + eventBus: eventBusService, + ); + when(() => restaurantRepository.getRestaurantDetails(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(RestaurantDto.fixture())); + when(() => restaurantRepository.getReviews(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(ReviewQueryResultDto.fixture())); + + await sut.load(); + + expect(sut.isFavorite, true); + }); + + test('''when [load] get successfully the data from favoriteService and finds no matching ID in the favorite list + [detailsViewModel.isFavorite] should be [false]''', () async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value(['new-restaurant'])); + final sut = DetailsViewModel( + favoriteService: favoritesService, + restaurantId: restaurantId, + restaurantRepository: restaurantRepository, + eventBus: eventBusService, + ); + + await sut.load(); + + expect(sut.isFavorite, false); + }); + + test('''when [load] get successfully the data from favoriteService and the [Restaurant] is favorite and + when [toggleFavorite] is called should call [favoritesService.removeFavorite] once time + and [detailsViewModel.isFavorite] should be changed to [false]''', () async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([restaurantId])); + when(() => favoritesService.addFavorite(any())).thenAnswer((_) => Future.value()); + when(() => favoritesService.removeFavorite(any())).thenAnswer((_) => Future.value()); + when(() => restaurantRepository.getRestaurantDetails(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(RestaurantDto.fixture())); + when(() => restaurantRepository.getReviews(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(ReviewQueryResultDto.fixture())); + + final sut = DetailsViewModel( + favoriteService: favoritesService, + restaurantId: restaurantId, + restaurantRepository: restaurantRepository, + eventBus: eventBusService, + ); + + await sut.load(); + expect(sut.isFavorite, true); + + await sut.toggleFavorite(); + + expect(sut.isFavorite, false); + verify(() => favoritesService.removeFavorite(restaurantId)).called(1); + verifyNever(() => favoritesService.addFavorite(restaurantId)); + }); + + test('''when [load] get successfully the data from favoriteService and the [Restaurant] is not favorite and + when [toggleFavorite] is called should call [favoritesService.addFavorite] once time + and [detailsViewModel.isFavorite] should be changed to [true]''', () async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value(['favorite-restaurant'])); + when(() => favoritesService.addFavorite(any())).thenAnswer((_) => Future.value()); + when(() => favoritesService.removeFavorite(any())).thenAnswer((_) => Future.value()); + final sut = DetailsViewModel( + favoriteService: favoritesService, + restaurantId: restaurantId, + restaurantRepository: restaurantRepository, + eventBus: eventBusService, + ); + + await sut.load(); + expect(sut.isFavorite, false); + + await sut.toggleFavorite(); + + expect(sut.isFavorite, true); + verify(() => favoritesService.addFavorite(restaurantId)).called(1); + verifyNever(() => favoritesService.removeFavorite(restaurantId)); + }); +} diff --git a/test/unit_tests/favorites/favorites_page_view_model_test.dart b/test/unit_tests/favorites/favorites_page_view_model_test.dart new file mode 100644 index 00000000..6094b812 --- /dev/null +++ b/test/unit_tests/favorites/favorites_page_view_model_test.dart @@ -0,0 +1,204 @@ +import 'package:restaurantour/components/rt_image_network.dart'; +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/features/restaurants/pages/favorites/favorites_view_model.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; + +import '../../test.dart'; + +void main() { + RestaurantRepository restaurantRepository = RestaurantRepositoryMock(); + FavoriteService favoritesService = FavoritesServiceMock(); + EventBusService eventBusService = EventBusServiceMock(); + + setUp(() { + GetIt.I.registerFactory(() => restaurantRepository); + GetIt.I.registerFactory(() => favoritesService); + GetIt.I.registerFactory(() => RTImageNetworkMock()); + }); + + tearDown(() { + reset(restaurantRepository); + reset(favoritesService); + GetIt.I.reset(); + }); + + group('tests on restaurantViewModel.load() ->', () { + test('''when [RestaurantViewModel] is created the [FavoritesStatus] should starts with [RestaurantStatus.loading] + and no call to [restaurantRepository.getRestaurants] should be triggered''', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + expect(sut.status, FavoritesStatus.loading); + + verifyNever(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))); + }); + + test('when [load] is called should call one time the [getRestaurants] on [RestaurantRepository]', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + await sut.getRestaurants(); + + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + }); + + test('''when [FavoritesViewModel] is created using the [FavoritesViewModel.create] constructor + the method [load] called should call one time the [getRestaurants] on [RestaurantRepository]''', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + FavoritesViewModel.create(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + }); + + test('when [load] get successfully the data from repository the [FavoritesStatus] should be [RestaurantStatus.content]', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + await sut.getRestaurants(); + + expect(sut.status, FavoritesStatus.content); + }); + + test('''when [load] get successfully the data from repository but the [Restaurant] list is [empty] + the [FavoritesStatus] should be [RestaurantStatus.empty]''', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(const RestaurantQueryResultDto(restaurants: []))); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + await sut.getRestaurants(); + + expect(sut.status, FavoritesStatus.empty); + }); + + test('when [load] get some error from repository the [FavoritesStatus] should be [RestaurantStatus.error]', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenThrow('error mock'); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = FavoritesViewModel.create(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + expect(sut.status, FavoritesStatus.error); + }); + + test('''when [getRestaurants] get some error from repository + no more calls should be triggered to [restaurantRepository.getRestaurants]''', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenThrow('error mock'); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + await sut.getRestaurants(); + + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + }); + }); + + group('tests on restaurantViewModel.loadFavorites() ->', () { + test('''when [RestaurantViewModel] is created the [favoritesStatus] should starts with [FavoriteStatus.loading] + and no call to [favoritesService.loadFavorites] should be triggered''', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + expect(sut.status, FavoritesStatus.loading); + verifyNever(() => favoritesService.getFavorites()); + }); + + test('when [loadFavorites] is called should call only once one more time the [getRestaurants] on [FavoritesService]', () async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + await sut.getFavorites(); + + verify(() => favoritesService.getFavorites()).called(1); + }); + + test('''when [RestaurantViewModel] is created the [Restaurant] list has no items, + if [loadFavorites] get successfully the data from favoritesService + but the [Restaurant] don't have all the restaurants of favorites list + them should get each one from repository and the favoritesStatus should be content''', () async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([RestaurantDto.fixture().id ?? ''])); + when(() => restaurantRepository.getSingleRestaurant(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(RestaurantDto.fixture())); + + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + await sut.getFavorites(); + + expect(sut.status, FavoritesStatus.content); + }); + + test('''when [load] get successfully the data from repository but the [Restaurant] list is [empty], + when [loadFavorites] get successfully the data from favoritesService + but the [Restaurant] don't have all the restaurants of favorites list + them should get each one from repository and the favoritesStatus should be content''', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(const RestaurantQueryResultDto(restaurants: []))); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([RestaurantDto.fixture().id ?? ''])); + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + await sut.getRestaurants(); + await sut.getFavorites(); + + expect(sut.status, FavoritesStatus.content); + }); + + test('''when [load] get successfully the data from repository + and [loadFavorites] get successfully the data from favoritesService but finds no matching ID in the lists, + them should get each one from repository and the favoritesStatus should be content''', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value(['another-restaurant-id'])); + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + await sut.getRestaurants(); + await sut.getFavorites(); + + expect(sut.status, FavoritesStatus.content); + }); + + test('''when [load] get successfully the data from repository + and [loadFavorites] get successfully the data from favoritesService and finds a matching ID in the lists, + the [favoritesStatus] should be [FavoriteStatus.content]''', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([RestaurantDto.fixture().id ?? ''])); + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + await sut.getRestaurants(); + await sut.getFavorites(); + + expect(sut.status, FavoritesStatus.content); + }); + + test('''when [loadFavorites] get successfully the data from favoritesService but the list is [empty] + the [favoritesStatus] should be [FavoriteStatus.empty]''', () async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + await sut.getFavorites(); + + expect(sut.status, FavoritesStatus.empty); + }); + + test('''when [loadFavorites] get some error from favoritesService + the [favoritesStatus] should be [FavoriteStatus.error]''', () async { + when(() => favoritesService.getFavorites()).thenThrow('error mock'); + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + await sut.getFavorites(); + + expect(sut.status, FavoritesStatus.error); + }); + + test('''when [loadFavorites] get some error from favoritesService + no more calls should be triggered to [favoritesService.loadFavorites]''', () async { + when(() => favoritesService.getFavorites()).thenThrow('error mock'); + final sut = FavoritesViewModel(favoritesService: favoritesService, restaurantRepository: restaurantRepository, eventBus: eventBusService); + + await sut.getFavorites(); + + verify(() => favoritesService.getFavorites()).called(1); + }); + }); +} diff --git a/test/unit_tests/restaurants/restaurant_page_view_model_test.dart b/test/unit_tests/restaurants/restaurant_page_view_model_test.dart new file mode 100644 index 00000000..2137370c --- /dev/null +++ b/test/unit_tests/restaurants/restaurant_page_view_model_test.dart @@ -0,0 +1,92 @@ +import 'package:restaurantour/components/rt_image_network.dart'; +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/features/restaurants/pages/restaurants/restaurants_view_model.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; + +import '../../test.dart'; + +void main() { + EventBusService eventBusService = EventBusServiceMock(); + RestaurantRepository restaurantRepository = RestaurantRepositoryMock(); + FavoriteService favoritesService = FavoritesServiceMock(); + + setUp(() { + GetIt.I.registerFactory(() => eventBusService); + GetIt.I.registerFactory(() => restaurantRepository); + GetIt.I.registerFactory(() => favoritesService); + GetIt.I.registerFactory(() => RTImageNetworkMock()); + }); + + tearDown(() { + reset(restaurantRepository); + reset(favoritesService); + GetIt.I.reset(); + }); + + group('tests on restaurantViewModel.load() ->', () { + test('''when [RestaurantViewModel] is created the [restaurantsStatus] should starts with [RestaurantStatus.loading] + and no call to [restaurantRepository.getRestaurants] should be triggered''', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = RestaurantsViewModel(restaurantRepository: restaurantRepository); + + expect(sut.restaurantsStatus, RestaurantsStatus.loading); + verifyNever(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))); + }); + + test('when [load] is called should call one time the [getRestaurants] on [RestaurantRepository]', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = RestaurantsViewModel(restaurantRepository: restaurantRepository); + + await sut.loadRestaurants(); + + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + }); + + test('when [load] get successfully the data from repository the [restaurantsStatus] should be [RestaurantStatus.content]', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = RestaurantsViewModel(restaurantRepository: restaurantRepository); + + await sut.loadRestaurants(); + + expect(sut.restaurantsStatus, RestaurantsStatus.content); + }); + + test('''when [load] get successfully the data from repository but the [Restaurant] list is [empty] + the [restaurantsStatus] should be [RestaurantStatus.empty]''', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(const RestaurantQueryResultDto(restaurants: []))); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = RestaurantsViewModel(restaurantRepository: restaurantRepository); + + await sut.loadRestaurants(); + + expect(sut.restaurantsStatus, RestaurantsStatus.empty); + }); + + test('when [load] get some error from repository the [restaurantsStatus] should be [RestaurantStatus.error]', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenThrow('error mock'); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = RestaurantsViewModel(restaurantRepository: restaurantRepository); + + await sut.loadRestaurants(); + + expect(sut.restaurantsStatus, RestaurantsStatus.error); + }); + + test('''when [load] get some error from repository + no more calls should be triggered to [restaurantRepository.getRestaurants]''', () async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenThrow('error mock'); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + final sut = RestaurantsViewModel(restaurantRepository: restaurantRepository); + + await sut.loadRestaurants(); + + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + }); + }); +} diff --git a/test/utils/local_file_comparator_with_threshold.dart b/test/utils/local_file_comparator_with_threshold.dart new file mode 100644 index 00000000..f411af53 --- /dev/null +++ b/test/utils/local_file_comparator_with_threshold.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class LocalFileComparatorWithThreshold extends LocalFileComparator { + final double threshold; + + LocalFileComparatorWithThreshold(Uri testFile, this.threshold) + : assert(threshold >= 0 && threshold <= 1), + super(testFile); + @override + Future compare(Uint8List imageBytes, Uri golden) async { + final result = await GoldenFileComparator.compareLists( + imageBytes, + await getGoldenBytes(golden), + ); + + if (!result.passed && result.diffPercent <= threshold) { + debugPrint( + 'A difference of ${result.diffPercent * 100}% was found, but it is ' + 'acceptable since it is not greater than the threshold of ' + '${threshold * 100}%', + ); + + return true; + } + + if (!result.passed) { + final error = await generateFailureOutput(result, golden, basedir); + throw FlutterError(error); + } + return result.passed; + } +} diff --git a/test/widget_for_tests.dart b/test/widget_for_tests.dart new file mode 100644 index 00000000..e90d70c6 --- /dev/null +++ b/test/widget_for_tests.dart @@ -0,0 +1,18 @@ +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/theme/theme.dart'; + +Widget widgetBuilder(Widget child) { + return MaterialApp( + localizationsDelegates: localizationsDelegates, + debugShowCheckedModeBanner: false, + theme: RTThemeData.themeData, + home: Scaffold(body: child), + ); +} + +final localizationsDelegates = [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, +]; diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 83fbeae4..00000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:restaurantour/main.dart'; - -void main() { - testWidgets('Page loads', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const Restaurantour()); - - // Verify that tests will run - expect(find.text('Fetch Restaurants'), findsOneWidget); - }); -} diff --git a/test/widget_tests/favorites_page_test.dart b/test/widget_tests/favorites_page_test.dart new file mode 100644 index 00000000..7cdde165 --- /dev/null +++ b/test/widget_tests/favorites_page_test.dart @@ -0,0 +1,114 @@ +import 'package:restaurantour/components/rt_components.dart'; +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/features/restaurants/pages/favorites/favorites_page.dart'; +import 'package:restaurantour/features/restaurants/pages/favorites/favorites_view_model.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; +import 'package:restaurantour/theme/theme.dart'; + +import '../test.dart'; + +void main() { + RestaurantRepository restaurantRepository = RestaurantRepositoryMock(); + FavoriteService favoritesService = FavoritesServiceMock(); + + setUp(() { + GetIt.I.registerFactory(() => RTImageNetworkMock()); + }); + + tearDown(() { + reset(restaurantRepository); + reset(favoritesService); + GetIt.I.reset(); + }); + + Widget widgetBuilder() => MaterialApp( + localizationsDelegates: localizationsDelegates, + debugShowCheckedModeBanner: false, + theme: RTThemeData.themeData, + home: ChangeNotifierProvider( + create: (context) => FavoritesViewModel.create( + favoritesService: favoritesService, + restaurantRepository: restaurantRepository, + eventBus: EventBusService(), + ), + child: const FavoritesPage(), + ), + ); + + testWidgets('''when successfully fetch the [RestaurantQueryResult] and has data, and also load the favorites fetch the data + should create a [RTItemWidget] for each favorite [Restaurant]''', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([RestaurantDto.fixture().id ?? ''])); + + await tester.pumpWidget(widgetBuilder()); + await tester.pumpAndSettle(); + + expect(find.text('Restaurant Name'), findsAtLeastNWidgets(1)); + expect(find.bySubtype(), findsAtLeastNWidgets(1)); + expect(find.byKey(const Key('favorite-restaurant-0')), findsOneWidget); + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + verify(() => favoritesService.getFavorites()).called(1); + }); + + testWidgets('''when get some error while fetch the favorites, + should render [RTErrorWidget] to inform to the user that something fails''', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenThrow('error mock'); + + await tester.pumpWidget(widgetBuilder()); + await tester.pumpAndSettle(); + + expect(find.bySubtype(), findsAtLeastNWidgets(1)); + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + verify(() => favoritesService.getFavorites()).called(1); + }); + + testWidgets('''when successfully fetch the [RestaurantQueryResult] and has no data, + and also when load the favorites and has no favorites, + should render [RTEmptyWidget] to inform to the user that has no results''', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(const RestaurantQueryResultDto(restaurants: []))); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + await tester.pumpWidget(widgetBuilder()); + await tester.pumpAndSettle(); + + expect(find.bySubtype(), findsAtLeastNWidgets(1)); + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + verify(() => favoritesService.getFavorites()).called(1); + }); + + testWidgets('''when successfully fetch the [RestaurantQueryResult] and has data, + but when load the favorites and has no favorites, + should render [RTEmptyWidget] to inform to the user that has no results''', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + await tester.pumpWidget(widgetBuilder()); + await tester.pumpAndSettle(); + + expect(find.bySubtype(), findsAtLeastNWidgets(1)); + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + verify(() => favoritesService.getFavorites()).called(1); + }); + + testWidgets('''when successfully fetch the [RestaurantQueryResult] and has no data, + but when load the favorites and has favorites, + but the [Restaurant] don't have all the restaurants of favorites list + them should get each one from repository and the favoritesStatus should be content''', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(const RestaurantQueryResultDto(restaurants: []))); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([RestaurantDto.fixture().id ?? ''])); + await tester.pumpWidget(widgetBuilder()); + await tester.pumpAndSettle(); + + expect(find.bySubtype(), findsAtLeastNWidgets(1)); + expect(find.byKey(const Key('favorite-restaurant-0')), findsOneWidget); + }); +} + +final localizationsDelegates = [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, +]; diff --git a/test/widget_tests/restauran_tour_test.dart b/test/widget_tests/restauran_tour_test.dart new file mode 100644 index 00000000..6bd15d2f --- /dev/null +++ b/test/widget_tests/restauran_tour_test.dart @@ -0,0 +1,45 @@ +import 'package:get_it/get_it.dart'; +import 'package:restaurantour/components/rt_components.dart'; +import 'package:restaurantour/features/restaurants/restaurants_screen.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/favorite_service.dart'; + +import '../test.dart'; + +void main() { + RestaurantRepository restaurantRepository = RestaurantRepositoryMock(); + FavoriteService favoritesService = FavoritesServiceMock(); + setUp(() { + GetIt.I.registerFactory(() => restaurantRepository); + GetIt.I.registerFactory(() => favoritesService); + GetIt.I.registerFactory(() => RTImageNetworkMock()); + }); + + tearDown(() { + reset(restaurantRepository); + reset(favoritesService); + GetIt.I.reset(); + }); + + testWidgets('when [RestaurantTour] loads should show the title on the page and fetch the data', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + + await tester.pumpWidget(widgetBuilder(const RestaurantsScreen())); + + expect(find.text('RestauranTour'), findsOneWidget); + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + }); + + testWidgets('when [RestaurantTour] loads should show the tabs [All Restaurants] and [My Favorites] on the page and fetch the data', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + + await tester.pumpWidget(widgetBuilder(const RestaurantsScreen())); + + expect(find.text('All Restaurants'), findsOneWidget); + expect(find.text('My Favorites'), findsOneWidget); + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + }); +} diff --git a/test/widget_tests/restaurant_details_screen_test.dart b/test/widget_tests/restaurant_details_screen_test.dart new file mode 100644 index 00000000..a6253319 --- /dev/null +++ b/test/widget_tests/restaurant_details_screen_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:get_it/get_it.dart'; +import 'package:restaurantour/components/rt_components.dart'; +import 'package:restaurantour/features/details/details_screen.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/event_bus_service.dart'; +import 'package:restaurantour/services/favorite_service.dart'; +import 'package:restaurantour/theme/theme.dart'; + +import '../test.dart'; + +void main() { + EventBusService eventBusService = EventBusServiceMock(); + RestaurantRepository restaurantRepository = RestaurantRepositoryMock(); + FavoriteService favoritesService = FavoritesServiceMock(); + + setUp(() { + GetIt.I.registerFactory(() => eventBusService); + + GetIt.I.registerFactory(() => restaurantRepository); + GetIt.I.registerFactory(() => favoritesService); + GetIt.I.registerFactory(() => RTImageNetworkMock()); + }); + + tearDown(() { + reset(restaurantRepository); + reset(favoritesService); + GetIt.I.reset(); + }); + + Widget widgetBuilder() => MaterialApp( + localizationsDelegates: localizationsDelegates, + debugShowCheckedModeBanner: false, + theme: RTThemeData.themeData, + home: DetailsScreen.create(restaurantId: RestaurantDto.fixture().id), + ); + + testWidgets('''when successfully load the [DetailsScreen] + should create a have the favorite Icon, the name of restaurant''', (WidgetTester tester) async { + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([RestaurantDto.fixture().id ?? ''])); + when(() => restaurantRepository.getRestaurantDetails(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(RestaurantDto.fixture())); + when(() => restaurantRepository.getReviews(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(ReviewQueryResultDto.fixture())); + + await tester.pumpWidget(widgetBuilder()); + await tester.pumpAndSettle(); + + expect(find.text('Restaurant Name'), findsOneWidget); + expect(find.text('Open Now'), findsOneWidget); + expect(find.bySubtype(), findsAtLeastNWidgets(1)); + }); + + testWidgets('''when successfully load the [DetailsScreen] but fails to load favorites + should display the error screen''', (WidgetTester tester) async { + when(() => favoritesService.getFavorites()).thenThrow('error mock'); + + await tester.pumpWidget(widgetBuilder()); + await tester.pumpAndSettle(); + + expect(find.bySubtype(), findsOneWidget); + }); + + testWidgets('''if successfully load the [DetailsScreen] and the restaurant is already favorite, + when tap on favorite button should call the service to remove from favorites passing the id''', (WidgetTester tester) async { + final restaurantId = RestaurantDto.fixture().id!; + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([restaurantId])); + when(() => favoritesService.removeFavorite(any())).thenAnswer((_) => Future.value()); + when(() => restaurantRepository.getRestaurantDetails(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(RestaurantDto.fixture())); + when(() => restaurantRepository.getReviews(restaurantId: any(named: 'restaurantId'))).thenAnswer((_) => Future.value(ReviewQueryResultDto.fixture())); + + await tester.pumpWidget(widgetBuilder()); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('favorite-button'))); + await tester.pumpAndSettle(); + + expect(find.text('Restaurant Name'), findsOneWidget); + expect(find.text('Open Now'), findsOneWidget); + expect(find.bySubtype(), findsAtLeastNWidgets(1)); + verify(() => favoritesService.removeFavorite(restaurantId)).called(1); + verifyNever(() => favoritesService.addFavorite(any())); + }); +} + +final localizationsDelegates = [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, +]; diff --git a/test/widget_tests/restaurants_page_test.dart b/test/widget_tests/restaurants_page_test.dart new file mode 100644 index 00000000..d0ea58a3 --- /dev/null +++ b/test/widget_tests/restaurants_page_test.dart @@ -0,0 +1,80 @@ +import 'package:restaurantour/components/rt_components.dart'; +import 'package:restaurantour/core/core.dart'; +import 'package:restaurantour/features/restaurants/pages/restaurants/restaurants_page.dart'; +import 'package:restaurantour/features/restaurants/pages/restaurants/restaurants_view_model.dart'; +import 'package:restaurantour/models/dto.dart'; +import 'package:restaurantour/repositories/restaurant_repository.dart'; +import 'package:restaurantour/services/favorite_service.dart'; +import 'package:restaurantour/theme/theme.dart'; + +import '../test.dart'; + +void main() { + RestaurantRepository restaurantRepository = RestaurantRepositoryMock(); + FavoriteService favoritesService = FavoritesServiceMock(); + + setUp(() { + GetIt.I.registerFactory(() => RTImageNetworkMock()); + }); + + tearDown(() { + reset(restaurantRepository); + reset(favoritesService); + GetIt.I.reset(); + }); + + Widget widgetBuilder() => MaterialApp( + localizationsDelegates: localizationsDelegates, + debugShowCheckedModeBanner: false, + theme: RTThemeData.themeData, + home: ChangeNotifierProvider( + create: (context) => RestaurantsViewModel.create(restaurantRepository: restaurantRepository), + child: const RestaurantsPage(), + ), + ); + + testWidgets('''when successfully fetch the [RestaurantQueryResult] and has data, + should create a [RTItemWidget] for each [Restaurant]''', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(RestaurantQueryResultDto.fixture())); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + + await tester.pumpWidget(widgetBuilder()); + await tester.pumpAndSettle(); + + expect(find.text('Restaurant Name'), findsAtLeastNWidgets(1)); + expect(find.bySubtype(), findsAtLeastNWidgets(1)); + expect(find.byKey(const Key('restaurant-0')), findsOneWidget); + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + }); + + testWidgets('''when get some error while fetch the [RestaurantQueryResult], + should render [RTErrorWidget] to inform to the user that something fails''', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenThrow('error mock'); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + + await tester.pumpWidget(widgetBuilder()); + await tester.pumpAndSettle(); + + expect(find.bySubtype(), findsAtLeastNWidgets(1)); + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + }); + + testWidgets('''when successfully fetch the [RestaurantQueryResult] and has no data, + should render [RTEmptyWidget] to inform to the user that has no results''', (WidgetTester tester) async { + when(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).thenAnswer((_) => Future.value(const RestaurantQueryResultDto(restaurants: []))); + when(() => favoritesService.getFavorites()).thenAnswer((_) => Future.value([])); + + await tester.pumpWidget(widgetBuilder()); + await tester.pumpAndSettle(); + + expect(find.bySubtype(), findsAtLeastNWidgets(1)); + verify(() => restaurantRepository.getRestaurants(offset: any(named: 'offset'))).called(1); + }); +} + +final localizationsDelegates = [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, +];