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:
+
+
+Setup
+
+get # run flutter pub get
+setup-fvm-version # download and setup the fvm flutter version for the project
+pre-setup # clean the project and install some dependencies
+setup-env-file # generate the .env file
+setup # run pre-setup, setup-env-file, run code generation and run unit tests
+try-fix-gen # try run code generation if fail try fix and run code generation again
+fix-gen # perform some steps to fix code generation
+
+
+
+
+
+
+Tests
+tests # run unit and golden tests
+all-tests # run all type of tests on project, unit, golden and integration
+unit-tests # run only unit tests
+tests-ci-cd #run tests with a flag that enable threshold for golden tests on pipeline
+integration-tests # run integration test
+update-goldens # update the golden images
+remove-goldens-failures # this remove the failure folder of golden tests
+
+
+
+
+
+Coverage
+See Coverage reports
+
+lcov # run the tests and show the coverage report filtered
+show-coverage # run tests and show the coverage report
+lcov-ignore # remove some files from coverage report
+
+
+
+
+
+Code gen
+l10n # generate the localization files
+asset-gen # generate the assets file
+gen # generate models using build_runner
+
+
+
+
### 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