├── .gitignore ├── .metadata ├── README.md ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── my_movie_list │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable │ │ │ └── launch_background.xml │ │ │ ├── mipmap-hdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-mdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxxhdpi │ │ │ └── ic_launcher.png │ │ │ └── values │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties ├── settings.gradle └── settings_aar.gradle ├── assets ├── cats_img │ ├── cat1.jpeg │ ├── cat2.jpeg │ ├── cat3.jpeg │ └── cat4.jpeg └── img │ ├── loading-gif.gif │ ├── loading.gif │ └── no-image.jpg ├── coverage └── lcov.info ├── ios ├── .gitignore ├── Flutter │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Podfile ├── Podfile.lock ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-App-1024x1024@1x.png │ │ ├── Icon-App-20x20@1x.png │ │ ├── Icon-App-20x20@2x.png │ │ ├── Icon-App-20x20@3x.png │ │ ├── Icon-App-29x29@1x.png │ │ ├── Icon-App-29x29@2x.png │ │ ├── Icon-App-29x29@3x.png │ │ ├── Icon-App-40x40@1x.png │ │ ├── Icon-App-40x40@2x.png │ │ ├── Icon-App-40x40@3x.png │ │ ├── Icon-App-60x60@2x.png │ │ ├── Icon-App-60x60@3x.png │ │ ├── Icon-App-76x76@1x.png │ │ ├── Icon-App-76x76@2x.png │ │ └── Icon-App-83.5x83.5@2x.png │ └── LaunchImage.imageset │ │ ├── Contents.json │ │ ├── LaunchImage.png │ │ ├── LaunchImage@2x.png │ │ ├── LaunchImage@3x.png │ │ └── README.md │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Info.plist │ └── Runner-Bridging-Header.h ├── l10n.yaml ├── lib ├── core │ ├── api │ │ ├── movies_api.dart │ │ └── movies_endpoint.dart │ ├── errors │ │ ├── exception.dart │ │ └── failure.dart │ ├── network │ │ └── network_info.dart │ ├── routes.dart │ └── usecases │ │ └── usecase.dart ├── data │ ├── datasources │ │ ├── genres │ │ │ ├── genres_local_data_source.dart │ │ │ └── genres_remote_data_source.dart │ │ └── movies │ │ │ ├── movies_local_data_source.dart │ │ │ └── movies_remote_data_source.dart │ └── repositories │ │ ├── genres_repository_impl.dart │ │ └── movies_repository_impl.dart ├── domain │ ├── entities │ │ ├── actor.dart │ │ ├── genre.dart │ │ ├── movie.dart │ │ ├── production_company.dart │ │ └── production_country.dart │ ├── repositories │ │ ├── genres_repository.dart │ │ └── movies_repository.dart │ └── usecases │ │ ├── get_genres.dart │ │ ├── get_movie_cast.dart │ │ ├── get_movie_detail.dart │ │ ├── get_movies.dart │ │ └── search_movies.dart ├── injection_container.dart ├── l10n │ ├── I10n.dart │ ├── app_en.arb │ └── app_es.arb ├── main.dart └── presentation │ ├── custom_drawer │ ├── business_logic │ │ └── drawer_nav_cubit │ │ │ ├── drawer_nav_cubit.dart │ │ │ └── drawer_nav_state.dart │ ├── custom_drawer.dart │ ├── drawer_categories.dart │ └── drawer_category_button.dart │ ├── genres │ ├── business_logic │ │ ├── genres_cubit.dart │ │ └── genres_state.dart │ └── genres_view │ │ └── genres_loading_view.dart │ ├── global_widgets │ └── dialogs │ │ └── on_will_pop_dialog.dart │ ├── index.dart │ └── movies │ ├── business_logic │ ├── appbar_search_mode_cubit.dart │ ├── movie_cast_cubit │ │ ├── movie_cast_cubit.dart │ │ └── movie_cast_state.dart │ ├── movie_details_cubit │ │ ├── movie_details_cubit.dart │ │ └── movie_details_state.dart │ ├── movies_bloc │ │ ├── movies_bloc.dart │ │ ├── movies_event.dart │ │ └── movies_state.dart │ └── movies_search_cubit │ │ ├── movies_search_cubit.dart │ │ └── movies_search_state.dart │ ├── movie_profile_view │ ├── movie_profile_appbar.dart │ └── movie_profile_view.dart │ ├── movies_view │ ├── movies_grid_view.dart │ ├── movies_loading_view.dart │ └── movies_view.dart │ ├── movies_widgets │ ├── movie_poster.dart │ ├── movie_rating.dart │ └── movies_appbar.dart │ └── search_movies │ ├── search_movies_bar.dart │ ├── search_movies_success.dart │ └── search_movies_suggestions.dart ├── pubspec.lock ├── pubspec.yaml ├── readme_sources ├── architecture.jpeg ├── categories.gif ├── clean_architecture.jpeg ├── data_layer.png ├── domain_layer.png ├── movie_profile.gif ├── presentation_layer.png └── search.gif └── test ├── core └── network │ └── network_info_test.dart ├── data ├── datasources │ ├── genres │ │ ├── genres_local_data_source_test.dart │ │ └── genres_remote_data_source_test.dart │ └── movies │ │ ├── movies_local_data_source_test.dart │ │ └── movies_remote_data_source_test.dart └── repositories │ ├── genres_repository_impl_test.dart │ └── movie_repository_impl_test.dart ├── domain └── usecases │ ├── get_genres_test.dart │ ├── get_movie_detail_test.dart │ ├── get_movies_test.dart │ └── search_movies_test.dart ├── fixtures ├── fixture_reader.dart ├── genres.json ├── genres_cached.json ├── movie_detail.json ├── movies_cached.json └── movies_now_playing.json └── presentation ├── custom_drawer └── business_logic │ └── drawer_nav_cubit │ └── drawer_nav_cubit_test.dart ├── genres └── business_logic │ └── genres_cubit_test.dart └── movies └── business_logic ├── appbar_search_mode_cubit_test.dart ├── movie_cast_cubit └── movie_cast_cubit_test.dart ├── movie_details_cubit └── movie_details_cubit_test.dart ├── movies_bloc └── movies_bloc_test.dart └── movies_search_cubit └── movies_search_cubit_test.dart /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | **/ios/Flutter/.last_build_id 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | 34 | # Web related 35 | lib/generated_plugin_registrant.dart 36 | 37 | # Symbolication related 38 | app.*.symbols 39 | 40 | # Obfuscation related 41 | app.*.map.json 42 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flutter - Clean Architecture & TDD 2 | 3 | [![Linkedin Badge](https://img.shields.io/badge/-Linkedin-0e76a8?style=flat&labelColor=0e76a8&logo=linkedin&logoColor=white)](https://www.linkedin.com/in/jf96/) 4 | ![visitors](https://visitor-badge.glitch.me/badge?page_id=sr-Te.movies-CleanArchitecture-TDD) 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ## Introducción 15 | 16 | Este proyecto consta de una aplicación móvil desarrollada en [Flutter](https://flutter.dev/), la cual muestra información acerca de películas 17 | haciendo consultas a la API de [themoviedb.org](https://www.themoviedb.org/). 18 | 19 | ## Tabla De Contenidos 20 | 21 | 1. [Introducción](#intro) 22 | 2. [Objetivos](#obj) 23 | 3. [Vista Previa](#preview) 24 | 25 | 4. [Instalación Proyecto](#install) 26 | 5. [Estructura Del Proyecto](#structure) 27 | 6. [Arquitectura Limpia (Clean Architecture)](#clean_architecture) 28 | 7. [Flujo De Trabajo (Workflow)](#workflow) 29 | - [Capa De Presentación](#presentation) 30 | - [Capa De Dominio](#domain) 31 | - [Capa De Datos](#data) 32 | 8. [TDD](#tdd) 33 | 9. [Testeo Unitario En Flutter (Unit Testing)](#tests) 34 | 35 | 36 | 37 | ## Objetivos 38 | 39 | - Se tiene como objetivo practicar, compartir y discutir los temas aprendidos en el blog de 40 | [Resocoder](https://resocoder.com/category/tutorials/flutter/tdd-clean-architecture/). 41 | - Consumir toda la API de [themoviedb.org](https://www.themoviedb.org/). 42 | 43 | 44 | 45 | ## Vista Previa 46 | 47 |

48 | 49 | 50 | 51 |

52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ## Instalación Proyecto 62 | 63 | - [Instalar Flutter](https://flutter.dev/docs/get-started/install) 64 | - Crear una cuenta y obtener un [API KEY aquí](https://www.themoviedb.org/documentation/api) 65 | - Copiar el `API KEY` obtenido en `./lib/core/api/movies_api.dart` 66 | - Flutter run 67 | 68 | 69 | 70 | ## Estructura Del Proyecto 71 | 72 | ``` 73 | ├─ core/ NÚCLEO 74 | │ ├─ api/ definición de elementos asociados a la API 75 | │ ├─ errors/ definición de errores y excepciones 76 | │ ├─ network/ utils asociados a la conexión de internet 77 | │ ├─ usecases/ definición de los casos de uso 78 | │ └─ routes.dart rutas de nuestro proyecto 79 | │ 80 | ├─ data/ CAPA DE DATOS 81 | │ ├─ datasources/ origen de los datos solicitados por el repositorio 82 | │ ├─ models/ contienen funciones fromJson & toJson y heredan de una entidad 83 | │ └─ repositories implementación de los repositorios 84 | │ 85 | ├─ domain/ CAPA DE DOMINIO 86 | │ ├─ entities/ entidades 87 | │ ├─ repositories/ definición de los repositorios 88 | │ └─ usecases/ implementación de los casos de uso 89 | │ 90 | ├─ presentation/ CAPA DE PRESENTACIÓN 91 | │ ├─ business_logic/ gestor de estados 92 | │ ├─ views/ vistas 93 | │ └─ widgets/ widgets personalizados utilizados en las vistas 94 | │ 95 | ├─ injection_container.dart inyección de dependencias 96 | └─ main.dart 97 | 98 | ``` 99 | 100 | 101 | 102 | ## Arquitectura Limpia (Clean Architecture) 103 | 104 | Propuesta por [Robert C. Martin](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html). 105 | 106 | La idea principal en la arquitectura limpia es separar el código en capas independientes, las cuales se vuelven más abstractas 107 | cuando se avanza a las capas interiores. 108 | 109 | Porque las capas interiores representan reglas que restringen a las capas exteriores, lo que vendría siendo **la regla 110 | dependencia** (Las capas exteriores pueden depender de las interiores, pero no al revés). 111 | 112 |
113 | 114 |
115 | 116 | La idea sería tener los requerimientos definidos, además de las entidades y casos de uso que contendrá nuestro proyecto. Teniendo estas 117 | reglas claras, se pueden escribir aquellos mecanismos que son necesarios para que el caso de uso sea bien ejecutado. 118 | 119 | 120 | 121 | ## Flujo De Trabajo (Workflow) 122 | 123 | El usuario interactúa con las **vistas** gatillando **eventos**, los que al ser escuchados por nuestro **gestor de estados**, 124 | genera un nuevo **flujo de estados**; éstos las pueden mutar para indicar en que etapa del proceso está y así permite desarrollar un **caso de uso**. 125 | 126 |
127 | 128 |
129 | 130 | 131 | 132 | #### Capa de Presentación 133 | 134 | Los widgets componen nuestras vistas y necesitan una gestión 135 | de estados para mutar según se requiera durante la vida de la aplicación. 136 | 137 |
138 | 139 |
140 | 141 | En esta ocasión se utilizó [flutter_bloc](https://pub.dev/packages/flutter_bloc). 142 | 143 | 144 | 145 | #### Capa de dominio 146 | 147 | Como se ve en la imagen, el repositorio pertenece tanto a la capa de datos como a la de dominio, 148 | con la gran diferencia que, en la capa de dominio sólo están las definiciones abstractas de éste 149 | y en la capa de datos estaría la implementación. 150 | 151 | Así se dependerá de un "contrato" definido en la capa de dominio, que deberá ser cumplido en la capa de 152 | datos. 153 | 154 |
155 | 156 |
157 | 158 | 159 | 160 | #### Capa de datos 161 | 162 | Consta de una implementación de los repositorios y fuentes de datos: remoto (API) y local (CACHE). 163 | 164 | En el repositorio se decide si devolver datos desde la api o aquellos almacenados en cache y 165 | cuando almacenarlos. 166 | 167 |
168 | 169 |
170 | 171 | En esta capa no se trabaja con entidades, se trabaja con modelos, éstos heredan de una entidad y tienen métodos toJson y fromJson, 172 | se obtiene el beneficio, por si en algún futuro se decide cambiar de json a xml, sin tener demasiados quebraderos de cabeza. 173 | 174 | 175 | 176 | ## TDD 177 | 178 | 179 | 180 | 181 |
182 | 183 | **Test Driven Development:** es un proceso de desarrollo iterativo, donde el desarrollador escribe una prueba antes de escribir el código 184 | suficiente para cumplirla y luego refactoriza si es necesario. 185 | 186 | Las ventajas de este proceso es que el desarrollador se centra m√°s en los requisitos del software, preguntándose el por qué necesita la fracción de código 187 | que está punto de escribir, antes de continuar con la implementación. 188 | 189 | Mediante este proceso el desarrollador puede identificar requisitos mal definidos y mejorar 190 | sus hábitos con el tiempo, lo que conducir√≠a a una mejora en su calidad de código. 191 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply plugin: 'kotlin-android' 26 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 27 | 28 | android { 29 | compileSdkVersion 29 30 | 31 | sourceSets { 32 | main.java.srcDirs += 'src/main/kotlin' 33 | } 34 | 35 | lintOptions { 36 | disable 'InvalidPackage' 37 | } 38 | 39 | defaultConfig { 40 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 41 | applicationId "com.example.my_movie_list" 42 | minSdkVersion 16 43 | targetSdkVersion 29 44 | versionCode flutterVersionCode.toInteger() 45 | versionName flutterVersionName 46 | } 47 | 48 | buildTypes { 49 | release { 50 | // TODO: Add your own signing config for the release build. 51 | // Signing with the debug keys for now, so `flutter run --release` works. 52 | signingConfig signingConfigs.debug 53 | } 54 | } 55 | } 56 | 57 | flutter { 58 | source '../..' 59 | } 60 | 61 | dependencies { 62 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 63 | } 64 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 8 | 9 | 10 | 15 | 16 | 23 | 27 | 31 | 36 | 40 | 41 | 42 | 43 | 44 | 45 | 47 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/my_movie_list/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example.my_movie_list 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.3.50' 3 | repositories { 4 | google() 5 | jcenter() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:3.5.0' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | } 12 | } 13 | 14 | allprojects { 15 | repositories { 16 | google() 17 | jcenter() 18 | } 19 | } 20 | 21 | rootProject.buildDir = '../build' 22 | subprojects { 23 | project.buildDir = "${rootProject.buildDir}/${project.name}" 24 | } 25 | subprojects { 26 | project.evaluationDependsOn(':app') 27 | } 28 | 29 | task clean(type: Delete) { 30 | delete rootProject.buildDir 31 | } 32 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | android.enableR8=true 5 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip 7 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | def properties = new Properties() 5 | 6 | assert localPropertiesFile.exists() 7 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | 9 | def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | -------------------------------------------------------------------------------- /android/settings_aar.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | -------------------------------------------------------------------------------- /assets/cats_img/cat1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/assets/cats_img/cat1.jpeg -------------------------------------------------------------------------------- /assets/cats_img/cat2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/assets/cats_img/cat2.jpeg -------------------------------------------------------------------------------- /assets/cats_img/cat3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/assets/cats_img/cat3.jpeg -------------------------------------------------------------------------------- /assets/cats_img/cat4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/assets/cats_img/cat4.jpeg -------------------------------------------------------------------------------- /assets/img/loading-gif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/assets/img/loading-gif.gif -------------------------------------------------------------------------------- /assets/img/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/assets/img/loading.gif -------------------------------------------------------------------------------- /assets/img/no-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/assets/img/no-image.jpg -------------------------------------------------------------------------------- /coverage/lcov.info: -------------------------------------------------------------------------------- 1 | SF:lib/core/network/network_info.dart 2 | DA:9,1 3 | DA:11,1 4 | DA:12,2 5 | LF:3 6 | LH:3 7 | end_of_record 8 | SF:lib/data/datasources/genres/genres_local_data_source.dart 9 | DA:19,1 10 | DA:21,1 11 | DA:23,2 12 | DA:25,2 13 | DA:30,1 14 | DA:31,2 15 | DA:33,3 16 | DA:35,1 17 | LF:8 18 | LH:8 19 | end_of_record 20 | SF:lib/data/models/genre_model.dart 21 | DA:3,3 22 | DA:4,0 23 | DA:6,3 24 | DA:7,6 25 | DA:8,3 26 | DA:9,3 27 | DA:14,1 28 | DA:15,1 29 | DA:16,1 30 | DA:17,0 31 | DA:22,0 32 | DA:23,0 33 | DA:24,0 34 | DA:25,0 35 | DA:30,0 36 | DA:31,0 37 | DA:34,4 38 | DA:37,4 39 | DA:42,6 40 | DA:43,3 41 | DA:44,3 42 | DA:47,0 43 | DA:48,0 44 | DA:49,0 45 | LF:24 46 | LH:13 47 | end_of_record 48 | SF:lib/domain/entities/genre.dart 49 | DA:5,5 50 | LF:1 51 | LH:1 52 | end_of_record 53 | SF:lib/core/api/movies_api.dart 54 | DA:10,2 55 | DA:11,1 56 | DA:13,1 57 | DA:14,2 58 | DA:16,2 59 | DA:19,1 60 | DA:21,2 61 | DA:22,2 62 | DA:24,0 63 | DA:25,0 64 | DA:28,0 65 | DA:32,0 66 | DA:35,0 67 | DA:39,0 68 | DA:42,1 69 | DA:43,1 70 | DA:44,2 71 | DA:47,0 72 | LF:18 73 | LH:11 74 | end_of_record 75 | SF:lib/data/datasources/genres/genres_remote_data_source.dart 76 | DA:17,1 77 | DA:20,1 78 | DA:21,4 79 | DA:22,2 80 | DA:23,4 81 | DA:25,1 82 | LF:6 83 | LH:6 84 | end_of_record 85 | SF:lib/data/datasources/movies/movies_local_data_source.dart 86 | DA:17,1 87 | DA:19,1 88 | DA:21,2 89 | DA:23,2 90 | DA:27,1 91 | DA:29,2 92 | DA:31,3 93 | DA:33,1 94 | LF:8 95 | LH:8 96 | end_of_record 97 | SF:lib/data/models/movie_model.dart 98 | DA:8,3 99 | DA:9,0 100 | DA:11,3 101 | DA:12,6 102 | DA:13,3 103 | DA:14,3 104 | DA:19,1 105 | DA:20,1 106 | DA:21,1 107 | DA:22,0 108 | DA:48,4 109 | DA:68,4 110 | DA:90,6 111 | DA:91,3 112 | DA:92,3 113 | DA:93,3 114 | DA:94,12 115 | DA:95,1 116 | DA:96,3 117 | DA:97,3 118 | DA:98,3 119 | DA:99,3 120 | DA:100,6 121 | DA:101,3 122 | DA:102,6 123 | DA:103,3 124 | DA:104,3 125 | DA:105,6 126 | DA:106,3 127 | DA:107,3 128 | DA:108,3 129 | DA:109,3 130 | DA:110,3 131 | DA:111,2 132 | DA:112,3 133 | DA:113,3 134 | DA:114,3 135 | DA:115,2 136 | DA:116,3 137 | DA:117,3 138 | DA:120,0 139 | DA:121,0 140 | DA:122,0 141 | DA:123,0 142 | DA:124,0 143 | DA:125,0 144 | DA:126,0 145 | DA:127,0 146 | DA:128,0 147 | DA:129,0 148 | DA:131,0 149 | DA:132,0 150 | DA:133,0 151 | DA:134,0 152 | DA:135,0 153 | DA:136,0 154 | DA:137,0 155 | DA:139,0 156 | DA:141,0 157 | LF:59 158 | LH:38 159 | end_of_record 160 | SF:lib/domain/entities/movie.dart 161 | DA:27,6 162 | LF:1 163 | LH:1 164 | end_of_record 165 | SF:lib/data/models/production_company_model.dart 166 | DA:2,1 167 | DA:14,1 168 | DA:15,1 169 | DA:16,1 170 | DA:17,1 171 | DA:18,1 172 | DA:19,1 173 | DA:22,0 174 | DA:23,0 175 | DA:24,0 176 | DA:25,0 177 | DA:26,0 178 | LF:12 179 | LH:7 180 | end_of_record 181 | SF:lib/data/models/production_country_model.dart 182 | DA:2,1 183 | DA:10,1 184 | DA:11,1 185 | DA:12,1 186 | DA:13,1 187 | DA:16,0 188 | DA:17,0 189 | DA:18,0 190 | LF:8 191 | LH:5 192 | end_of_record 193 | SF:lib/data/datasources/movies/movies_remote_data_source.dart 194 | DA:31,1 195 | DA:34,1 196 | DA:39,3 197 | DA:40,1 198 | DA:42,2 199 | DA:43,4 200 | DA:45,1 201 | DA:49,1 202 | DA:50,4 203 | DA:51,2 204 | DA:52,4 205 | DA:54,1 206 | DA:58,1 207 | DA:59,3 208 | DA:60,1 209 | DA:62,2 210 | DA:63,3 211 | DA:65,1 212 | DA:69,0 213 | DA:70,0 214 | DA:71,0 215 | DA:73,0 216 | DA:74,0 217 | DA:76,0 218 | LF:24 219 | LH:18 220 | end_of_record 221 | SF:lib/data/models/actor_model.dart 222 | DA:3,0 223 | DA:4,0 224 | DA:6,0 225 | DA:7,0 226 | DA:8,0 227 | DA:9,0 228 | DA:15,0 229 | DA:28,0 230 | DA:51,0 231 | DA:52,0 232 | DA:53,0 233 | DA:54,0 234 | DA:55,0 235 | DA:56,0 236 | DA:57,0 237 | DA:58,0 238 | DA:59,0 239 | DA:60,0 240 | DA:61,0 241 | DA:62,0 242 | DA:63,0 243 | DA:66,0 244 | DA:67,0 245 | DA:68,0 246 | DA:69,0 247 | DA:70,0 248 | DA:71,0 249 | DA:72,0 250 | DA:73,0 251 | DA:74,0 252 | DA:75,0 253 | DA:76,0 254 | DA:77,0 255 | DA:78,0 256 | LF:34 257 | LH:0 258 | end_of_record 259 | SF:lib/domain/entities/actor.dart 260 | DA:10,0 261 | DA:20,0 262 | DA:21,0 263 | DA:24,0 264 | LF:4 265 | LH:0 266 | end_of_record 267 | SF:lib/core/errors/failure.dart 268 | DA:4,2 269 | DA:5,2 270 | DA:20,3 271 | DA:21,3 272 | DA:22,3 273 | DA:24,3 274 | LF:6 275 | LH:6 276 | end_of_record 277 | SF:lib/data/repositories/genres_repository_impl.dart 278 | DA:19,1 279 | DA:26,1 280 | DA:27,3 281 | DA:28,0 282 | DA:29,3 283 | DA:31,3 284 | DA:32,2 285 | DA:33,1 286 | DA:34,1 287 | DA:35,1 288 | DA:36,2 289 | DA:40,3 290 | DA:41,1 291 | DA:42,1 292 | DA:43,1 293 | DA:44,2 294 | LF:16 295 | LH:15 296 | end_of_record 297 | SF:lib/data/repositories/movies_repository_impl.dart 298 | DA:20,1 299 | DA:30,1 300 | DA:35,1 301 | DA:36,0 302 | DA:37,1 303 | DA:38,0 304 | DA:39,3 305 | DA:41,3 306 | DA:46,4 307 | DA:47,1 308 | DA:48,1 309 | DA:49,1 310 | DA:50,2 311 | DA:54,3 312 | DA:55,2 313 | DA:57,1 314 | DA:58,1 315 | DA:59,1 316 | DA:60,2 317 | DA:66,1 318 | DA:70,3 319 | DA:72,3 320 | DA:76,1 321 | DA:77,1 322 | DA:78,2 323 | DA:81,2 324 | DA:85,1 325 | DA:89,3 326 | DA:91,3 327 | DA:95,1 328 | DA:96,1 329 | DA:97,2 330 | DA:100,2 331 | DA:104,0 332 | DA:108,0 333 | DA:110,0 334 | DA:114,0 335 | DA:115,0 336 | DA:116,0 337 | DA:119,0 338 | DA:122,1 339 | DA:123,1 340 | DA:124,0 341 | DA:126,2 342 | DA:130,1 343 | DA:131,1 344 | DA:132,0 345 | DA:133,0 346 | DA:135,1 347 | DA:136,2 348 | LF:50 349 | LH:38 350 | end_of_record 351 | SF:lib/domain/usecases/get_movie_detail.dart 352 | DA:11,1 353 | DA:14,1 354 | DA:15,5 355 | DA:23,2 356 | DA:25,1 357 | DA:26,3 358 | LF:6 359 | LH:6 360 | end_of_record 361 | SF:lib/core/usecases/usecase.dart 362 | DA:11,0 363 | DA:12,0 364 | LF:2 365 | LH:0 366 | end_of_record 367 | SF:lib/domain/usecases/get_movies.dart 368 | DA:14,1 369 | DA:17,1 370 | DA:18,3 371 | DA:19,1 372 | DA:20,1 373 | DA:21,1 374 | DA:31,2 375 | DA:37,1 376 | DA:38,4 377 | LF:9 378 | LH:9 379 | end_of_record 380 | SF:lib/domain/usecases/get_genres.dart 381 | DA:12,1 382 | DA:15,1 383 | DA:16,4 384 | DA:23,2 385 | DA:25,1 386 | DA:26,2 387 | LF:6 388 | LH:6 389 | end_of_record 390 | SF:lib/domain/usecases/search_movies.dart 391 | DA:13,1 392 | DA:16,1 393 | DA:17,5 394 | DA:26,2 395 | DA:28,1 396 | DA:29,3 397 | LF:6 398 | LH:6 399 | end_of_record 400 | SF:lib/presentation/genres/business_logic/genres_state.dart 401 | DA:4,1 402 | DA:5,1 403 | DA:14,1 404 | DA:16,1 405 | DA:17,2 406 | DA:22,1 407 | DA:24,1 408 | DA:25,2 409 | LF:8 410 | LH:8 411 | end_of_record 412 | SF:lib/presentation/genres/business_logic/genres_cubit.dart 413 | DA:15,1 414 | DA:17,0 415 | DA:18,2 416 | DA:20,1 417 | DA:23,2 418 | DA:24,4 419 | DA:25,1 420 | DA:26,1 421 | DA:27,3 422 | DA:28,2 423 | DA:33,1 424 | DA:34,1 425 | DA:35,1 426 | LF:13 427 | LH:12 428 | end_of_record 429 | SF:lib/presentation/movies/business_logic/movies_bloc/movies_state.dart 430 | DA:4,1 431 | DA:5,1 432 | DA:14,1 433 | DA:16,1 434 | DA:17,2 435 | DA:22,1 436 | DA:24,1 437 | DA:25,2 438 | LF:8 439 | LH:8 440 | end_of_record 441 | SF:lib/presentation/movies/business_logic/movies_bloc/movies_event.dart 442 | DA:4,0 443 | DA:5,0 444 | DA:13,1 445 | DA:15,0 446 | DA:16,0 447 | LF:5 448 | LH:1 449 | end_of_record 450 | SF:lib/presentation/movies/business_logic/movies_bloc/movies_bloc.dart 451 | DA:15,1 452 | DA:17,0 453 | DA:18,2 454 | DA:21,1 455 | DA:22,3 456 | DA:25,1 457 | DA:26,2 458 | DA:27,3 459 | DA:28,1 460 | DA:29,1 461 | DA:30,1 462 | DA:31,1 463 | DA:33,2 464 | DA:34,3 465 | DA:35,2 466 | LF:15 467 | LH:14 468 | end_of_record 469 | SF:lib/presentation/movies/business_logic/movie_details_cubit/movie_details_state.dart 470 | DA:4,1 471 | DA:5,1 472 | DA:12,1 473 | DA:14,1 474 | DA:15,2 475 | DA:20,1 476 | DA:22,1 477 | DA:23,2 478 | LF:8 479 | LH:8 480 | end_of_record 481 | SF:lib/presentation/movies/business_logic/movie_details_cubit/movie_details_cubit.dart 482 | DA:14,1 483 | DA:15,0 484 | DA:16,2 485 | DA:18,1 486 | DA:19,2 487 | DA:20,3 488 | DA:21,1 489 | DA:23,1 490 | DA:24,2 491 | DA:25,2 492 | DA:27,3 493 | LF:11 494 | LH:10 495 | end_of_record 496 | SF:lib/presentation/movies/business_logic/movies_search_cubit/movies_search_state.dart 497 | DA:4,1 498 | DA:5,1 499 | DA:14,1 500 | DA:16,1 501 | DA:17,2 502 | DA:22,1 503 | DA:24,1 504 | DA:25,2 505 | LF:8 506 | LH:8 507 | end_of_record 508 | SF:lib/presentation/movies/business_logic/movies_search_cubit/movies_search_cubit.dart 509 | DA:17,1 510 | DA:19,0 511 | DA:20,2 512 | DA:22,1 513 | DA:23,2 514 | DA:26,1 515 | DA:30,2 516 | DA:31,0 517 | DA:33,2 518 | DA:36,1 519 | DA:37,1 520 | DA:38,1 521 | DA:39,3 522 | DA:40,1 523 | DA:43,1 524 | DA:44,2 525 | DA:45,2 526 | DA:47,3 527 | LF:18 528 | LH:16 529 | end_of_record 530 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | *.mode1v3 2 | *.mode2v3 3 | *.moved-aside 4 | *.pbxuser 5 | *.perspectivev3 6 | **/*sync/ 7 | .sconsign.dblite 8 | .tags* 9 | **/.vagrant/ 10 | **/DerivedData/ 11 | Icon? 12 | **/Pods/ 13 | **/.symlinks/ 14 | profile 15 | xcuserdata 16 | **/.generated/ 17 | Flutter/App.framework 18 | Flutter/Flutter.framework 19 | Flutter/Flutter.podspec 20 | Flutter/Generated.xcconfig 21 | Flutter/app.flx 22 | Flutter/app.zip 23 | Flutter/flutter_assets/ 24 | Flutter/flutter_export_environment.sh 25 | ServiceDefinitions.json 26 | Runner/GeneratedPluginRegistrant.* 27 | 28 | # Exceptions to above rules. 29 | !default.mode1v3 30 | !default.mode2v3 31 | !default.pbxuser 32 | !default.perspectivev3 33 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 8.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | # platform :ios, '9.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | use_frameworks! 32 | use_modular_headers! 33 | 34 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 | end 36 | 37 | post_install do |installer| 38 | installer.pods_project.targets.each do |target| 39 | flutter_additional_ios_build_settings(target) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /ios/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Flutter (1.0.0) 3 | - FMDB (2.7.5): 4 | - FMDB/standard (= 2.7.5) 5 | - FMDB/standard (2.7.5) 6 | - path_provider (0.0.1): 7 | - Flutter 8 | - shared_preferences (0.0.1): 9 | - Flutter 10 | - sqflite (0.0.2): 11 | - Flutter 12 | - FMDB (>= 2.7.5) 13 | 14 | DEPENDENCIES: 15 | - Flutter (from `Flutter`) 16 | - path_provider (from `.symlinks/plugins/path_provider/ios`) 17 | - shared_preferences (from `.symlinks/plugins/shared_preferences/ios`) 18 | - sqflite (from `.symlinks/plugins/sqflite/ios`) 19 | 20 | SPEC REPOS: 21 | trunk: 22 | - FMDB 23 | 24 | EXTERNAL SOURCES: 25 | Flutter: 26 | :path: Flutter 27 | path_provider: 28 | :path: ".symlinks/plugins/path_provider/ios" 29 | shared_preferences: 30 | :path: ".symlinks/plugins/shared_preferences/ios" 31 | sqflite: 32 | :path: ".symlinks/plugins/sqflite/ios" 33 | 34 | SPEC CHECKSUMS: 35 | Flutter: 434fef37c0980e73bb6479ef766c45957d4b510c 36 | FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a 37 | path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c 38 | shared_preferences: af6bfa751691cdc24be3045c43ec037377ada40d 39 | sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 40 | 41 | PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c 42 | 43 | COCOAPODS: 1.10.1 44 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | Movies 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | $(FLUTTER_BUILD_NAME) 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(FLUTTER_BUILD_NUMBER) 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UIViewControllerBasedStatusBarAppearance 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /l10n.yaml: -------------------------------------------------------------------------------- 1 | arb-dir: lib/l10n 2 | template-arb-file: app_en.arb 3 | output-localization-file: app_localizations.dart 4 | -------------------------------------------------------------------------------- /lib/core/api/movies_api.dart: -------------------------------------------------------------------------------- 1 | class MoviesApi { 2 | static String _apikey = '4936b271d28ceb320ef9e012cf1363d7'; 3 | static String _url = 'api.themoviedb.org'; 4 | 5 | // languages 6 | static const String es = 'es-ES'; 7 | static const String en = 'en-US'; 8 | 9 | // Uris 10 | static Uri getGenres(String language) => Uri.https( 11 | _url, '3/genre/movie/list', {'api_key': _apikey, 'language': language}); 12 | 13 | static Uri getMovies(String endpoint, String language, int genreId) => 14 | Uri.https(_url, endpoint, _getParameters(language, genreId)); 15 | 16 | static Uri searchMovies(String language, String query) => Uri.https( 17 | _url, 18 | '3/search/movie', 19 | {'api_key': _apikey, 'language': language, 'query': query}); 20 | 21 | static Uri getMovieDetail(String language, int movieId) => Uri.https( 22 | _url, '3/movie/$movieId', {'api_key': _apikey, 'language': language}); 23 | 24 | static Uri getMovieCast(String language, int movieId) => Uri.https(_url, 25 | '3/movie/$movieId/credits', {'api_key': _apikey, 'language': language}); 26 | 27 | // Img / Posters 28 | static String getMoviePoster(String posterPath) { 29 | if (posterPath == null) 30 | return 'https://cdn11.bigcommerce.com/s-auu4kfi2d9/stencil/59512910-bb6d-0136-46ec-71c445b85d45/e/933395a0-cb1b-0135-a812-525400970412/icons/icon-no-image.svg'; 31 | else 32 | return 'https://image.tmdb.org/t/p/w500/$posterPath'; 33 | } 34 | 35 | static String getMovieBackgroundImg(String backdropPath) { 36 | if (backdropPath == null) 37 | return 'https://cdn11.bigcommerce.com/s-auu4kfi2d9/stencil/59512910-bb6d-0136-46ec-71c445b85d45/e/933395a0-cb1b-0135-a812-525400970412/icons/icon-no-image.svg'; 38 | else 39 | return 'https://image.tmdb.org/t/p/w500/$backdropPath'; 40 | } 41 | 42 | static Map _getParameters(String language, int genreId) { 43 | var parameters = {'api_key': _apikey, 'language': language}; 44 | if (genreId == null || genreId == -1) 45 | return parameters; 46 | else { 47 | parameters['with_genres'] = '$genreId'; 48 | return parameters; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/core/api/movies_endpoint.dart: -------------------------------------------------------------------------------- 1 | class MoviesEndpoint { 2 | static const String withGenre = '3/discover/movie'; 3 | } 4 | -------------------------------------------------------------------------------- /lib/core/errors/exception.dart: -------------------------------------------------------------------------------- 1 | class ServerException implements Exception {} 2 | 3 | class CacheException implements Exception {} 4 | -------------------------------------------------------------------------------- /lib/core/errors/failure.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | abstract class Failure extends Equatable { 4 | @override 5 | List get props => []; 6 | } 7 | 8 | class ServerFailure extends Failure {} 9 | 10 | class CacheFailure extends Failure {} 11 | 12 | class InternetFailure extends Failure {} 13 | 14 | class FailureMessage { 15 | static String server = 'Error de servidor'; 16 | static String unexpected = 'Error inesperado D:'; 17 | static String internet = 'Parece que no tienes internet :('; 18 | } 19 | 20 | String mapFailureToMessage(Failure failure) { 21 | switch (failure.runtimeType) { 22 | case InternetFailure: 23 | return FailureMessage.internet; 24 | case ServerFailure: 25 | return FailureMessage.server; 26 | default: 27 | return FailureMessage.unexpected; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/core/network/network_info.dart: -------------------------------------------------------------------------------- 1 | import 'package:data_connection_checker/data_connection_checker.dart'; 2 | 3 | abstract class NetworkInfo { 4 | Future get isConnected; 5 | } 6 | 7 | class NetworkInfoImpl implements NetworkInfo { 8 | final DataConnectionChecker connectionChecker; 9 | NetworkInfoImpl(this.connectionChecker); 10 | 11 | @override 12 | Future get isConnected => connectionChecker.hasConnection; 13 | } 14 | -------------------------------------------------------------------------------- /lib/core/routes.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | 4 | import '../injection_container.dart'; 5 | import '../presentation/custom_drawer/business_logic/drawer_nav_cubit/drawer_nav_cubit.dart'; 6 | import '../presentation/movies/business_logic/appbar_search_mode_cubit.dart'; 7 | import '../presentation/movies/business_logic/movie_cast_cubit/movie_cast_cubit.dart'; 8 | import '../presentation/movies/business_logic/movie_details_cubit/movie_details_cubit.dart'; 9 | import '../presentation/movies/movie_profile_view/movie_profile_view.dart'; 10 | import '../presentation/index.dart'; 11 | 12 | class AppRouter { 13 | Route onGenerateRoute(RouteSettings settings) { 14 | switch (settings.name) { 15 | case '/': 16 | return MaterialPageRoute( 17 | builder: (context) => MultiBlocProvider( 18 | providers: [ 19 | BlocProvider(create: (context) => sl()), 20 | BlocProvider(create: (context) => sl()), 21 | ], 22 | child: Index(), 23 | ), 24 | ); 25 | case '/movie_profile': 26 | return MaterialPageRoute( 27 | settings: settings, 28 | builder: (context) => MultiBlocProvider( 29 | providers: [ 30 | BlocProvider(create: (context) => sl()), 31 | BlocProvider(create: (context) => sl()), 32 | ], 33 | child: MovieProfileView(), 34 | ), 35 | ); 36 | default: 37 | return null; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/core/usecases/usecase.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | 4 | import '../errors/failure.dart'; 5 | 6 | abstract class UseCase { 7 | Future> call(Params params); 8 | } 9 | 10 | class NoParams extends Equatable { 11 | @override 12 | List get props => []; 13 | } 14 | -------------------------------------------------------------------------------- /lib/data/datasources/genres/genres_local_data_source.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:shared_preferences/shared_preferences.dart'; 5 | 6 | import '../../../core/errors/exception.dart'; 7 | import '../../../domain/entities/genre.dart'; 8 | 9 | abstract class GenresLocalDataSource { 10 | Future> getLastGenres(); 11 | Future cacheGenres(List moviesToCache); 12 | } 13 | 14 | const CACHED_GENRES = 'CACHED_GENRES'; 15 | 16 | class GenresLocalDataSourceImpl implements GenresLocalDataSource { 17 | final SharedPreferences sharedPreferences; 18 | 19 | GenresLocalDataSourceImpl({@required this.sharedPreferences}); 20 | 21 | @override 22 | Future cacheGenres(List genresToCache) { 23 | return sharedPreferences.setString( 24 | CACHED_GENRES, 25 | json.encode(genreModelListToJsonList(genresToCache)), 26 | ); 27 | } 28 | 29 | @override 30 | Future> getLastGenres() async { 31 | final jsonString = sharedPreferences.getString(CACHED_GENRES); 32 | if (jsonString != null) { 33 | return Future.value(genreModelListFromJsonList(json.decode(jsonString))); 34 | } else { 35 | throw CacheException(); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/data/datasources/genres/genres_remote_data_source.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:http/http.dart' as http; 5 | 6 | import '../../../core/api/movies_api.dart'; 7 | import '../../../core/errors/exception.dart'; 8 | import '../../../domain/entities/genre.dart'; 9 | 10 | abstract class GenresRemoteDataSource { 11 | Future> getGenres(String language); 12 | } 13 | 14 | class GenresRemoteDataSourceImpl implements GenresRemoteDataSource { 15 | final http.Client client; 16 | 17 | GenresRemoteDataSourceImpl({@required this.client}); 18 | 19 | @override 20 | Future> getGenres(String language) async { 21 | final response = await client.get(MoviesApi.getGenres(language)); 22 | if (response.statusCode == 200) 23 | return genreModelListFromJsonList(json.decode(response.body)['genres']); 24 | else 25 | throw ServerException(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/data/datasources/movies/movies_local_data_source.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:shared_preferences/shared_preferences.dart'; 5 | 6 | import '../../../core/errors/exception.dart'; 7 | import '../../../domain/entities/movie.dart'; 8 | 9 | abstract class MoviesLocalDataSource { 10 | Future> getLastMovies(String endpoint); 11 | Future cacheMovies(String endpoint, List moviesToCache); 12 | } 13 | 14 | class MoviesLocalDataSourceImpl implements MoviesLocalDataSource { 15 | final SharedPreferences sharedPreferences; 16 | 17 | MoviesLocalDataSourceImpl({@required this.sharedPreferences}); 18 | 19 | @override 20 | Future cacheMovies(String endpoint, List moviesToCache) { 21 | return sharedPreferences.setString( 22 | endpoint, 23 | json.encode(movieModelListToJsonList(moviesToCache)), 24 | ); 25 | } 26 | 27 | @override 28 | Future> getLastMovies(String endpoint) { 29 | final jsonString = sharedPreferences.getString(endpoint); 30 | if (jsonString != null) { 31 | return Future.value(movieModelListFromJsonList(json.decode(jsonString))); 32 | } else { 33 | throw CacheException(); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/data/datasources/movies/movies_remote_data_source.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:http/http.dart' as http; 5 | 6 | import '../../../core/api/movies_api.dart'; 7 | import '../../../core/errors/exception.dart'; 8 | import '../../../domain/entities/actor.dart'; 9 | import '../../../domain/entities/movie.dart'; 10 | 11 | abstract class MoviesRemoteDataSource { 12 | Future> getMovies( 13 | String endpoint, 14 | String language, 15 | int genreId, 16 | ); 17 | 18 | Future> searchMovies( 19 | String language, 20 | String query, 21 | ); 22 | 23 | Future getMovieDetail(String language, int movieId); 24 | 25 | Future> getMovieCast(String language, int movieId); 26 | } 27 | 28 | class MoviesRemoteDataSourceImpl implements MoviesRemoteDataSource { 29 | final http.Client client; 30 | 31 | MoviesRemoteDataSourceImpl({@required this.client}); 32 | 33 | @override 34 | Future> getMovies( 35 | String endpoint, 36 | String language, 37 | int genreId, 38 | ) async { 39 | final response = await client.get( 40 | MoviesApi.getMovies(endpoint, language, genreId), 41 | ); 42 | if (response.statusCode == 200) 43 | return movieModelListFromJsonList(json.decode(response.body)['results']); 44 | else { 45 | print(response.statusCode); 46 | print(response.body); 47 | throw ServerException(); 48 | } 49 | } 50 | 51 | @override 52 | Future> searchMovies(String language, String query) async { 53 | final response = await client.get(MoviesApi.searchMovies(language, query)); 54 | if (response.statusCode == 200) { 55 | return movieModelListFromJsonList(json.decode(response.body)['results']); 56 | } else 57 | throw ServerException(); 58 | } 59 | 60 | @override 61 | Future getMovieDetail(String language, int movieId) async { 62 | final response = await client.get( 63 | MoviesApi.getMovieDetail(language, movieId), 64 | ); 65 | if (response.statusCode == 200) { 66 | return Movie.fromJson(json.decode(response.body)); 67 | } else 68 | throw ServerException(); 69 | } 70 | 71 | @override 72 | Future> getMovieCast(String language, int movieId) async { 73 | final response = await client.get( 74 | MoviesApi.getMovieCast(language, movieId), 75 | ); 76 | if (response.statusCode == 200) { 77 | return actorModelListFromJsonList(json.decode(response.body)['cast']); 78 | } else 79 | throw ServerException(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/data/repositories/genres_repository_impl.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | import '../../core/errors/exception.dart'; 5 | import '../../core/errors/failure.dart'; 6 | import '../../core/network/network_info.dart'; 7 | import '../../domain/entities/genre.dart'; 8 | import '../../domain/repositories/genres_repository.dart'; 9 | import '../datasources/genres/genres_local_data_source.dart'; 10 | import '../datasources/genres/genres_remote_data_source.dart'; 11 | 12 | class GenresRepositoryImpl extends GenresRepository { 13 | final GenresRemoteDataSource remoteDataSource; 14 | final GenresLocalDataSource localDataSource; 15 | final NetworkInfo networkInfo; 16 | 17 | List genres = []; 18 | 19 | GenresRepositoryImpl({ 20 | @required this.remoteDataSource, 21 | @required this.localDataSource, 22 | @required this.networkInfo, 23 | }); 24 | 25 | @override 26 | Future>> getGenres(String language) async { 27 | if (genres != null && genres.isNotEmpty) { 28 | return Right(genres); 29 | } else if (await networkInfo.isConnected) { 30 | try { 31 | final remoteGenres = await remoteDataSource.getGenres(language); 32 | localDataSource.cacheGenres(remoteGenres); 33 | genres = remoteGenres; 34 | return Right(remoteGenres); 35 | } on ServerException { 36 | return Left(ServerFailure()); 37 | } 38 | } else { 39 | try { 40 | final localGenres = await localDataSource.getLastGenres(); 41 | genres = localGenres; 42 | return Right(localGenres); 43 | } on CacheException { 44 | return Left(CacheFailure()); 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/data/repositories/movies_repository_impl.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | import '../../core/api/movies_endpoint.dart'; 5 | import '../../core/errors/exception.dart'; 6 | import '../../core/errors/failure.dart'; 7 | import '../../core/network/network_info.dart'; 8 | import '../../domain/entities/actor.dart'; 9 | import '../../domain/entities/movie.dart'; 10 | import '../../domain/repositories/movies_repository.dart'; 11 | import '../datasources/movies/movies_local_data_source.dart'; 12 | import '../datasources/movies/movies_remote_data_source.dart'; 13 | 14 | class MoviesRepositoryImpl implements MoviesRepository { 15 | final MoviesRemoteDataSource remoteDataSource; 16 | final MoviesLocalDataSource localDataSource; 17 | final NetworkInfo networkInfo; 18 | 19 | MoviesRepositoryImpl({ 20 | @required this.remoteDataSource, 21 | @required this.localDataSource, 22 | @required this.networkInfo, 23 | }); 24 | 25 | Map> moviesByEnpoint = {}; 26 | Map> moviesByCategory = {}; 27 | 28 | @override 29 | Future>> getMovies( 30 | String endpoint, 31 | String language, 32 | int genreId, 33 | ) async { 34 | if (_isInMoviesByCategory(endpoint, genreId)) 35 | return Right(moviesByCategory[genreId]); 36 | else if (_isInMoviesByEndpoint(endpoint)) 37 | return Right(moviesByEnpoint[endpoint]); 38 | else if (await networkInfo.isConnected) { 39 | try { 40 | final remoteMovies = await remoteDataSource.getMovies( 41 | endpoint, 42 | language, 43 | genreId, 44 | ); 45 | localDataSource.cacheMovies(endpoint + '$genreId', remoteMovies); 46 | _saveData(endpoint, genreId, remoteMovies); 47 | return Right(remoteMovies); 48 | } on ServerException { 49 | return Left(ServerFailure()); 50 | } 51 | } else { 52 | try { 53 | final localMovies = await localDataSource.getLastMovies( 54 | endpoint + '$genreId', 55 | ); 56 | _saveData(endpoint, genreId, localMovies); 57 | return Right(localMovies); 58 | } on CacheException { 59 | return Left(CacheFailure()); 60 | } 61 | } 62 | } 63 | 64 | @override 65 | Future>> searchMovies( 66 | String language, 67 | String query, 68 | ) async { 69 | if (await networkInfo.isConnected) 70 | try { 71 | final remoteMovies = await remoteDataSource.searchMovies( 72 | language, 73 | query, 74 | ); 75 | return Right(remoteMovies); 76 | } on ServerException { 77 | return Left(ServerFailure()); 78 | } 79 | else 80 | return Left(InternetFailure()); 81 | } 82 | 83 | @override 84 | Future> getMovieDetail( 85 | String language, 86 | int movieId, 87 | ) async { 88 | if (await networkInfo.isConnected) 89 | try { 90 | final remoteMovieDetail = await remoteDataSource.getMovieDetail( 91 | language, 92 | movieId, 93 | ); 94 | return Right(remoteMovieDetail); 95 | } on ServerException { 96 | return Left(ServerFailure()); 97 | } 98 | else 99 | return Left(InternetFailure()); 100 | } 101 | 102 | @override 103 | Future>> getMovieCast( 104 | String language, 105 | int movieId, 106 | ) async { 107 | if (await networkInfo.isConnected) 108 | try { 109 | final remoteMovieDetail = await remoteDataSource.getMovieCast( 110 | language, 111 | movieId, 112 | ); 113 | return Right(remoteMovieDetail); 114 | } on ServerException { 115 | return Left(ServerFailure()); 116 | } 117 | else 118 | return Left(InternetFailure()); 119 | } 120 | 121 | void _saveData(String endpoint, int genreId, List movies) { 122 | if (endpoint == MoviesEndpoint.withGenre) { 123 | moviesByCategory[genreId] = movies; 124 | } else { 125 | moviesByEnpoint[endpoint] = movies; 126 | } 127 | } 128 | 129 | bool _isInMoviesByCategory(String endpoint, int genreId) => 130 | endpoint == MoviesEndpoint.withGenre && 131 | moviesByCategory[genreId] != null && 132 | moviesByCategory[genreId].isNotEmpty; 133 | 134 | bool _isInMoviesByEndpoint(String endpoint) => 135 | moviesByEnpoint[endpoint] != null && moviesByEnpoint[endpoint].isNotEmpty; 136 | } 137 | -------------------------------------------------------------------------------- /lib/domain/entities/actor.dart: -------------------------------------------------------------------------------- 1 | List actorModelListFromJsonList(List jsonList) { 2 | if (jsonList == null) return []; 3 | 4 | List cast = []; 5 | jsonList.forEach((item) { 6 | final actor = Actor.fromJson(item); 7 | cast.add(actor); 8 | }); 9 | return cast; 10 | } 11 | 12 | class Actor { 13 | Actor({ 14 | this.adult, 15 | this.gender, 16 | this.id, 17 | this.knownForDepartment, 18 | this.name, 19 | this.originalName, 20 | this.popularity, 21 | this.profilePath, 22 | this.castId, 23 | this.character, 24 | this.creditId, 25 | this.order, 26 | }); 27 | 28 | bool adult; 29 | int gender; 30 | int id; 31 | String knownForDepartment; 32 | String name; 33 | String originalName; 34 | double popularity; 35 | String profilePath; 36 | int castId; 37 | String character; 38 | String creditId; 39 | int order; 40 | 41 | factory Actor.fromJson(Map json) => Actor( 42 | adult: json["adult"], 43 | gender: json["gender"], 44 | id: json["id"], 45 | knownForDepartment: json["known_for_department"], 46 | name: json["name"], 47 | originalName: json["original_name"], 48 | popularity: json["popularity"].toDouble(), 49 | profilePath: json["profile_path"], 50 | castId: json["cast_id"], 51 | character: json["character"], 52 | creditId: json["credit_id"], 53 | order: json["order"], 54 | ); 55 | 56 | Map toJson() => { 57 | "adult": adult, 58 | "gender": gender, 59 | "id": id, 60 | "known_for_department": knownForDepartment, 61 | "name": name, 62 | "original_name": originalName, 63 | "popularity": popularity, 64 | "profile_path": profilePath, 65 | "cast_id": castId, 66 | "character": character, 67 | "credit_id": creditId, 68 | "order": order, 69 | }; 70 | 71 | getFoto() { 72 | if (profilePath == null) { 73 | return 'http://forum.spaceengine.org/styles/se/theme/images/no_avatar.jpg'; 74 | } else { 75 | return 'https://image.tmdb.org/t/p/w500/$profilePath'; 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /lib/domain/entities/genre.dart: -------------------------------------------------------------------------------- 1 | List genreModelListFromJsonList(List jsonList) { 2 | if (jsonList == null) return []; 3 | 4 | List genres = []; 5 | jsonList.forEach((item) { 6 | final genre = Genre.fromJson(item); 7 | genres.add(genre); 8 | }); 9 | return genres; 10 | } 11 | 12 | List> genreModelListToJsonList(List genres) { 13 | List> genresJson = []; 14 | genres.forEach((genre) { 15 | genresJson.add(genre.toJson()); 16 | }); 17 | return genresJson; 18 | } 19 | 20 | List> genreListToJsonList(List genres) { 21 | List> genresJson = []; 22 | genres.forEach((genre) { 23 | genresJson.add(genreToJson(genre)); 24 | }); 25 | return genresJson; 26 | } 27 | 28 | Map genreToJson(Genre genre) => 29 | {"id": genre.id, "name": genre.name}; 30 | 31 | class Genre { 32 | Genre({ 33 | this.id, 34 | this.name, 35 | }); 36 | 37 | int id; 38 | String name; 39 | 40 | factory Genre.fromJson(Map json) => Genre( 41 | id: json["id"], 42 | name: json["name"], 43 | ); 44 | 45 | Map toJson() => { 46 | "id": id, 47 | "name": name, 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /lib/domain/entities/movie.dart: -------------------------------------------------------------------------------- 1 | import 'production_company.dart'; 2 | import 'production_country.dart'; 3 | 4 | List movieModelListFromJsonList(List jsonList) { 5 | if (jsonList == null) return []; 6 | 7 | List movies = []; 8 | jsonList.forEach((item) { 9 | final movie = Movie.fromJson(item); 10 | movies.add(movie); 11 | }); 12 | return movies; 13 | } 14 | 15 | List> movieModelListToJsonList(List movies) { 16 | List> moviesJson = []; 17 | movies.forEach((movie) { 18 | moviesJson.add(movie.toJson()); 19 | }); 20 | return moviesJson; 21 | } 22 | 23 | class Movie { 24 | int id; 25 | String title; 26 | String homepage; 27 | bool adult; 28 | String backdropPath; 29 | List genreIds; 30 | String originalLanguage; 31 | String originalTitle; 32 | String overview; 33 | double popularity; 34 | String posterPath; 35 | DateTime releaseDate; 36 | bool video; 37 | double voteAverage; 38 | int voteCount; 39 | int budget; 40 | int revenue; 41 | List productionCompanies; 42 | List productionCountries; 43 | 44 | Movie({ 45 | this.id, 46 | this.title, 47 | this.homepage, 48 | this.adult, 49 | this.backdropPath, 50 | this.genreIds, 51 | this.originalLanguage, 52 | this.originalTitle, 53 | this.overview, 54 | this.popularity, 55 | this.posterPath, 56 | this.releaseDate, 57 | this.video, 58 | this.voteAverage, 59 | this.voteCount, 60 | this.budget, 61 | this.revenue, 62 | this.productionCompanies, 63 | this.productionCountries, 64 | }); 65 | 66 | factory Movie.fromJson(Map json) => Movie( 67 | adult: json["adult"], 68 | backdropPath: json["backdrop_path"], 69 | genreIds: json["genre_ids"] != null 70 | ? List.from(json["genre_ids"].map((x) => x)) 71 | : [], 72 | id: json["id"], 73 | originalLanguage: json["original_language"], 74 | originalTitle: json["original_title"], 75 | overview: json["overview"], 76 | popularity: json["popularity"].toDouble(), 77 | posterPath: json["poster_path"], 78 | releaseDate: json["release_date"] != null && json["release_date"] != "" 79 | ? DateTime.parse(json["release_date"]) 80 | : null, 81 | title: json["title"], 82 | video: json["video"], 83 | voteAverage: json["vote_average"].toDouble(), 84 | voteCount: json["vote_count"], 85 | budget: json["budget"], 86 | revenue: json["revenue"], 87 | homepage: json["homepage"], 88 | productionCompanies: json["production_companies"] != null 89 | ? List.from(json["production_companies"] 90 | .map((x) => ProductionCompany.fromJson(x))) 91 | : [], 92 | productionCountries: json["production_countries"] != null 93 | ? List.from(json["production_countries"] 94 | .map((x) => ProductionCountry.fromJson(x))) 95 | : [], 96 | ); 97 | 98 | Map toJson() => { 99 | "adult": adult, 100 | "backdrop_path": backdropPath, 101 | "genre_ids": List.from(genreIds.map((x) => x)), 102 | "id": id, 103 | "original_language": originalLanguage, 104 | "original_title": originalTitle, 105 | "overview": overview, 106 | "popularity": popularity, 107 | "poster_path": posterPath, 108 | "release_date": releaseDate == null 109 | ? null 110 | : "${releaseDate.year.toString().padLeft(4, '0')}-${releaseDate.month.toString().padLeft(2, '0')}-${releaseDate.day.toString().padLeft(2, '0')}", 111 | "title": title, 112 | "video": video, 113 | "vote_average": voteAverage, 114 | "vote_count": voteCount, 115 | "budget": budget, 116 | "homepage": homepage, 117 | "production_companies": 118 | List.from(productionCompanies.map((x) => x.toJson())), 119 | "production_countries": 120 | List.from(productionCountries.map((x) => x.toJson())), 121 | }; 122 | } 123 | -------------------------------------------------------------------------------- /lib/domain/entities/production_company.dart: -------------------------------------------------------------------------------- 1 | class ProductionCompany { 2 | ProductionCompany({ 3 | this.id, 4 | this.logoPath, 5 | this.name, 6 | this.originCountry, 7 | }); 8 | 9 | int id; 10 | String logoPath; 11 | String name; 12 | String originCountry; 13 | 14 | factory ProductionCompany.fromJson(Map json) => 15 | ProductionCompany( 16 | id: json["id"], 17 | logoPath: json["logo_path"], 18 | name: json["name"], 19 | originCountry: json["origin_country"], 20 | ); 21 | 22 | Map toJson() => { 23 | "id": id, 24 | "logo_path": logoPath, 25 | "name": name, 26 | "origin_country": originCountry, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /lib/domain/entities/production_country.dart: -------------------------------------------------------------------------------- 1 | class ProductionCountry { 2 | ProductionCountry({ 3 | this.iso31661, 4 | this.name, 5 | }); 6 | 7 | String iso31661; 8 | String name; 9 | 10 | factory ProductionCountry.fromJson(Map json) => 11 | ProductionCountry( 12 | iso31661: json["iso_3166_1"], 13 | name: json["name"], 14 | ); 15 | 16 | Map toJson() => { 17 | "iso_3166_1": iso31661, 18 | "name": name, 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /lib/domain/repositories/genres_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | 3 | import '../../core/errors/failure.dart'; 4 | import '../entities/genre.dart'; 5 | 6 | abstract class GenresRepository { 7 | Future>> getGenres(String language); 8 | } 9 | -------------------------------------------------------------------------------- /lib/domain/repositories/movies_repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | 3 | import '../../core/errors/failure.dart'; 4 | import '../entities/actor.dart'; 5 | import '../entities/movie.dart'; 6 | 7 | abstract class MoviesRepository { 8 | Future>> getMovies( 9 | String endpoint, 10 | String language, 11 | int genreId, 12 | ); 13 | 14 | Future>> searchMovies( 15 | String language, 16 | String query, 17 | ); 18 | 19 | Future> getMovieDetail( 20 | String language, 21 | int movieId, 22 | ); 23 | 24 | Future>> getMovieCast( 25 | String language, 26 | int movieId, 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /lib/domain/usecases/get_genres.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | 4 | import '../../core/errors/failure.dart'; 5 | import '../../core/usecases/usecase.dart'; 6 | import '../entities/genre.dart'; 7 | import '../repositories/genres_repository.dart'; 8 | 9 | class GetGenres extends UseCase, GenresParams> { 10 | final GenresRepository repository; 11 | 12 | GetGenres(this.repository); 13 | 14 | @override 15 | Future>> call(params) async { 16 | return await repository.getGenres(params.language); 17 | } 18 | } 19 | 20 | class GenresParams extends Equatable { 21 | final String language; 22 | 23 | GenresParams({this.language}); 24 | 25 | @override 26 | List get props => [language]; 27 | } 28 | -------------------------------------------------------------------------------- /lib/domain/usecases/get_movie_cast.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | 4 | import '../../core/errors/failure.dart'; 5 | import '../../core/usecases/usecase.dart'; 6 | import '../entities/actor.dart'; 7 | import '../repositories/movies_repository.dart'; 8 | 9 | class GetMovieCast extends UseCase, CastParams> { 10 | final MoviesRepository repository; 11 | 12 | GetMovieCast(this.repository); 13 | 14 | @override 15 | Future>> call(params) async { 16 | return await repository.getMovieCast(params.language, params.movieId); 17 | } 18 | } 19 | 20 | class CastParams extends Equatable { 21 | final String language; 22 | final int movieId; 23 | 24 | CastParams({this.language, this.movieId}); 25 | 26 | @override 27 | List get props => [language, movieId]; 28 | } 29 | -------------------------------------------------------------------------------- /lib/domain/usecases/get_movie_detail.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:my_movie_list/core/errors/failure.dart'; 3 | import 'package:dartz/dartz.dart'; 4 | import 'package:my_movie_list/core/usecases/usecase.dart'; 5 | import 'package:my_movie_list/domain/entities/movie.dart'; 6 | import 'package:my_movie_list/domain/repositories/movies_repository.dart'; 7 | 8 | class GetMovieDetail extends UseCase { 9 | final MoviesRepository repository; 10 | 11 | GetMovieDetail(this.repository); 12 | 13 | @override 14 | Future> call(params) async { 15 | return await repository.getMovieDetail(params.language, params.movieId); 16 | } 17 | } 18 | 19 | class MovieDetailParams extends Equatable { 20 | final String language; 21 | final int movieId; 22 | 23 | MovieDetailParams({this.language, this.movieId}); 24 | 25 | @override 26 | List get props => [language, movieId]; 27 | } 28 | -------------------------------------------------------------------------------- /lib/domain/usecases/get_movies.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | 4 | import '../../core/errors/failure.dart'; 5 | import '../../core/api/movies_api.dart'; 6 | import '../../core/api/movies_endpoint.dart'; 7 | import '../../core/usecases/usecase.dart'; 8 | import '../entities/movie.dart'; 9 | import '../repositories/movies_repository.dart'; 10 | 11 | class GetMovies extends UseCase, Params> { 12 | final MoviesRepository repository; 13 | 14 | GetMovies(this.repository); 15 | 16 | @override 17 | Future>> call(Params params) async { 18 | return await repository.getMovies( 19 | params.endpoint, 20 | params.language, 21 | params.genreId, 22 | ); 23 | } 24 | } 25 | 26 | class Params extends Equatable { 27 | final String language; 28 | final String endpoint; 29 | final int genreId; 30 | 31 | Params({ 32 | this.endpoint = MoviesEndpoint.withGenre, 33 | this.language = MoviesApi.es, 34 | this.genreId, 35 | }); 36 | 37 | @override 38 | List get props => [this.endpoint, this.language, this.genreId]; 39 | } 40 | -------------------------------------------------------------------------------- /lib/domain/usecases/search_movies.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | 4 | import '../../core/errors/failure.dart'; 5 | import '../../core/api/movies_api.dart'; 6 | import '../../core/usecases/usecase.dart'; 7 | import '../entities/movie.dart'; 8 | import '../repositories/movies_repository.dart'; 9 | 10 | class SearchMovies extends UseCase, SearchMoviesParams> { 11 | final MoviesRepository repository; 12 | 13 | SearchMovies(this.repository); 14 | 15 | @override 16 | Future>> call(SearchMoviesParams params) async { 17 | var res = await repository.searchMovies(params.language, params.query); 18 | return res; 19 | } 20 | } 21 | 22 | class SearchMoviesParams extends Equatable { 23 | final String language; 24 | final String query; 25 | 26 | SearchMoviesParams({this.language = MoviesApi.es, this.query}); 27 | 28 | @override 29 | List get props => [this.language, this.query]; 30 | } 31 | -------------------------------------------------------------------------------- /lib/injection_container.dart: -------------------------------------------------------------------------------- 1 | import 'package:get_it/get_it.dart'; 2 | import 'package:http/http.dart' as http; 3 | import 'package:shared_preferences/shared_preferences.dart'; 4 | import 'package:data_connection_checker/data_connection_checker.dart'; 5 | 6 | import 'core/network/network_info.dart'; 7 | import 'data/datasources/genres/genres_local_data_source.dart'; 8 | import 'data/datasources/genres/genres_remote_data_source.dart'; 9 | import 'data/datasources/movies/movies_local_data_source.dart'; 10 | import 'data/datasources/movies/movies_remote_data_source.dart'; 11 | import 'data/repositories/genres_repository_impl.dart'; 12 | import 'data/repositories/movies_repository_impl.dart'; 13 | import 'domain/repositories/genres_repository.dart'; 14 | import 'domain/repositories/movies_repository.dart'; 15 | import 'domain/usecases/get_genres.dart'; 16 | import 'domain/usecases/get_movie_cast.dart'; 17 | import 'domain/usecases/get_movie_detail.dart'; 18 | import 'domain/usecases/get_movies.dart'; 19 | import 'domain/usecases/search_movies.dart'; 20 | import 'presentation/custom_drawer/business_logic/drawer_nav_cubit/drawer_nav_cubit.dart'; 21 | import 'presentation/genres/business_logic/genres_cubit.dart'; 22 | import 'presentation/movies/business_logic/appbar_search_mode_cubit.dart'; 23 | import 'presentation/movies/business_logic/movie_cast_cubit/movie_cast_cubit.dart'; 24 | import 'presentation/movies/business_logic/movies_bloc/movies_bloc.dart'; 25 | import 'presentation/movies/business_logic/movies_search_cubit/movies_search_cubit.dart'; 26 | import 'presentation/movies/business_logic/movie_details_cubit/movie_details_cubit.dart'; 27 | 28 | final sl = GetIt.instance; 29 | 30 | Future init() async { 31 | //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 32 | //! MOVIES 33 | //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 34 | // Business Logic 35 | sl.registerFactory(() => DrawerNavCubit()); 36 | sl.registerFactory(() => MoviesBloc(getMovies: sl())); 37 | sl.registerFactory(() => AppbarSearhModeCubit()); 38 | sl.registerFactory(() => MoviesSearchCubit(searchMovies: sl())); 39 | sl.registerFactory(() => MovieDetailsCubit(getMovieDetail: sl())); 40 | sl.registerFactory(() => MovieCastCubit(getMovieCast: sl())); 41 | 42 | // UseCases 43 | sl.registerLazySingleton(() => GetMovies(sl())); 44 | sl.registerLazySingleton(() => SearchMovies(sl())); 45 | sl.registerLazySingleton(() => GetMovieDetail(sl())); 46 | sl.registerLazySingleton(() => GetMovieCast(sl())); 47 | 48 | // Repository 49 | sl.registerLazySingleton( 50 | () => MoviesRepositoryImpl( 51 | networkInfo: sl(), 52 | localDataSource: sl(), 53 | remoteDataSource: sl(), 54 | ), 55 | ); 56 | 57 | // DataSources 58 | sl.registerLazySingleton( 59 | () => MoviesRemoteDataSourceImpl(client: sl()), 60 | ); 61 | sl.registerLazySingleton( 62 | () => MoviesLocalDataSourceImpl(sharedPreferences: sl()), 63 | ); 64 | 65 | //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 66 | //! Genres 67 | //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 68 | // Business Logic 69 | sl.registerFactory(() => GenresCubit(getGenres: sl())); 70 | 71 | // UseCases 72 | sl.registerLazySingleton(() => GetGenres(sl())); 73 | 74 | // Repository 75 | sl.registerLazySingleton( 76 | () => GenresRepositoryImpl( 77 | remoteDataSource: sl(), 78 | localDataSource: sl(), 79 | networkInfo: sl(), 80 | ), 81 | ); 82 | 83 | // DataSources 84 | sl.registerLazySingleton( 85 | () => GenresLocalDataSourceImpl(sharedPreferences: sl()), 86 | ); 87 | 88 | sl.registerLazySingleton( 89 | () => GenresRemoteDataSourceImpl(client: sl()), 90 | ); 91 | 92 | //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 93 | //! CORE 94 | //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 95 | sl.registerLazySingleton(() => NetworkInfoImpl(sl())); 96 | 97 | //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 98 | //! EXTERNAL 99 | //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 100 | // shared preferences 101 | final sharedPreferences = await SharedPreferences.getInstance(); 102 | sl.registerLazySingleton(() => sharedPreferences); 103 | 104 | // http 105 | sl.registerLazySingleton(() => http.Client()); 106 | 107 | // connection checker 108 | sl.registerLazySingleton(() => DataConnectionChecker()); 109 | } 110 | -------------------------------------------------------------------------------- /lib/l10n/I10n.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../core/api/movies_api.dart'; 4 | 5 | class L10n { 6 | static const en = 'en'; 7 | static const es = 'es'; 8 | 9 | static final all = [ 10 | const Locale(en), 11 | const Locale(es), 12 | ]; 13 | 14 | static final getLang = { 15 | en: MoviesApi.en, 16 | es: MoviesApi.es, 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /lib/l10n/app_en.arb: -------------------------------------------------------------------------------- 1 | { 2 | "loading_genres": "Loading Movie Genres...", 3 | "loading_movies": "Loading Movies...", 4 | "drawer_categories": "Categories", 5 | "no_info" : "No Information", 6 | "search_movies_searching" : "Looking for movies...", 7 | "search_movies_not_found" : "No matching movies found :(", 8 | "search_movies_error" : "It seems that there has been an error, try again!", 9 | "movie_profile_original_title": "Original Title", 10 | "movie_profile_assessment": "Score", 11 | "movie_profile_genres" : "Genres", 12 | "movie_profile_overview": "Overview", 13 | "movie_profile_release_date": "Release Date", 14 | "movie_profile_budget" : "Budget", 15 | "movie_profile_revenue" : "Revenue", 16 | "movie_profile_production_compenies" : "Production Companies", 17 | "movie_profile_production_countries" : "Production Countries", 18 | "movie_profile_cast": "Cast" 19 | 20 | } -------------------------------------------------------------------------------- /lib/l10n/app_es.arb: -------------------------------------------------------------------------------- 1 | { 2 | "loading_genres": "Cargando Géneros De Películas...", 3 | "loading_movies": "Cargando Películas...", 4 | "drawer_categories": "Categorías", 5 | "no_info" : "Sin Información", 6 | "search_movies_searching" : "Buscando Películas...", 7 | "search_movies_not_found" : "No se han encontrado películas que coincidan :(", 8 | "search_movies_error" : "Parece que ha habido un error, intente otra vez!", 9 | "movie_profile_original_title": "Original Title", 10 | "movie_profile_assessment": "Valoración", 11 | "movie_profile_genres" : "Géneros", 12 | "movie_profile_overview": "Sinopsis", 13 | "movie_profile_release_date": "Fecha De Lanzamiento", 14 | "movie_profile_budget" : "Presupuesto", 15 | "movie_profile_revenue" : "Ingresos", 16 | "movie_profile_production_compenies" : "Compañias De Producción", 17 | "movie_profile_production_countries" : "Países De Producción", 18 | "movie_profile_cast": "Reparto" 19 | 20 | } 21 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 4 | import 'package:flutter_localizations/flutter_localizations.dart'; 5 | 6 | import 'core/routes.dart'; 7 | import 'injection_container.dart' as di; 8 | import 'l10n/I10n.dart'; 9 | import 'presentation/genres/business_logic/genres_cubit.dart'; 10 | import 'presentation/movies/business_logic/movies_bloc/movies_bloc.dart'; 11 | import 'presentation/movies/business_logic/movies_search_cubit/movies_search_cubit.dart'; 12 | 13 | void main() async { 14 | WidgetsFlutterBinding.ensureInitialized(); 15 | await di.init(); 16 | runApp(MyApp()); 17 | } 18 | 19 | class MyApp extends StatelessWidget { 20 | final _router = AppRouter(); 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | return MultiBlocProvider( 25 | providers: [ 26 | BlocProvider(create: (context) => di.sl()), 27 | BlocProvider(create: (context) => di.sl()), 28 | BlocProvider(create: (context) => di.sl()), 29 | ], 30 | child: MaterialApp( 31 | title: 'Movies', 32 | debugShowCheckedModeBanner: false, 33 | localizationsDelegates: [ 34 | AppLocalizations.delegate, 35 | GlobalMaterialLocalizations.delegate, 36 | GlobalWidgetsLocalizations.delegate, 37 | GlobalCupertinoLocalizations.delegate, 38 | ], 39 | supportedLocales: L10n.all, 40 | theme: ThemeData( 41 | primaryColor: Colors.black, 42 | accentColor: Colors.black, 43 | ), 44 | onGenerateRoute: _router.onGenerateRoute, 45 | initialRoute: '/', 46 | ), 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/presentation/custom_drawer/business_logic/drawer_nav_cubit/drawer_nav_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer' as logger; 2 | import 'package:equatable/equatable.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | import 'package:my_movie_list/domain/entities/genre.dart'; 5 | 6 | part 'drawer_nav_state.dart'; 7 | 8 | class DrawerNavCubit extends Cubit { 9 | DrawerNavCubit() : super(DrawerNavInitial()); 10 | 11 | @override 12 | void onChange(Change change) { 13 | logger.log('3.1.2: genre => $change'); 14 | super.onChange(change); 15 | } 16 | 17 | @override 18 | void onError(Object error, StackTrace stackTrace) { 19 | logger.log('3.1.3: $error, $stackTrace'); 20 | super.onError(error, stackTrace); 21 | } 22 | 23 | Future getWithGenre(Genre genre) async { 24 | logger.log('3.1.1: genre => ${genre.name}'); 25 | emit(DrawerNavGenre(genre)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/presentation/custom_drawer/business_logic/drawer_nav_cubit/drawer_nav_state.dart: -------------------------------------------------------------------------------- 1 | part of 'drawer_nav_cubit.dart'; 2 | 3 | abstract class DrawerNavState extends Equatable { 4 | @override 5 | List get props => []; 6 | } 7 | 8 | class DrawerNavInitial extends DrawerNavState {} 9 | 10 | class DrawerNavGenre extends DrawerNavState { 11 | final Genre genre; 12 | DrawerNavGenre(this.genre); 13 | 14 | @override 15 | List get props => [genre]; 16 | } 17 | -------------------------------------------------------------------------------- /lib/presentation/custom_drawer/custom_drawer.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import 'drawer_categories.dart'; 4 | 5 | class CustomDrawer extends StatelessWidget { 6 | @override 7 | Widget build(BuildContext context) { 8 | return SafeArea( 9 | child: Container( 10 | width: MediaQuery.of(context).size.width * 0.8, 11 | decoration: BoxDecoration( 12 | color: Colors.grey[300], 13 | borderRadius: BorderRadius.only( 14 | topRight: Radius.circular(20), 15 | bottomRight: Radius.circular(20), 16 | ), 17 | ), 18 | child: Container( 19 | padding: EdgeInsets.symmetric(horizontal: 20, vertical: 60), 20 | child: Column( 21 | crossAxisAlignment: CrossAxisAlignment.start, 22 | children: [ 23 | Row( 24 | children: [ 25 | _drawerTitle('Categorías'), 26 | Expanded(child: Container()), 27 | ], 28 | ), 29 | SizedBox(height: 30), 30 | Expanded(child: DrawerCategories()), 31 | ], 32 | ), 33 | ), 34 | ), 35 | ); 36 | } 37 | 38 | Widget _drawerTitle(String text) { 39 | return Text( 40 | text, 41 | style: TextStyle( 42 | fontSize: 20, 43 | fontWeight: FontWeight.bold, 44 | ), 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/presentation/custom_drawer/drawer_categories.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | 4 | import '../genres/business_logic/genres_cubit.dart'; 5 | import 'business_logic/drawer_nav_cubit/drawer_nav_cubit.dart'; 6 | import 'drawer_category_button.dart'; 7 | 8 | class DrawerCategories extends StatelessWidget { 9 | @override 10 | Widget build(BuildContext context) { 11 | return BlocBuilder( 12 | builder: (context, state) { 13 | if (state is GenresLoadSuccess) 14 | return ListView.builder( 15 | itemCount: state.genres.length, 16 | shrinkWrap: true, 17 | itemBuilder: (context, index) { 18 | return Container( 19 | margin: EdgeInsets.only(right: 30), 20 | child: DrawerCategoryButton( 21 | title: state.genres[index].name, 22 | function: () { 23 | Navigator.of(context).pop(); 24 | BlocProvider.of(context) 25 | .getWithGenre(state.genres[index]); 26 | }, 27 | ), 28 | ); 29 | }, 30 | ); 31 | else if (state is GenresLoadFailure) 32 | return Container( 33 | padding: EdgeInsets.only(top: 20, left: 20), 34 | child: Text(state.message), 35 | ); 36 | else 37 | return Container( 38 | padding: EdgeInsets.only(top: 20, left: 20), 39 | child: Column( 40 | children: [ 41 | Row( 42 | children: [ 43 | CircularProgressIndicator(), 44 | Expanded(child: Container()), 45 | ], 46 | ), 47 | Expanded(child: Container()), 48 | ], 49 | ), 50 | ); 51 | }, 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/presentation/custom_drawer/drawer_category_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class DrawerCategoryButton extends StatelessWidget { 4 | final String title; 5 | final void Function() function; 6 | const DrawerCategoryButton({ 7 | @required this.title, 8 | @required this.function, 9 | }); 10 | @override 11 | Widget build(BuildContext context) { 12 | return Material( 13 | color: Colors.transparent, 14 | child: InkWell( 15 | onTap: function, 16 | child: Container( 17 | padding: EdgeInsets.symmetric(vertical: 10, horizontal: 20), 18 | child: Row( 19 | children: [ 20 | SizedBox(width: 15), 21 | Text( 22 | title, 23 | style: TextStyle( 24 | fontWeight: FontWeight.w600, 25 | fontSize: 15, 26 | ), 27 | ), 28 | Expanded(child: Container()), 29 | Icon(Icons.arrow_right), 30 | ], 31 | ), 32 | ), 33 | ), 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/presentation/genres/business_logic/genres_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | 5 | import '../../../core/errors/failure.dart'; 6 | import '../../../core/api/movies_api.dart'; 7 | import '../../../domain/entities/genre.dart'; 8 | import '../../../domain/usecases/get_genres.dart'; 9 | 10 | part 'genres_state.dart'; 11 | 12 | class GenresCubit extends Cubit { 13 | final GetGenres getGenres; 14 | 15 | GenresCubit({ 16 | @required this.getGenres, 17 | }) : assert(getGenres != null), 18 | super(GenresInitial()); 19 | 20 | Future genresGet({ 21 | String language = MoviesApi.es, 22 | }) async { 23 | emit(GenresLoadInProgress()); 24 | final failureOrGenres = await getGenres(GenresParams(language: language)); 25 | emit( 26 | failureOrGenres.fold( 27 | (failure) => GenresLoadFailure(message: _mapFailureToMessage(failure)), 28 | (genres) => GenresLoadSuccess(genres: genres), 29 | ), 30 | ); 31 | } 32 | 33 | String _mapFailureToMessage(Failure failure) { 34 | switch (failure.runtimeType) { 35 | case ServerFailure: 36 | return FailureMessage.server; 37 | default: 38 | return FailureMessage.unexpected; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/presentation/genres/business_logic/genres_state.dart: -------------------------------------------------------------------------------- 1 | part of 'genres_cubit.dart'; 2 | 3 | abstract class GenresState extends Equatable { 4 | @override 5 | List get props => []; 6 | } 7 | 8 | class GenresInitial extends GenresState {} 9 | 10 | class GenresLoadInProgress extends GenresState {} 11 | 12 | class GenresLoadSuccess extends GenresState { 13 | final List genres; 14 | GenresLoadSuccess({@required this.genres}); 15 | 16 | @override 17 | List get props => [this.genres]; 18 | } 19 | 20 | class GenresLoadFailure extends GenresState { 21 | final String message; 22 | GenresLoadFailure({@required this.message}); 23 | 24 | @override 25 | List get props => [this.message]; 26 | } 27 | -------------------------------------------------------------------------------- /lib/presentation/genres/genres_view/genres_loading_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 3 | 4 | class GenresLoadingView extends StatelessWidget { 5 | @override 6 | Widget build(BuildContext context) { 7 | String loadingGenres = AppLocalizations.of(context).loading_genres; 8 | 9 | return Scaffold( 10 | backgroundColor: Colors.black, 11 | body: Center( 12 | child: Column( 13 | mainAxisAlignment: MainAxisAlignment.center, 14 | children: [ 15 | Expanded(flex: 30, child: Container()), 16 | Expanded( 17 | flex: 15, 18 | child: Container( 19 | child: Text( 20 | loadingGenres, 21 | maxLines: 2, 22 | textAlign: TextAlign.center, 23 | softWrap: true, 24 | style: TextStyle( 25 | color: Colors.white, 26 | fontSize: 20, 27 | fontWeight: FontWeight.bold, 28 | ), 29 | ), 30 | ), 31 | ), 32 | Expanded(flex: 1, child: Container()), 33 | Container( 34 | width: 60, 35 | height: 60, 36 | child: CircularProgressIndicator( 37 | strokeWidth: 10, 38 | valueColor: new AlwaysStoppedAnimation(Colors.orange), 39 | ), 40 | ), 41 | Expanded(flex: 15, child: Container()), 42 | ], 43 | ), 44 | ), 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/presentation/global_widgets/dialogs/on_will_pop_dialog.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class OnWillPopDialog extends StatelessWidget { 4 | @override 5 | Widget build(BuildContext context) { 6 | return AlertDialog( 7 | title: Text( 8 | '¿Estás seguro que deseas salir?', 9 | textAlign: TextAlign.center, 10 | ), 11 | actionsOverflowButtonSpacing: 10, 12 | content: Row( 13 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 14 | children: [ 15 | ElevatedButton( 16 | style: ButtonStyle( 17 | backgroundColor: MaterialStateProperty.all( 18 | Colors.grey[900], 19 | ), 20 | ), 21 | child: Container( 22 | padding: EdgeInsets.symmetric(horizontal: 15), 23 | child: Text('Salir'), 24 | ), 25 | onPressed: () => Navigator.of(context).pop(true)), 26 | ElevatedButton( 27 | style: ButtonStyle( 28 | backgroundColor: MaterialStateProperty.all( 29 | Colors.grey[900], 30 | ), 31 | ), 32 | child: Text('Cancelar'), 33 | onPressed: () => Navigator.of(context).pop(false)), 34 | ], 35 | ), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/presentation/index.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer' as logger; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_bloc/flutter_bloc.dart'; 5 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 6 | 7 | import '../core/api/movies_endpoint.dart'; 8 | import '../domain/entities/genre.dart'; 9 | import '../l10n/I10n.dart'; 10 | import 'custom_drawer/business_logic/drawer_nav_cubit/drawer_nav_cubit.dart'; 11 | import 'genres/business_logic/genres_cubit.dart'; 12 | import 'genres/genres_view/genres_loading_view.dart'; 13 | import 'global_widgets/dialogs/on_will_pop_dialog.dart'; 14 | import 'movies/business_logic/movies_bloc/movies_bloc.dart'; 15 | import 'movies/movies_view/movies_view.dart'; 16 | 17 | class Index extends StatelessWidget { 18 | @override 19 | Widget build(BuildContext context) { 20 | logger.log('1'); 21 | return WillPopScope( 22 | onWillPop: () async => showDialog( 23 | context: context, 24 | builder: (context) => OnWillPopDialog(), 25 | ), 26 | child: BlocBuilder( 27 | builder: (context, genresState) { 28 | if (genresState is GenresInitial) { 29 | _initGenres(context); 30 | return GenresLoadingView(); 31 | } else if (genresState is GenresLoadInProgress) { 32 | return GenresLoadingView(); 33 | } else if (genresState is GenresLoadFailure) { 34 | return _genresLoadFailure(context, genresState.message); 35 | } else if (genresState is GenresLoadSuccess) { 36 | return _genresLoadSuccess(context, genresState.genres); 37 | // return _moviesWithGenre(context, genresState.genres[0]); 38 | } else { 39 | return null; 40 | } 41 | }, 42 | ), 43 | ); 44 | } 45 | 46 | void _initGenres(BuildContext context) { 47 | logger.log('2'); 48 | String language = L10n.getLang[AppLocalizations.of(context).localeName]; 49 | BlocProvider.of(context).genresGet(language: language); 50 | } 51 | 52 | Widget _genresLoadSuccess(BuildContext context, List genres) { 53 | logger.log('3'); 54 | return BlocBuilder( 55 | builder: (context, state) { 56 | logger.log('3.0.1: state => $state'); 57 | if (state is DrawerNavInitial) { 58 | logger.log('3.1: genre => ${genres[0].name}'); 59 | BlocProvider.of(context).getWithGenre(genres[0]); 60 | return _moviesWithGenre(context, genres[0]); 61 | } else if (state is DrawerNavGenre) { 62 | logger.log('3.2'); 63 | return _moviesWithGenre(context, state.genre); 64 | } else { 65 | return null; 66 | } 67 | }, 68 | ); 69 | } 70 | 71 | Widget _moviesWithGenre(BuildContext context, Genre genre) { 72 | logger.log('4'); 73 | String endpoint = MoviesEndpoint.withGenre; 74 | String language = L10n.getLang[AppLocalizations.of(context).localeName]; 75 | String title = genre.name; 76 | 77 | BlocProvider.of(context).add( 78 | MoviesGet(endpoint: endpoint, language: language, genre: genre.id), 79 | ); 80 | return MoviesView(title: title, endpoint: endpoint, language: language); 81 | } 82 | 83 | Widget _genresLoadFailure(BuildContext context, String message) { 84 | logger.log('5'); 85 | showDialog( 86 | context: context, 87 | builder: (context) => AlertDialog( 88 | title: Text('Error'), 89 | content: Text(message), 90 | ), 91 | ); 92 | return GenresLoadingView(); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /lib/presentation/movies/business_logic/appbar_search_mode_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_bloc/flutter_bloc.dart'; 2 | 3 | class AppbarSearhModeCubit extends Cubit { 4 | AppbarSearhModeCubit() : super(false); 5 | 6 | void appbarModeSearch() { 7 | emit(true); 8 | } 9 | 10 | void appbarModeNormal() { 11 | emit(false); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/presentation/movies/business_logic/movie_cast_cubit/movie_cast_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | 5 | import '../../../../core/errors/failure.dart'; 6 | import '../../../../domain/entities/actor.dart'; 7 | import '../../../../domain/usecases/get_movie_cast.dart'; 8 | 9 | part 'movie_cast_state.dart'; 10 | 11 | class MovieCastCubit extends Cubit { 12 | final GetMovieCast getMovieCast; 13 | 14 | MovieCastCubit({@required this.getMovieCast}) 15 | : assert(getMovieCast != null), 16 | super(MovieCastLoadInProgress()); 17 | 18 | Future movieCastGet({String language, int movieId}) async { 19 | emit(MovieCastLoadInProgress()); 20 | final failureOrMovie = await getMovieCast( 21 | CastParams(movieId: movieId, language: language), 22 | ); 23 | failureOrMovie.fold( 24 | (failure) => emit( 25 | MovieCastLoadFailure(message: mapFailureToMessage(failure)), 26 | ), 27 | (cast) => emit(MovieCastLoadSuccess(cast: cast)), 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/presentation/movies/business_logic/movie_cast_cubit/movie_cast_state.dart: -------------------------------------------------------------------------------- 1 | part of 'movie_cast_cubit.dart'; 2 | 3 | abstract class MovieCastState extends Equatable { 4 | @override 5 | List get props => []; 6 | } 7 | 8 | class MovieCastLoadInProgress extends MovieCastState {} 9 | 10 | class MovieCastLoadSuccess extends MovieCastState { 11 | final List cast; 12 | MovieCastLoadSuccess({@required this.cast}); 13 | 14 | @override 15 | List get props => [cast]; 16 | } 17 | 18 | class MovieCastLoadFailure extends MovieCastState { 19 | final String message; 20 | MovieCastLoadFailure({@required this.message}); 21 | 22 | @override 23 | List get props => [message]; 24 | } 25 | -------------------------------------------------------------------------------- /lib/presentation/movies/business_logic/movie_details_cubit/movie_details_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | 5 | import '../../../../core/errors/failure.dart'; 6 | import '../../../../domain/entities/movie.dart'; 7 | import '../../../../domain/usecases/get_movie_detail.dart'; 8 | 9 | part 'movie_details_state.dart'; 10 | 11 | class MovieDetailsCubit extends Cubit { 12 | final GetMovieDetail getMovieDetail; 13 | 14 | MovieDetailsCubit({@required this.getMovieDetail}) 15 | : assert(getMovieDetail != null), 16 | super(MovieDetailsLoadInProgress()); 17 | 18 | Future movieDetailsGet({String language, int movieId}) async { 19 | emit(MovieDetailsLoadInProgress()); 20 | final failureOrMovie = await getMovieDetail( 21 | MovieDetailParams(movieId: movieId, language: language), 22 | ); 23 | failureOrMovie.fold( 24 | (failure) => emit( 25 | MovieDetailsLoadFailure(message: mapFailureToMessage(failure)), 26 | ), 27 | (movie) => emit(MovieDetailsLoadSuccess(movie: movie)), 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/presentation/movies/business_logic/movie_details_cubit/movie_details_state.dart: -------------------------------------------------------------------------------- 1 | part of 'movie_details_cubit.dart'; 2 | 3 | abstract class MovieDetailsState extends Equatable { 4 | @override 5 | List get props => []; 6 | } 7 | 8 | class MovieDetailsLoadInProgress extends MovieDetailsState {} 9 | 10 | class MovieDetailsLoadSuccess extends MovieDetailsState { 11 | final Movie movie; 12 | MovieDetailsLoadSuccess({@required this.movie}); 13 | 14 | @override 15 | List get props => [movie]; 16 | } 17 | 18 | class MovieDetailsLoadFailure extends MovieDetailsState { 19 | final String message; 20 | MovieDetailsLoadFailure({@required this.message}); 21 | 22 | @override 23 | List get props => [message]; 24 | } 25 | -------------------------------------------------------------------------------- /lib/presentation/movies/business_logic/movies_bloc/movies_bloc.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | 5 | import '../../../../core/errors/failure.dart'; 6 | import '../../../../domain/entities/movie.dart'; 7 | import '../../../../domain/usecases/get_movies.dart'; 8 | 9 | part 'movies_event.dart'; 10 | part 'movies_state.dart'; 11 | 12 | class MoviesBloc extends Bloc { 13 | final GetMovies getMovies; 14 | 15 | MoviesBloc({ 16 | @required this.getMovies, 17 | }) : assert(getMovies != null), 18 | super(MoviesInitial()); 19 | 20 | @override 21 | Stream mapEventToState(MoviesEvent event) async* { 22 | if (event is MoviesGet) yield* _moviesGet(event); 23 | } 24 | 25 | Stream _moviesGet(MoviesGet event) async* { 26 | yield MoviesLoadInProgress(); 27 | final failureOrMovies = await getMovies( 28 | Params( 29 | endpoint: event.endpoint, 30 | language: event.language, 31 | genreId: event.genre, 32 | ), 33 | ); 34 | yield failureOrMovies.fold( 35 | (failure) => MoviesLoadFailure(message: mapFailureToMessage(failure)), 36 | (movies) => MoviesLoadSuccess(movies: movies), 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/presentation/movies/business_logic/movies_bloc/movies_event.dart: -------------------------------------------------------------------------------- 1 | part of 'movies_bloc.dart'; 2 | 3 | abstract class MoviesEvent extends Equatable { 4 | @override 5 | List get props => []; 6 | } 7 | 8 | class MoviesGet extends MoviesEvent { 9 | final String endpoint; 10 | final String language; 11 | final int genre; 12 | 13 | MoviesGet({this.endpoint, this.language, this.genre = -1}); 14 | 15 | @override 16 | List get props => [endpoint, language, genre]; 17 | } 18 | -------------------------------------------------------------------------------- /lib/presentation/movies/business_logic/movies_bloc/movies_state.dart: -------------------------------------------------------------------------------- 1 | part of 'movies_bloc.dart'; 2 | 3 | abstract class MoviesState extends Equatable { 4 | @override 5 | List get props => []; 6 | } 7 | 8 | class MoviesInitial extends MoviesState {} 9 | 10 | class MoviesLoadInProgress extends MoviesState {} 11 | 12 | class MoviesLoadSuccess extends MoviesState { 13 | final List movies; 14 | MoviesLoadSuccess({@required this.movies}); 15 | 16 | @override 17 | List get props => [movies]; 18 | } 19 | 20 | class MoviesLoadFailure extends MoviesState { 21 | final String message; 22 | MoviesLoadFailure({@required this.message}); 23 | 24 | @override 25 | List get props => [message]; 26 | } 27 | -------------------------------------------------------------------------------- /lib/presentation/movies/business_logic/movies_search_cubit/movies_search_cubit.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_bloc/flutter_bloc.dart'; 6 | 7 | import '../../../../core/errors/failure.dart'; 8 | import '../../../../core/api/movies_api.dart'; 9 | import '../../../../domain/entities/movie.dart'; 10 | import '../../../../domain/usecases/search_movies.dart'; 11 | 12 | part 'movies_search_state.dart'; 13 | 14 | class MoviesSearchCubit extends Cubit { 15 | final SearchMovies searchMovies; 16 | 17 | MoviesSearchCubit({ 18 | @required this.searchMovies, 19 | }) : assert(searchMovies != null), 20 | super(MoviesSearchInitial()); 21 | 22 | Future moviesSearchRestart() async { 23 | emit(MoviesSearchInitial()); 24 | } 25 | 26 | Future moviesSearch({ 27 | String language = MoviesApi.es, 28 | String query, 29 | }) async { 30 | if (query.trim().isEmpty || query == null) 31 | emit(MoviesSearchInitial()); 32 | else { 33 | emit(MoviesSearchLoadInProgress()); 34 | 35 | // to avoid making a query in every on change 36 | Timer( 37 | Duration(milliseconds: 500), 38 | () async { 39 | final failureOrMovies = await searchMovies( 40 | SearchMoviesParams(language: language, query: query), 41 | ); 42 | 43 | failureOrMovies.fold( 44 | (failure) => emit( 45 | MoviesSearchLoadFailure(message: mapFailureToMessage(failure)), 46 | ), 47 | (movies) => emit(MoviesSearchLoadSuccess(movies: movies)), 48 | ); 49 | }, 50 | ); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/presentation/movies/business_logic/movies_search_cubit/movies_search_state.dart: -------------------------------------------------------------------------------- 1 | part of 'movies_search_cubit.dart'; 2 | 3 | abstract class MoviesSearchState extends Equatable { 4 | @override 5 | List get props => []; 6 | } 7 | 8 | class MoviesSearchInitial extends MoviesSearchState {} 9 | 10 | class MoviesSearchLoadInProgress extends MoviesSearchState {} 11 | 12 | class MoviesSearchLoadSuccess extends MoviesSearchState { 13 | final List movies; 14 | MoviesSearchLoadSuccess({this.movies}); 15 | 16 | @override 17 | List get props => [this.movies]; 18 | } 19 | 20 | class MoviesSearchLoadFailure extends MoviesSearchState { 21 | final String message; 22 | MoviesSearchLoadFailure({this.message}); 23 | 24 | @override 25 | List get props => [this.message]; 26 | } 27 | -------------------------------------------------------------------------------- /lib/presentation/movies/movie_profile_view/movie_profile_appbar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class MoviesProfileAppbar extends StatelessWidget with PreferredSizeWidget { 4 | @override 5 | final Size preferredSize; 6 | 7 | final GlobalKey drawerKey; 8 | final String title; 9 | 10 | MoviesProfileAppbar({ 11 | @required this.title, 12 | this.drawerKey, 13 | }) : preferredSize = Size.fromHeight(70.0), 14 | super(); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return AppBar( 19 | title: Center( 20 | child: Container( 21 | child: Text( 22 | title, 23 | textAlign: TextAlign.center, 24 | softWrap: true, 25 | maxLines: 2, 26 | ), 27 | ), 28 | ), 29 | centerTitle: true, 30 | backgroundColor: Colors.transparent, 31 | shadowColor: Colors.transparent, 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/presentation/movies/movies_view/movies_grid_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | import '../../../core/api/movies_api.dart'; 5 | import '../../../domain/entities/movie.dart'; 6 | import '../movies_widgets/movie_rating.dart'; 7 | 8 | class MoviesGridView extends StatelessWidget { 9 | final List movies; 10 | 11 | const MoviesGridView({this.movies}); 12 | @override 13 | Widget build(BuildContext context) { 14 | double padd = 20; 15 | return Column( 16 | children: [ 17 | SizedBox(height: 90), 18 | Expanded( 19 | child: GridView.builder( 20 | padding: EdgeInsets.symmetric(horizontal: padd), 21 | gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( 22 | crossAxisCount: 2, 23 | mainAxisSpacing: 20, 24 | crossAxisSpacing: 20, 25 | childAspectRatio: 5 / 8, 26 | ), 27 | itemCount: movies.length, 28 | itemBuilder: (context, index) { 29 | return MovieGridCard(movies[index]); 30 | }), 31 | ), 32 | ], 33 | ); 34 | } 35 | } 36 | 37 | class MovieGridCard extends StatelessWidget { 38 | final Movie movie; 39 | MovieGridCard(this.movie); 40 | 41 | @override 42 | Widget build(BuildContext context) { 43 | return GestureDetector( 44 | onTap: () => _goToMovieProfile(context, movie), 45 | child: Stack( 46 | children: [ 47 | Hero( 48 | tag: movie.id, 49 | child: Container( 50 | child: _posterImage(movie), 51 | ), 52 | ), 53 | _movieTitle(movie), 54 | Column( 55 | children: [ 56 | Expanded(child: Container()), 57 | Container( 58 | height: 170, 59 | width: double.infinity, 60 | padding: EdgeInsets.all(10), 61 | child: Row( 62 | children: [ 63 | Expanded(child: Container()), 64 | MovieRating(movie: movie, tiny: true), 65 | ], 66 | ), 67 | ), 68 | ], 69 | ), 70 | ], 71 | ), 72 | ); 73 | } 74 | 75 | Widget _posterImage(Movie movie) { 76 | return CachedNetworkImage( 77 | imageUrl: MoviesApi.getMoviePoster(movie.posterPath), 78 | imageBuilder: (context, imageProvider) => Container( 79 | decoration: BoxDecoration( 80 | borderRadius: BorderRadius.circular(10), 81 | image: DecorationImage(image: imageProvider, fit: BoxFit.cover), 82 | ), 83 | ), 84 | progressIndicatorBuilder: (context, url, downloadProgress) => Center( 85 | child: CircularProgressIndicator(value: downloadProgress.progress)), 86 | errorWidget: (context, url, error) => Icon(Icons.error), 87 | ); 88 | } 89 | 90 | Widget _movieTitle(Movie movie) { 91 | return Column( 92 | children: [ 93 | Expanded(child: Container()), 94 | Container( 95 | height: 85, 96 | width: double.infinity, 97 | child: Stack( 98 | children: [ 99 | Opacity( 100 | opacity: 0.7, 101 | child: Container( 102 | decoration: BoxDecoration( 103 | borderRadius: BorderRadius.only( 104 | bottomLeft: Radius.circular(10), 105 | bottomRight: Radius.circular(10), 106 | ), 107 | color: Colors.black, 108 | ), 109 | ), 110 | ), 111 | Container( 112 | padding: EdgeInsets.symmetric(horizontal: 10), 113 | child: Center( 114 | child: Text( 115 | movie.title, 116 | textAlign: TextAlign.center, 117 | style: TextStyle( 118 | fontSize: 15, 119 | color: Colors.white, 120 | fontWeight: FontWeight.bold, 121 | ), 122 | ), 123 | ), 124 | ), 125 | ], 126 | ), 127 | ), 128 | ], 129 | ); 130 | } 131 | 132 | void _goToMovieProfile(BuildContext context, movie) { 133 | Navigator.of(context).pushNamed('/movie_profile', arguments: movie); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /lib/presentation/movies/movies_view/movies_loading_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 3 | 4 | class MoviesLoadingView extends StatelessWidget { 5 | @override 6 | Widget build(BuildContext context) { 7 | String loadingMovies = AppLocalizations.of(context).loading_movies; 8 | 9 | return Scaffold( 10 | backgroundColor: Colors.black, 11 | body: Center( 12 | child: Column( 13 | mainAxisAlignment: MainAxisAlignment.center, 14 | children: [ 15 | Expanded(flex: 30, child: Container()), 16 | Expanded( 17 | flex: 15, 18 | child: Container( 19 | child: Text( 20 | loadingMovies, 21 | maxLines: 2, 22 | textAlign: TextAlign.center, 23 | softWrap: true, 24 | style: TextStyle( 25 | color: Colors.white, 26 | fontSize: 20, 27 | fontWeight: FontWeight.bold, 28 | ), 29 | ), 30 | ), 31 | ), 32 | Expanded(flex: 1, child: Container()), 33 | Container( 34 | width: 60, 35 | height: 60, 36 | child: CircularProgressIndicator( 37 | strokeWidth: 10, 38 | valueColor: new AlwaysStoppedAnimation(Colors.blue), 39 | ), 40 | ), 41 | Expanded(flex: 15, child: Container()), 42 | ], 43 | ), 44 | ), 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/presentation/movies/movies_view/movies_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | 4 | import '../../custom_drawer/custom_drawer.dart'; 5 | import '../business_logic/appbar_search_mode_cubit.dart'; 6 | import '../business_logic/movies_bloc/movies_bloc.dart'; 7 | import '../business_logic/movies_search_cubit/movies_search_cubit.dart'; 8 | import '../movies_widgets/movies_appbar.dart'; 9 | import '../search_movies/search_movies_suggestions.dart'; 10 | import 'movies_grid_view.dart'; 11 | import 'movies_loading_view.dart'; 12 | 13 | class MoviesView extends StatelessWidget { 14 | final String title; 15 | final String endpoint; 16 | final String language; 17 | 18 | const MoviesView({this.title, this.endpoint, this.language}); 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | final mainAppbar = MoviesAppBar(title: title); 23 | final bodyMargin = 30.0; 24 | 25 | return Scaffold( 26 | resizeToAvoidBottomInset: false, 27 | extendBodyBehindAppBar: true, 28 | backgroundColor: Colors.transparent, 29 | appBar: mainAppbar, 30 | drawer: CustomDrawer(), 31 | body: BlocBuilder( 32 | builder: (context, searchState) { 33 | if (searchState is MoviesSearchInitial) 34 | return _viewMovies(bodyMargin); 35 | else 36 | return BlocBuilder( 37 | builder: (context, inSearchMode) { 38 | if (inSearchMode) 39 | return Stack( 40 | children: [ 41 | _viewMovies(bodyMargin), 42 | Column( 43 | children: [ 44 | SizedBox(height: mainAppbar.preferredSize.height), 45 | SizedBox(height: bodyMargin), 46 | SizedBox(height: 10), 47 | SearchMoviesSuggestions(), 48 | ], 49 | ), 50 | ], 51 | ); 52 | else 53 | return _viewMovies(bodyMargin); 54 | }, 55 | ); 56 | }, 57 | ), 58 | ); 59 | } 60 | 61 | Widget _viewMovies(double bodyMargin) { 62 | return Column( 63 | children: [ 64 | Container(height: bodyMargin, color: Colors.black), 65 | Expanded( 66 | child: BlocBuilder( 67 | builder: (context, state) { 68 | if (state is MoviesLoadSuccess) 69 | return MoviesGridView(movies: state.movies); 70 | else if (state is MoviesLoadFailure) 71 | return Center(child: Text(state.message)); 72 | else if (state is MoviesLoadInProgress) 73 | return MoviesLoadingView(); 74 | else if (state is MoviesInitial) { 75 | return MoviesLoadingView(); 76 | } else 77 | return null; 78 | }, 79 | ), 80 | ), 81 | ], 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/presentation/movies/movies_widgets/movie_poster.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:cached_network_image/cached_network_image.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | import '../../../core/api/movies_api.dart'; 7 | import '../../../domain/entities/movie.dart'; 8 | 9 | class MoviePoster extends StatelessWidget { 10 | final Movie movie; 11 | 12 | const MoviePoster({this.movie}); 13 | @override 14 | Widget build(BuildContext context) { 15 | return CachedNetworkImage( 16 | imageUrl: MoviesApi.getMoviePoster(movie.posterPath), 17 | imageBuilder: (context, imageProvider) => Opacity( 18 | opacity: 0.7, 19 | child: Container( 20 | decoration: BoxDecoration( 21 | image: DecorationImage(image: imageProvider, fit: BoxFit.cover), 22 | ), 23 | child: BackdropFilter( 24 | filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), 25 | child: Container( 26 | decoration: 27 | new BoxDecoration(color: Colors.white.withOpacity(0.0)), 28 | ), 29 | ), 30 | ), 31 | ), 32 | progressIndicatorBuilder: (context, url, downloadProgress) => Center( 33 | child: CircularProgressIndicator(value: downloadProgress.progress), 34 | ), 35 | errorWidget: (context, url, error) => Icon(Icons.error), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/presentation/movies/movies_widgets/movie_rating.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:percent_indicator/percent_indicator.dart'; 3 | 4 | import '../../../domain/entities/movie.dart'; 5 | 6 | class MovieRating extends StatelessWidget { 7 | final Movie movie; 8 | final bool tiny; 9 | 10 | const MovieRating({this.movie, this.tiny = false}); 11 | @override 12 | Widget build(BuildContext context) { 13 | final _percent = movie.voteAverage / 10; 14 | return Center( 15 | child: Container( 16 | padding: EdgeInsets.symmetric(vertical: 30), 17 | child: CircularPercentIndicator( 18 | radius: tiny ? 45 : 65.0, 19 | animation: true, 20 | animationDuration: 800, 21 | lineWidth: tiny ? 5.0 : 7.0, 22 | percent: _percent, 23 | center: Stack( 24 | children: [ 25 | Center( 26 | child: Opacity( 27 | opacity: 0.6, 28 | child: Container( 29 | padding: EdgeInsets.all(tiny ? 8.2 : 15), 30 | decoration: BoxDecoration( 31 | color: Colors.black, 32 | borderRadius: BorderRadius.circular(1000), 33 | ), 34 | child: Text('....'), 35 | ), 36 | ), 37 | ), 38 | Center( 39 | child: Text( 40 | '${movie.voteAverage}', 41 | style: TextStyle( 42 | fontWeight: FontWeight.bold, 43 | fontSize: tiny ? 12 : 14.0, 44 | color: Colors.white, 45 | ), 46 | ), 47 | ), 48 | ], 49 | ), 50 | circularStrokeCap: CircularStrokeCap.round, 51 | backgroundColor: Colors.black.withOpacity(0.4), 52 | progressColor: movie.voteAverage >= 7 53 | ? Colors.green 54 | : movie.voteAverage >= 4 55 | ? Colors.yellow 56 | : Colors.red, 57 | ), 58 | ), 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/presentation/movies/movies_widgets/movies_appbar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | 4 | import '../business_logic/appbar_search_mode_cubit.dart'; 5 | import '../search_movies/search_movies_bar.dart'; 6 | 7 | class MoviesAppBar extends StatelessWidget with PreferredSizeWidget { 8 | @override 9 | final Size preferredSize; 10 | 11 | final GlobalKey drawerKey; 12 | final String title; 13 | 14 | MoviesAppBar({ 15 | @required this.title, 16 | this.drawerKey, 17 | }) : preferredSize = Size.fromHeight(80.0), 18 | super(); 19 | 20 | @override 21 | Widget build(BuildContext context) { 22 | return BlocBuilder( 23 | builder: (context, state) { 24 | if (state) 25 | return AppBar( 26 | backgroundColor: Colors.transparent, 27 | shadowColor: Colors.transparent, 28 | flexibleSpace: SafeArea(child: Center(child: SearchMoviesBar())), 29 | actions: [ 30 | IconButton( 31 | icon: Icon(Icons.search_off), 32 | onPressed: () => BlocProvider.of(context) 33 | .appbarModeNormal(), 34 | ), 35 | ], 36 | ); 37 | else 38 | return AppBar( 39 | title: Center( 40 | child: Container( 41 | child: Text( 42 | title, 43 | textAlign: TextAlign.center, 44 | softWrap: true, 45 | maxLines: 2, 46 | ), 47 | ), 48 | ), 49 | centerTitle: true, 50 | backgroundColor: Colors.transparent, 51 | shadowColor: Colors.transparent, 52 | actions: [ 53 | IconButton( 54 | icon: Icon(Icons.search), 55 | onPressed: () => BlocProvider.of(context) 56 | .appbarModeSearch(), 57 | ), 58 | ], 59 | ); 60 | }, 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/presentation/movies/search_movies/search_movies_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | import '../business_logic/movies_search_cubit/movies_search_cubit.dart'; 6 | 7 | class SearchMoviesBar extends StatelessWidget { 8 | final double radius = 30; 9 | final controller = TextEditingController(); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | String searching = AppLocalizations.of(context).search_movies_searching; 14 | 15 | return Opacity( 16 | opacity: 0.7, 17 | child: Container( 18 | margin: EdgeInsets.symmetric(vertical: 5, horizontal: 50), 19 | decoration: BoxDecoration( 20 | color: Colors.black, 21 | borderRadius: BorderRadius.circular(radius), 22 | ), 23 | child: TextField( 24 | onChanged: (query) => BlocProvider.of(context) 25 | .moviesSearch(query: query), 26 | autofocus: true, 27 | cursorColor: Colors.white, 28 | controller: controller, 29 | style: TextStyle(color: Colors.white), 30 | decoration: InputDecoration( 31 | contentPadding: EdgeInsets.symmetric(horizontal: 30, vertical: 20), 32 | suffixIconConstraints: BoxConstraints(minWidth: 70), 33 | hintStyle: TextStyle(color: Colors.white, fontSize: 15), 34 | hintText: searching, 35 | suffixIcon: _iconClearButton(context), 36 | focusedBorder: _outlineInputBorder(radius, Colors.white), 37 | enabledBorder: _outlineInputBorder(radius, Colors.red), 38 | ), 39 | ), 40 | ), 41 | ); 42 | } 43 | 44 | Widget _iconClearButton(BuildContext context) { 45 | return IconButton( 46 | icon: Icon(Icons.clear, color: Colors.white), 47 | onPressed: () { 48 | controller.clear(); 49 | BlocProvider.of(context).moviesSearchRestart(); 50 | }, 51 | ); 52 | } 53 | 54 | OutlineInputBorder _outlineInputBorder(double radius, Color borderColor) { 55 | return OutlineInputBorder( 56 | borderSide: BorderSide(color: borderColor), 57 | borderRadius: BorderRadius.circular(radius), 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/presentation/movies/search_movies/search_movies_success.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | import '../../../../core/api/movies_api.dart'; 6 | import '../../../../domain/entities/movie.dart'; 7 | import '../business_logic/appbar_search_mode_cubit.dart'; 8 | import '../business_logic/movies_search_cubit/movies_search_cubit.dart'; 9 | 10 | class SearchMoviesSuccess extends StatelessWidget { 11 | final List movies; 12 | SearchMoviesSuccess(this.movies); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final viewInsets = EdgeInsets.fromWindowPadding( 17 | WidgetsBinding.instance.window.viewInsets, 18 | WidgetsBinding.instance.window.devicePixelRatio, 19 | ); 20 | 21 | if (movies.isNotEmpty) 22 | return Container( 23 | height: MediaQuery.of(context).size.height * 0.75 - viewInsets.bottom, 24 | child: ListView.builder( 25 | padding: EdgeInsets.all(0), 26 | itemCount: movies.length, 27 | itemBuilder: (context, i) => _movieTile(context, movies[i]), 28 | ), 29 | ); 30 | else 31 | return _noItemsFound(context); 32 | } 33 | 34 | Widget _movieTile(BuildContext context, Movie movie) { 35 | return ListTile( 36 | onTap: () { 37 | BlocProvider.of(context).moviesSearchRestart(); 38 | BlocProvider.of(context).appbarModeNormal(); 39 | Navigator.of(context).pushNamed('/movie_profile', arguments: movie); 40 | }, 41 | leading: _moviePoster(context, movie), 42 | title: Text(movie.title, style: TextStyle(fontWeight: FontWeight.w500)), 43 | subtitle: Text(movie.originalTitle), 44 | ); 45 | } 46 | 47 | Widget _moviePoster(BuildContext context, Movie movie) { 48 | return FadeInImage( 49 | image: NetworkImage(MoviesApi.getMoviePoster(movie.posterPath)), 50 | width: 60.0, 51 | fit: BoxFit.contain, 52 | placeholder: AssetImage('assets/img/no-image.jpg'), 53 | placeholderErrorBuilder: (context, object, stacktrace) { 54 | return Container( 55 | child: Image(image: AssetImage('assets/img/no-image.jpg')), 56 | ); 57 | }, 58 | imageErrorBuilder: (context, object, stacktrace) { 59 | return Container( 60 | child: Image(image: AssetImage('assets/img/no-image.jpg')), 61 | ); 62 | }, 63 | ); 64 | } 65 | 66 | Widget _noItemsFound(BuildContext context) { 67 | String notFound = AppLocalizations.of(context).search_movies_not_found; 68 | return BlocBuilder( 69 | builder: (context, state) { 70 | if (state is MoviesSearchLoadFailure) 71 | return Text( 72 | state.message, 73 | style: TextStyle(fontSize: 15), 74 | ); 75 | else 76 | return Text( 77 | notFound, 78 | style: TextStyle(fontSize: 15), 79 | ); 80 | }); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lib/presentation/movies/search_movies/search_movies_suggestions.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | 4 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 5 | import '../business_logic/movies_search_cubit/movies_search_cubit.dart'; 6 | import 'search_movies_success.dart'; 7 | 8 | class SearchMoviesSuggestions extends StatelessWidget { 9 | @override 10 | Widget build(BuildContext context) { 11 | return Container( 12 | width: MediaQuery.of(context).size.width, 13 | margin: EdgeInsets.symmetric(horizontal: 30), 14 | padding: EdgeInsets.all(20), 15 | decoration: BoxDecoration( 16 | borderRadius: BorderRadius.circular(15), 17 | color: Colors.grey[300], 18 | ), 19 | child: BlocBuilder( 20 | builder: (context, state) { 21 | if (state is MoviesSearchLoadSuccess) 22 | return SearchMoviesSuccess(state.movies); 23 | else if (state is MoviesSearchLoadInProgress) 24 | return _loadingBuilder(); 25 | else if (state is MoviesSearchLoadFailure) 26 | return _errorBuilder(context); 27 | else if (state is MoviesSearchInitial) 28 | return Container(); 29 | else 30 | return null; 31 | }, 32 | ), 33 | ); 34 | } 35 | 36 | Widget _loadingBuilder() { 37 | return Row( 38 | children: [ 39 | CircularProgressIndicator( 40 | valueColor: new AlwaysStoppedAnimation(Colors.black), 41 | ), 42 | Expanded(child: Container()), 43 | ], 44 | ); 45 | } 46 | 47 | Widget _errorBuilder(BuildContext context) { 48 | String error = AppLocalizations.of(context).search_movies_error; 49 | 50 | return Text( 51 | error, 52 | style: TextStyle(fontSize: 15), 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: my_movie_list 2 | description: A new Flutter project. 3 | 4 | publish_to: "none" 5 | 6 | version: 1.0.0+1 7 | 8 | environment: 9 | sdk: ">=2.7.0 <3.0.0" 10 | 11 | dependencies: 12 | flutter: 13 | sdk: flutter 14 | 15 | # Base 16 | cupertino_icons: ^1.0.0 17 | http: ^0.13.1 18 | dartz: 0.10.0-nullsafety.1 19 | get_it: ^6.1.1 20 | equatable: ^2.0.0 21 | flutter_bloc: ^7.0.0 22 | shared_preferences: ^2.0.5 23 | data_connection_checker: ^0.3.4 24 | 25 | # Beauty 26 | cached_network_image: ^2.5.0 27 | percent_indicator: ^3.0.1 28 | 29 | # Internationalization 30 | intl: ^0.17.0 31 | flutter_localizations: 32 | sdk: flutter 33 | 34 | dev_dependencies: 35 | flutter_test: 36 | sdk: flutter 37 | test: ^1.16.0 38 | bloc_test: ^8.0.0 39 | mockito: ^4.1.1 40 | 41 | flutter: 42 | uses-material-design: true 43 | generate: true 44 | 45 | assets: 46 | - assets/img/ 47 | - assets/cats_img/ 48 | -------------------------------------------------------------------------------- /readme_sources/architecture.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/readme_sources/architecture.jpeg -------------------------------------------------------------------------------- /readme_sources/categories.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/readme_sources/categories.gif -------------------------------------------------------------------------------- /readme_sources/clean_architecture.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/readme_sources/clean_architecture.jpeg -------------------------------------------------------------------------------- /readme_sources/data_layer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/readme_sources/data_layer.png -------------------------------------------------------------------------------- /readme_sources/domain_layer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/readme_sources/domain_layer.png -------------------------------------------------------------------------------- /readme_sources/movie_profile.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/readme_sources/movie_profile.gif -------------------------------------------------------------------------------- /readme_sources/presentation_layer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/readme_sources/presentation_layer.png -------------------------------------------------------------------------------- /readme_sources/search.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sr-Te/Flutter-CleanArchitecture-TDD/e2b22625417c30e454be5b82ea7cebacc587ea28/readme_sources/search.gif -------------------------------------------------------------------------------- /test/core/network/network_info_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:data_connection_checker/data_connection_checker.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mockito/mockito.dart'; 4 | import 'package:my_movie_list/core/network/network_info.dart'; 5 | 6 | class MockDataConnectionChecker extends Mock implements DataConnectionChecker {} 7 | 8 | void main() { 9 | NetworkInfoImpl networkInfo; 10 | MockDataConnectionChecker mockDataConnectionChecker; 11 | 12 | setUp(() { 13 | mockDataConnectionChecker = MockDataConnectionChecker(); 14 | networkInfo = NetworkInfoImpl(mockDataConnectionChecker); 15 | }); 16 | 17 | group('isConnected', () { 18 | test( 19 | 'should forward the call to DataConnectionChecker.hasConnection', 20 | () { 21 | // arrange 22 | final tHasConnectionFuture = Future.value(true); 23 | when(mockDataConnectionChecker.hasConnection) 24 | .thenAnswer((_) => tHasConnectionFuture); 25 | // act 26 | final result = networkInfo.isConnected; 27 | // assert 28 | verify(mockDataConnectionChecker.hasConnection); 29 | expect(result, tHasConnectionFuture); 30 | }, 31 | ); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /test/data/datasources/genres/genres_local_data_source_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter_test/flutter_test.dart'; 4 | import 'package:mockito/mockito.dart'; 5 | import 'package:matcher/matcher.dart'; 6 | import 'package:my_movie_list/core/errors/exception.dart'; 7 | import 'package:my_movie_list/data/datasources/genres/genres_local_data_source.dart'; 8 | import 'package:my_movie_list/domain/entities/genre.dart'; 9 | import 'package:shared_preferences/shared_preferences.dart'; 10 | 11 | import '../../../fixtures/fixture_reader.dart'; 12 | 13 | class MockSharedPreferences extends Mock implements SharedPreferences {} 14 | 15 | void main() { 16 | GenresLocalDataSourceImpl dataSource; 17 | MockSharedPreferences mockSharedPreferences; 18 | 19 | setUp(() { 20 | mockSharedPreferences = MockSharedPreferences(); 21 | dataSource = GenresLocalDataSourceImpl( 22 | sharedPreferences: mockSharedPreferences, 23 | ); 24 | }); 25 | 26 | group('getLastGenres', () { 27 | test( 28 | 'should return List from SharedPreferences when is at least one movie in the cache', 29 | () async { 30 | // arrange 31 | when(mockSharedPreferences.getString(any)) 32 | .thenReturn(fixture('genres_cached.json')); 33 | // act 34 | final result = await dataSource.getLastGenres(); 35 | // assert 36 | verify(mockSharedPreferences.getString(CACHED_GENRES)); 37 | expect(result, isA>()); 38 | }, 39 | ); 40 | 41 | test( 42 | 'should throw a CacheException when there is not a cached value', 43 | () { 44 | // arrange 45 | when(mockSharedPreferences.getString(any)).thenReturn(null); 46 | // act 47 | final call = dataSource.getLastGenres; 48 | // assert 49 | expect(() => call(), throwsA(TypeMatcher())); 50 | }, 51 | ); 52 | }); 53 | 54 | group('cacheGenres', () { 55 | final List tGenreListModel = []; 56 | 57 | test( 58 | 'should call SharedPreferences to cache the data', 59 | () { 60 | // act 61 | dataSource.cacheGenres(tGenreListModel); 62 | // assert 63 | final expectedJsonString = json.encode( 64 | genreModelListToJsonList(tGenreListModel), 65 | ); 66 | verify(mockSharedPreferences.setString( 67 | CACHED_GENRES, 68 | expectedJsonString, 69 | )); 70 | }, 71 | ); 72 | }); 73 | } 74 | -------------------------------------------------------------------------------- /test/data/datasources/genres/genres_remote_data_source_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter_test/flutter_test.dart'; 4 | import 'package:matcher/matcher.dart'; 5 | import 'package:mockito/mockito.dart'; 6 | import 'package:http/http.dart' as http; 7 | import 'package:my_movie_list/core/errors/exception.dart'; 8 | import 'package:my_movie_list/core/api/movies_api.dart'; 9 | import 'package:my_movie_list/data/datasources/genres/genres_remote_data_source.dart'; 10 | import 'package:my_movie_list/domain/entities/genre.dart'; 11 | 12 | import '../../../fixtures/fixture_reader.dart'; 13 | 14 | class MockHttpClient extends Mock implements http.Client {} 15 | 16 | void main() { 17 | GenresRemoteDataSourceImpl dataSource; 18 | MockHttpClient mockHttpClient; 19 | 20 | setUp(() { 21 | mockHttpClient = MockHttpClient(); 22 | dataSource = GenresRemoteDataSourceImpl(client: mockHttpClient); 23 | }); 24 | 25 | void setUpMockHttpClientSuccess200() { 26 | when(mockHttpClient.get(any)).thenAnswer( 27 | (_) async => http.Response( 28 | fixture('genres.json'), 29 | 200, 30 | headers: { 31 | HttpHeaders.contentTypeHeader: 'application/json; charset=utf-8', 32 | }, 33 | ), 34 | ); 35 | } 36 | 37 | void setUpMockHttpClientFailure404() { 38 | when(mockHttpClient.get(any)).thenAnswer( 39 | (_) async => http.Response('Something went wrong', 404), 40 | ); 41 | } 42 | 43 | group('getGenres', () { 44 | final tLanguage = MoviesApi.es; 45 | 46 | test( 47 | 'should preform a GET request on a URL', 48 | () { 49 | // arrange 50 | setUpMockHttpClientSuccess200(); 51 | // act 52 | dataSource.getGenres(tLanguage); 53 | // assert 54 | verify(mockHttpClient.get(MoviesApi.getGenres(tLanguage))); 55 | }, 56 | ); 57 | 58 | test( 59 | 'shoud return a List when the Response code is 200 (success)', 60 | () async { 61 | // arrange 62 | setUpMockHttpClientSuccess200(); 63 | // act 64 | final result = await dataSource.getGenres(tLanguage); 65 | // assert 66 | expect(result, isA>()); 67 | }, 68 | ); 69 | 70 | test( 71 | 'should throw a ServerException when response code is 404 or other', 72 | () async { 73 | // arrange 74 | setUpMockHttpClientFailure404(); 75 | // act 76 | final call = dataSource.getGenres; 77 | // assert 78 | expect( 79 | () => call(tLanguage), 80 | throwsA(TypeMatcher()), 81 | ); 82 | }, 83 | ); 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /test/data/datasources/movies/movies_local_data_source_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter_test/flutter_test.dart'; 4 | import 'package:mockito/mockito.dart'; 5 | import 'package:matcher/matcher.dart'; 6 | import 'package:my_movie_list/core/errors/exception.dart'; 7 | import 'package:my_movie_list/core/api/movies_endpoint.dart'; 8 | import 'package:my_movie_list/data/datasources/movies/movies_local_data_source.dart'; 9 | import 'package:my_movie_list/domain/entities/movie.dart'; 10 | import 'package:shared_preferences/shared_preferences.dart'; 11 | 12 | import '../../../fixtures/fixture_reader.dart'; 13 | 14 | class MockSharedPreferences extends Mock implements SharedPreferences {} 15 | 16 | void main() { 17 | MoviesLocalDataSourceImpl dataSource; 18 | MockSharedPreferences mockSharedPreferences; 19 | 20 | setUp(() { 21 | mockSharedPreferences = MockSharedPreferences(); 22 | dataSource = MoviesLocalDataSourceImpl( 23 | sharedPreferences: mockSharedPreferences, 24 | ); 25 | }); 26 | 27 | final tEndpoint = MoviesEndpoint.withGenre; 28 | 29 | group('getLastMovies', () { 30 | test( 31 | 'should return List from SharedPreferences when is at least one movie in the cache', 32 | () async { 33 | // arrange 34 | when(mockSharedPreferences.getString(any)) 35 | .thenReturn(fixture('movies_cached.json')); 36 | // act 37 | final result = await dataSource.getLastMovies(tEndpoint); 38 | // assert 39 | verify(mockSharedPreferences.getString(tEndpoint)); 40 | expect(result, isA>()); 41 | }, 42 | ); 43 | 44 | test( 45 | 'should throw a CacheException when there is not a cached value', 46 | () async { 47 | // arrange 48 | when(mockSharedPreferences.getString(any)).thenReturn(null); 49 | // act 50 | final call = dataSource.getLastMovies; 51 | // assert 52 | expect(() => call(tEndpoint), throwsA(TypeMatcher())); 53 | }, 54 | ); 55 | }); 56 | 57 | group('cacheMovies', () { 58 | final List tMovieListModel = []; 59 | 60 | test( 61 | 'should call SharedPreferences to cache the data', 62 | () { 63 | // act 64 | dataSource.cacheMovies(tEndpoint, tMovieListModel); 65 | // assert 66 | final expectedJsonString = json.encode( 67 | movieModelListToJsonList(tMovieListModel), 68 | ); 69 | verify(mockSharedPreferences.setString( 70 | tEndpoint, 71 | expectedJsonString, 72 | )); 73 | }, 74 | ); 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /test/data/datasources/movies/movies_remote_data_source_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter_test/flutter_test.dart'; 4 | import 'package:matcher/matcher.dart'; 5 | import 'package:mockito/mockito.dart'; 6 | import 'package:http/http.dart' as http; 7 | import 'package:my_movie_list/core/errors/exception.dart'; 8 | import 'package:my_movie_list/core/api/movies_api.dart'; 9 | import 'package:my_movie_list/core/api/movies_endpoint.dart'; 10 | import 'package:my_movie_list/data/datasources/movies/movies_remote_data_source.dart'; 11 | import 'package:my_movie_list/domain/entities/movie.dart'; 12 | 13 | import '../../../fixtures/fixture_reader.dart'; 14 | 15 | class MockHttpClient extends Mock implements http.Client {} 16 | 17 | void main() { 18 | MoviesRemoteDataSourceImpl dataSource; 19 | MockHttpClient mockHttpClient; 20 | 21 | setUp(() { 22 | mockHttpClient = MockHttpClient(); 23 | dataSource = MoviesRemoteDataSourceImpl(client: mockHttpClient); 24 | }); 25 | 26 | void setUpMockHttpClientSuccess200(String jsonFile) { 27 | when(mockHttpClient.get(any)).thenAnswer( 28 | (_) async => http.Response( 29 | fixture(jsonFile), 30 | 200, 31 | headers: { 32 | HttpHeaders.contentTypeHeader: 'application/json; charset=utf-8', 33 | }, 34 | ), 35 | ); 36 | } 37 | 38 | void setUpMockHttpClientFailure404() { 39 | when(mockHttpClient.get(any)).thenAnswer( 40 | (_) async => http.Response('Something went wrong', 404), 41 | ); 42 | } 43 | 44 | group('getMovies', () { 45 | final tLanguage = MoviesApi.es; 46 | final tEndpoint = MoviesEndpoint.withGenre; 47 | 48 | test( 49 | 'should preform a GET request on a URL', 50 | () { 51 | // arrange 52 | setUpMockHttpClientSuccess200('movies_now_playing.json'); 53 | // act 54 | dataSource.getMovies(tEndpoint, tLanguage, -1); 55 | // assert 56 | verify(mockHttpClient.get( 57 | MoviesApi.getMovies(tEndpoint, tLanguage, -1), 58 | )); 59 | }, 60 | ); 61 | 62 | test( 63 | 'should return List when response code is 200 (success)', 64 | () async { 65 | // arrange 66 | setUpMockHttpClientSuccess200('movies_now_playing.json'); 67 | // act 68 | final result = await dataSource.getMovies(tEndpoint, tLanguage, -1); 69 | // arrange 70 | expect(result, isA>()); 71 | }, 72 | ); 73 | 74 | test( 75 | 'should throw a ServerException when response code is 404 or other', 76 | () async { 77 | // arrange 78 | setUpMockHttpClientFailure404(); 79 | // act 80 | final call = dataSource.getMovies; 81 | // assert 82 | expect( 83 | () => call(tEndpoint, tLanguage, -1), 84 | throwsA(TypeMatcher()), 85 | ); 86 | }, 87 | ); 88 | }); 89 | 90 | group('searchMovies', () { 91 | final tLanguage = MoviesApi.es; 92 | final tQuery = 'k'; 93 | 94 | test( 95 | 'should return List when response code is 200 (success)', 96 | () async { 97 | // arrange 98 | setUpMockHttpClientSuccess200('movies_now_playing.json'); 99 | // act 100 | final result = await dataSource.searchMovies(tLanguage, tQuery); 101 | // assert 102 | expect(result, isA>()); 103 | }, 104 | ); 105 | 106 | test( 107 | 'should throw a ServerException when response code is 404 or other', 108 | () async { 109 | // arrange 110 | setUpMockHttpClientFailure404(); 111 | // act 112 | final call = dataSource.searchMovies; 113 | // assert 114 | expect( 115 | () => call(tLanguage, tQuery), 116 | throwsA(TypeMatcher()), 117 | ); 118 | }, 119 | ); 120 | }); 121 | 122 | group('getMovieDetail', () { 123 | final tLanguage = MoviesApi.es; 124 | final tMovieId = 399566; 125 | 126 | test( 127 | 'should return a Movie when response code is 200 (success)', 128 | () async { 129 | // arrange 130 | setUpMockHttpClientSuccess200('movie_detail.json'); 131 | // act 132 | final result = await dataSource.getMovieDetail(tLanguage, tMovieId); 133 | // assert 134 | expect(result, isA()); 135 | }, 136 | ); 137 | 138 | test( 139 | 'should throw a ServerException when response code is 404 or other', 140 | () async { 141 | // arrange 142 | setUpMockHttpClientFailure404(); 143 | // act 144 | final call = dataSource.getMovieDetail; 145 | // assert 146 | expect( 147 | () => call(tLanguage, tMovieId), 148 | throwsA(TypeMatcher()), 149 | ); 150 | }, 151 | ); 152 | }); 153 | } 154 | -------------------------------------------------------------------------------- /test/data/repositories/genres_repository_impl_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mockito/mockito.dart'; 4 | import 'package:my_movie_list/core/errors/exception.dart'; 5 | import 'package:my_movie_list/core/errors/failure.dart'; 6 | import 'package:my_movie_list/core/api/movies_api.dart'; 7 | import 'package:my_movie_list/core/network/network_info.dart'; 8 | import 'package:my_movie_list/data/datasources/genres/genres_local_data_source.dart'; 9 | import 'package:my_movie_list/data/datasources/genres/genres_remote_data_source.dart'; 10 | import 'package:my_movie_list/data/repositories/genres_repository_impl.dart'; 11 | import 'package:my_movie_list/domain/entities/genre.dart'; 12 | 13 | class MockRemoteDataSource extends Mock implements GenresRemoteDataSource {} 14 | 15 | class MockLocalDataSource extends Mock implements GenresLocalDataSource {} 16 | 17 | class MockNetworkInfo extends Mock implements NetworkInfo {} 18 | 19 | void main() { 20 | GenresRepositoryImpl repository; 21 | MockRemoteDataSource mockRemoteDataSource; 22 | MockLocalDataSource mockLocalDataSource; 23 | MockNetworkInfo mockNetworkInfo; 24 | 25 | setUp(() { 26 | mockRemoteDataSource = MockRemoteDataSource(); 27 | mockLocalDataSource = MockLocalDataSource(); 28 | mockNetworkInfo = MockNetworkInfo(); 29 | repository = GenresRepositoryImpl( 30 | remoteDataSource: mockRemoteDataSource, 31 | localDataSource: mockLocalDataSource, 32 | networkInfo: mockNetworkInfo, 33 | ); 34 | }); 35 | 36 | void runTestsOnline(Function body) { 37 | group('device is online', () { 38 | setUp(() { 39 | when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); 40 | }); 41 | 42 | body(); 43 | }); 44 | } 45 | 46 | void runTestsOffline(Function body) { 47 | group('device is offline', () { 48 | setUp(() { 49 | when(mockNetworkInfo.isConnected).thenAnswer((_) async => false); 50 | }); 51 | 52 | body(); 53 | }); 54 | } 55 | 56 | group('getGenres', () { 57 | final tLanguage = MoviesApi.en; 58 | final tGenresList = [Genre(id: 1, name: 'test')]; 59 | 60 | test( 61 | 'should check if the device is online', 62 | () { 63 | // arrange 64 | when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); 65 | // act 66 | repository.getGenres(tLanguage); 67 | // assert 68 | verify(mockNetworkInfo.isConnected); 69 | }, 70 | ); 71 | 72 | runTestsOnline(() { 73 | test( 74 | 'should return remote data when the call to remote data source is successful', 75 | () async { 76 | // arrange 77 | when(mockRemoteDataSource.getGenres(any)) 78 | .thenAnswer((_) async => tGenresList); 79 | // act 80 | final result = await repository.getGenres(tLanguage); 81 | // assert 82 | verify(mockRemoteDataSource.getGenres(tLanguage)); 83 | expect(result, equals(Right(tGenresList))); 84 | }, 85 | ); 86 | 87 | test( 88 | 'should cache the data locally when the call to remote data source is successful', 89 | () async { 90 | // arrange 91 | when(mockRemoteDataSource.getGenres(any)) 92 | .thenAnswer((_) async => tGenresList); 93 | // act 94 | await repository.getGenres(tLanguage); 95 | // assert 96 | verify(mockRemoteDataSource.getGenres(tLanguage)); 97 | verify(mockLocalDataSource.cacheGenres(tGenresList)); 98 | }, 99 | ); 100 | 101 | test( 102 | 'should return server failure when the call to remote data source is unsuccessful', 103 | () async { 104 | // arrange 105 | when(mockRemoteDataSource.getGenres(any)) 106 | .thenThrow(ServerException()); 107 | // act 108 | final result = await repository.getGenres(tLanguage); 109 | // assert 110 | verify(mockRemoteDataSource.getGenres(tLanguage)); 111 | expect(result, equals(Left(ServerFailure()))); 112 | }, 113 | ); 114 | }); 115 | 116 | runTestsOffline(() { 117 | test( 118 | 'should return last locally cached data when the cached data is present', 119 | () async { 120 | // arrange 121 | when(mockLocalDataSource.getLastGenres()) 122 | .thenAnswer((_) async => tGenresList); 123 | // act 124 | final result = await repository.getGenres(tLanguage); 125 | // assert 126 | verifyZeroInteractions(mockRemoteDataSource); 127 | verify(mockLocalDataSource.getLastGenres()); 128 | expect(result, equals(Right(tGenresList))); 129 | }, 130 | ); 131 | 132 | test( 133 | 'shoul return CacheFailure when there is no cached data present', 134 | () async { 135 | // arrange 136 | when(mockLocalDataSource.getLastGenres()).thenThrow(CacheException()); 137 | // act 138 | final result = await repository.getGenres(tLanguage); 139 | // assert 140 | verifyZeroInteractions(mockRemoteDataSource); 141 | verify(mockLocalDataSource.getLastGenres()); 142 | expect(result, equals(Left(CacheFailure()))); 143 | }, 144 | ); 145 | }); 146 | }); 147 | } 148 | -------------------------------------------------------------------------------- /test/data/repositories/movie_repository_impl_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mockito/mockito.dart'; 4 | import 'package:my_movie_list/core/api/movies_api.dart'; 5 | import 'package:my_movie_list/core/api/movies_endpoint.dart'; 6 | import 'package:my_movie_list/core/errors/exception.dart'; 7 | import 'package:my_movie_list/core/errors/failure.dart'; 8 | import 'package:my_movie_list/core/network/network_info.dart'; 9 | import 'package:my_movie_list/data/datasources/movies/movies_local_data_source.dart'; 10 | import 'package:my_movie_list/data/datasources/movies/movies_remote_data_source.dart'; 11 | import 'package:my_movie_list/data/repositories/movies_repository_impl.dart'; 12 | import 'package:my_movie_list/domain/entities/movie.dart'; 13 | 14 | class MockRemoteDataSource extends Mock implements MoviesRemoteDataSource {} 15 | 16 | class MockLocalDataSource extends Mock implements MoviesLocalDataSource {} 17 | 18 | class MockNetworkInfo extends Mock implements NetworkInfo {} 19 | 20 | void main() { 21 | MoviesRepositoryImpl repository; 22 | MockRemoteDataSource mockRemoteDataSource; 23 | MockLocalDataSource mockLocalDataSource; 24 | MockNetworkInfo mockNetworkInfo; 25 | 26 | setUp(() { 27 | mockRemoteDataSource = MockRemoteDataSource(); 28 | mockLocalDataSource = MockLocalDataSource(); 29 | mockNetworkInfo = MockNetworkInfo(); 30 | 31 | repository = MoviesRepositoryImpl( 32 | remoteDataSource: mockRemoteDataSource, 33 | localDataSource: mockLocalDataSource, 34 | networkInfo: mockNetworkInfo, 35 | ); 36 | }); 37 | 38 | void runTestsOnline(Function body) { 39 | group('device is online', () { 40 | setUp(() { 41 | when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); 42 | }); 43 | 44 | body(); 45 | }); 46 | } 47 | 48 | void runTestsOffline(Function body) { 49 | group('device is offline', () { 50 | setUp(() { 51 | when(mockNetworkInfo.isConnected).thenAnswer((_) async => false); 52 | }); 53 | 54 | body(); 55 | }); 56 | } 57 | 58 | group('getMovies', () { 59 | // DATA FOR THE MOCKS AND ASSERTIONS 60 | final tLanguage = MoviesApi.en; 61 | final tEndpoint = MoviesEndpoint.withGenre; 62 | final genreId = -1; 63 | final List tMovieModelList = []; 64 | 65 | test( 66 | 'should check if the device is online', 67 | () { 68 | // arrange 69 | when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); 70 | // act 71 | repository.getMovies(tEndpoint, tLanguage, -1); 72 | // assert 73 | verify(mockNetworkInfo.isConnected); 74 | }, 75 | ); 76 | 77 | runTestsOnline(() { 78 | setUp(() { 79 | when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); 80 | }); 81 | 82 | test( 83 | 'should return remote data when the call to remote data source is successful', 84 | () async { 85 | // arrange 86 | when( 87 | mockRemoteDataSource.getMovies(any, any, any), 88 | ).thenAnswer((_) async => tMovieModelList); 89 | // act 90 | final result = 91 | await repository.getMovies(tEndpoint, tLanguage, genreId); 92 | // assert 93 | verify(mockRemoteDataSource.getMovies(any, any, any)); 94 | expect(result, equals(Right(tMovieModelList))); 95 | }, 96 | ); 97 | 98 | test( 99 | 'should cache the data locally when the call to remote data source is successful', 100 | () async { 101 | // arrange 102 | when(mockRemoteDataSource.getMovies(any, any, any)) 103 | .thenAnswer((_) async => tMovieModelList); 104 | // act 105 | await repository.getMovies(tEndpoint, tLanguage, genreId); 106 | // assert 107 | verify(mockRemoteDataSource.getMovies(tEndpoint, tLanguage, genreId)); 108 | 109 | verify( 110 | mockLocalDataSource.cacheMovies( 111 | tEndpoint + '$genreId', 112 | tMovieModelList, 113 | ), 114 | ); 115 | }, 116 | ); 117 | 118 | test( 119 | 'should return server failure when the call to remote data source is unsuccessful', 120 | () async { 121 | // arrange 122 | when(mockRemoteDataSource.getMovies(any, any, any)) 123 | .thenThrow(ServerException()); 124 | // act 125 | final result = 126 | await repository.getMovies(tEndpoint, tLanguage, genreId); 127 | // assert 128 | verify(mockRemoteDataSource.getMovies(tEndpoint, tLanguage, genreId)); 129 | expect(result, equals(Left(ServerFailure()))); 130 | }, 131 | ); 132 | }); 133 | 134 | runTestsOffline(() { 135 | test( 136 | 'should return last locally cached data when the cached data is present', 137 | () async { 138 | // arrange 139 | when(mockLocalDataSource.getLastMovies(tEndpoint + '$genreId')) 140 | .thenAnswer((_) async => tMovieModelList); 141 | // act 142 | final result = 143 | await repository.getMovies(tEndpoint, tLanguage, genreId); 144 | // assert 145 | verifyZeroInteractions(mockRemoteDataSource); 146 | verify(mockLocalDataSource.getLastMovies(tEndpoint + '$genreId')); 147 | expect(result, equals(Right(tMovieModelList))); 148 | }, 149 | ); 150 | 151 | test('should return CacheFailure when there is no cached data present', 152 | () async { 153 | // arrange 154 | when(mockLocalDataSource.getLastMovies(tEndpoint + '$genreId')) 155 | .thenThrow(CacheException()); 156 | // act 157 | final result = 158 | await repository.getMovies(tEndpoint, tLanguage, genreId); 159 | // assert 160 | verifyZeroInteractions(mockRemoteDataSource); 161 | verify(mockLocalDataSource.getLastMovies(tEndpoint + '$genreId')); 162 | expect(result, equals(Left(CacheFailure()))); 163 | }); 164 | }); 165 | }); 166 | 167 | group('searchMovies', () { 168 | final tLanguage = MoviesApi.en; 169 | final tQuery = 'k'; 170 | final List tMovieList = []; 171 | 172 | test( 173 | 'should check if the device is online', 174 | () { 175 | // arrange 176 | when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); 177 | // act 178 | repository.searchMovies(tLanguage, tQuery); 179 | // assert 180 | verify(mockNetworkInfo.isConnected); 181 | }, 182 | ); 183 | 184 | runTestsOnline(() { 185 | test( 186 | 'should return remote data when the call to remote data source is successful', 187 | () async { 188 | // arrange 189 | when( 190 | mockRemoteDataSource.searchMovies(any, any), 191 | ).thenAnswer((_) async => tMovieList); 192 | // act 193 | final result = await repository.searchMovies(tLanguage, tQuery); 194 | // assert 195 | verify(mockRemoteDataSource.searchMovies(any, any)); 196 | expect(result, equals(Right(tMovieList))); 197 | }, 198 | ); 199 | 200 | test( 201 | 'should return server failure when the call to remote data source is unsuccessful', 202 | () async { 203 | // arrange 204 | when(mockRemoteDataSource.searchMovies(any, any)) 205 | .thenThrow(ServerException()); 206 | // act 207 | final result = await repository.searchMovies(tLanguage, tQuery); 208 | // assert 209 | verify(mockRemoteDataSource.searchMovies(tLanguage, tQuery)); 210 | expect(result, equals(Left(ServerFailure()))); 211 | }, 212 | ); 213 | }); 214 | 215 | runTestsOffline(() { 216 | test( 217 | 'should return internet failure when there is no internet', 218 | () async { 219 | // act 220 | final result = await repository.searchMovies(tLanguage, tQuery); 221 | // assert 222 | verifyZeroInteractions(mockRemoteDataSource); 223 | expect(result, equals(Left(InternetFailure()))); 224 | }, 225 | ); 226 | }); 227 | }); 228 | 229 | group('getMovieDetail', () { 230 | final tLanguage = MoviesApi.en; 231 | final tMovieId = 399566; 232 | final tMovieModel = Movie(); 233 | 234 | test( 235 | 'should check if the device is online', 236 | () { 237 | // arrange 238 | when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); 239 | // act 240 | repository.getMovieDetail(tLanguage, tMovieId); 241 | // assert 242 | verify(mockNetworkInfo.isConnected); 243 | }, 244 | ); 245 | 246 | runTestsOnline(() { 247 | test( 248 | 'should return remote data when the call to remote data source is successful', 249 | () async { 250 | // arrange 251 | when( 252 | mockRemoteDataSource.getMovieDetail(any, any), 253 | ).thenAnswer((_) async => tMovieModel); 254 | // act 255 | final result = await repository.getMovieDetail(tLanguage, tMovieId); 256 | // assert 257 | verify(mockRemoteDataSource.getMovieDetail(any, any)); 258 | expect(result, equals(Right(tMovieModel))); 259 | }, 260 | ); 261 | 262 | test( 263 | 'should return server failure when the call to remote data source is unsuccessful', 264 | () async { 265 | // arrange 266 | when(mockRemoteDataSource.getMovieDetail(any, any)) 267 | .thenThrow(ServerException()); 268 | // act 269 | final result = await repository.getMovieDetail(tLanguage, tMovieId); 270 | // assert 271 | verify(mockRemoteDataSource.getMovieDetail(tLanguage, tMovieId)); 272 | expect(result, equals(Left(ServerFailure()))); 273 | }, 274 | ); 275 | }); 276 | 277 | runTestsOffline(() { 278 | test( 279 | 'should return internet failure when there is no internet', 280 | () async { 281 | // act 282 | final result = await repository.getMovieDetail(tLanguage, tMovieId); 283 | // assert 284 | verifyZeroInteractions(mockRemoteDataSource); 285 | expect(result, equals(Left(InternetFailure()))); 286 | }, 287 | ); 288 | }); 289 | }); 290 | } 291 | -------------------------------------------------------------------------------- /test/domain/usecases/get_genres_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mockito/mockito.dart'; 4 | import 'package:my_movie_list/core/api/movies_api.dart'; 5 | import 'package:my_movie_list/domain/entities/genre.dart'; 6 | import 'package:my_movie_list/domain/repositories/genres_repository.dart'; 7 | import 'package:my_movie_list/domain/usecases/get_genres.dart'; 8 | 9 | class MockGenresRepository extends Mock implements GenresRepository {} 10 | 11 | void main() { 12 | GetGenres usecase; 13 | MockGenresRepository mockMovieRepository; 14 | 15 | setUp(() { 16 | mockMovieRepository = MockGenresRepository(); 17 | usecase = GetGenres(mockMovieRepository); 18 | }); 19 | 20 | final tLanguage = MoviesApi.en; 21 | final tGenreList = [Genre(id: 1, name: 'test')]; 22 | 23 | test( 24 | 'shoul get genres list from the repository', 25 | () async { 26 | // arrange 27 | when(mockMovieRepository.getGenres(any)) 28 | .thenAnswer((_) async => Right(tGenreList)); 29 | // act 30 | final result = await usecase(GenresParams(language: tLanguage)); 31 | // assert 32 | expect(result, Right(tGenreList)); 33 | verify(mockMovieRepository.getGenres(tLanguage)); 34 | verifyNoMoreInteractions(mockMovieRepository); 35 | }, 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /test/domain/usecases/get_movie_detail_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mockito/mockito.dart'; 4 | import 'package:my_movie_list/core/api/movies_api.dart'; 5 | import 'package:my_movie_list/domain/entities/movie.dart'; 6 | import 'package:my_movie_list/domain/repositories/movies_repository.dart'; 7 | import 'package:my_movie_list/domain/usecases/get_movie_detail.dart'; 8 | 9 | class MockMoviesRepository extends Mock implements MoviesRepository {} 10 | 11 | void main() { 12 | GetMovieDetail usecase; 13 | MockMoviesRepository mockMoviesRepository; 14 | 15 | setUp(() { 16 | mockMoviesRepository = MockMoviesRepository(); 17 | usecase = GetMovieDetail(mockMoviesRepository); 18 | }); 19 | 20 | final tLanguage = MoviesApi.en; 21 | final tMovieId = 399566; 22 | final tMovie = Movie(); 23 | 24 | test( 25 | 'should get a movie from repository', 26 | () async { 27 | // arrange 28 | when(mockMoviesRepository.getMovieDetail(any, any)) 29 | .thenAnswer((_) async => Right(tMovie)); 30 | // act 31 | final result = await usecase( 32 | MovieDetailParams(language: tLanguage, movieId: tMovieId), 33 | ); 34 | // assert 35 | expect(result, Right(tMovie)); 36 | verify(mockMoviesRepository.getMovieDetail(tLanguage, tMovieId)); 37 | verifyNoMoreInteractions(mockMoviesRepository); 38 | }, 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /test/domain/usecases/get_movies_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mockito/mockito.dart'; 4 | import 'package:my_movie_list/core/api/movies_api.dart'; 5 | import 'package:my_movie_list/core/api/movies_endpoint.dart'; 6 | import 'package:my_movie_list/domain/entities/movie.dart'; 7 | import 'package:my_movie_list/domain/repositories/movies_repository.dart'; 8 | import 'package:my_movie_list/domain/usecases/get_movies.dart'; 9 | 10 | class MockMovieRepository extends Mock implements MoviesRepository {} 11 | 12 | void main() { 13 | GetMovies usecase; 14 | MockMovieRepository mockMovieRepository; 15 | 16 | setUp(() { 17 | mockMovieRepository = MockMovieRepository(); 18 | usecase = GetMovies(mockMovieRepository); 19 | }); 20 | 21 | final tLanguage = MoviesApi.en; 22 | final tEndpoint = MoviesEndpoint.withGenre; 23 | final genreId = -1; 24 | final List tMovieList = []; 25 | 26 | test( 27 | 'shoul get movie list from the repository', 28 | () async { 29 | // arrange 30 | when(mockMovieRepository.getMovies(any, any, any)) 31 | .thenAnswer((_) async => Right(tMovieList)); 32 | // act 33 | final result = await usecase( 34 | Params(endpoint: tEndpoint, language: tLanguage, genreId: genreId)); 35 | // assert 36 | expect(result, Right(tMovieList)); 37 | verify(mockMovieRepository.getMovies(tEndpoint, tLanguage, genreId)); 38 | verifyNoMoreInteractions(mockMovieRepository); 39 | }, 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /test/domain/usecases/search_movies_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mockito/mockito.dart'; 4 | import 'package:my_movie_list/core/api/movies_api.dart'; 5 | import 'package:my_movie_list/domain/entities/movie.dart'; 6 | import 'package:my_movie_list/domain/repositories/movies_repository.dart'; 7 | import 'package:my_movie_list/domain/usecases/search_movies.dart'; 8 | 9 | class MockMovieRepository extends Mock implements MoviesRepository {} 10 | 11 | void main() { 12 | SearchMovies usecase; 13 | MockMovieRepository mockMovieRepository; 14 | 15 | setUp(() { 16 | mockMovieRepository = MockMovieRepository(); 17 | usecase = SearchMovies(mockMovieRepository); 18 | }); 19 | 20 | final List tMovieList = []; 21 | final tLanguage = MoviesApi.es; 22 | final tQuery = 'k'; 23 | 24 | test( 25 | 'shoul get movie list from the repository', 26 | () async { 27 | // arrange 28 | when(mockMovieRepository.searchMovies(any, any)) 29 | .thenAnswer((_) async => Right(tMovieList)); 30 | // act 31 | final result = await usecase( 32 | SearchMoviesParams(language: tLanguage, query: tQuery), 33 | ); 34 | // assert 35 | expect(result, Right(tMovieList)); 36 | verify(mockMovieRepository.searchMovies(tLanguage, tQuery)); 37 | verifyNoMoreInteractions(mockMovieRepository); 38 | }, 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /test/fixtures/fixture_reader.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | String fixture(String name) { 4 | var dir = Directory.current.path; 5 | if (dir.endsWith('/test')) { 6 | dir = dir.replaceAll('/test', ''); 7 | } 8 | return File('$dir/test/fixtures/$name').readAsStringSync(); 9 | } 10 | -------------------------------------------------------------------------------- /test/fixtures/genres.json: -------------------------------------------------------------------------------- 1 | { 2 | "genres": [ 3 | { 4 | "id": 28, 5 | "name": "Action" 6 | }, 7 | { 8 | "id": 12, 9 | "name": "Adventure" 10 | }, 11 | { 12 | "id": 16, 13 | "name": "Animation" 14 | }, 15 | { 16 | "id": 35, 17 | "name": "Comedy" 18 | }, 19 | { 20 | "id": 80, 21 | "name": "Crime" 22 | }, 23 | { 24 | "id": 99, 25 | "name": "Documentary" 26 | }, 27 | { 28 | "id": 18, 29 | "name": "Drama" 30 | }, 31 | { 32 | "id": 10751, 33 | "name": "Family" 34 | }, 35 | { 36 | "id": 14, 37 | "name": "Fantasy" 38 | }, 39 | { 40 | "id": 36, 41 | "name": "History" 42 | }, 43 | { 44 | "id": 27, 45 | "name": "Horror" 46 | }, 47 | { 48 | "id": 10402, 49 | "name": "Music" 50 | }, 51 | { 52 | "id": 9648, 53 | "name": "Mystery" 54 | }, 55 | { 56 | "id": 10749, 57 | "name": "Romance" 58 | }, 59 | { 60 | "id": 878, 61 | "name": "Science Fiction" 62 | }, 63 | { 64 | "id": 10770, 65 | "name": "TV Movie" 66 | }, 67 | { 68 | "id": 53, 69 | "name": "Thriller" 70 | }, 71 | { 72 | "id": 10752, 73 | "name": "War" 74 | }, 75 | { 76 | "id": 37, 77 | "name": "Western" 78 | } 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /test/fixtures/genres_cached.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 28, 4 | "name": "Action" 5 | }, 6 | { 7 | "id": 12, 8 | "name": "Adventure" 9 | }, 10 | { 11 | "id": 16, 12 | "name": "Animation" 13 | }, 14 | { 15 | "id": 35, 16 | "name": "Comedy" 17 | }, 18 | { 19 | "id": 80, 20 | "name": "Crime" 21 | }, 22 | { 23 | "id": 99, 24 | "name": "Documentary" 25 | }, 26 | { 27 | "id": 18, 28 | "name": "Drama" 29 | }, 30 | { 31 | "id": 10751, 32 | "name": "Family" 33 | }, 34 | { 35 | "id": 14, 36 | "name": "Fantasy" 37 | }, 38 | { 39 | "id": 36, 40 | "name": "History" 41 | }, 42 | { 43 | "id": 27, 44 | "name": "Horror" 45 | }, 46 | { 47 | "id": 10402, 48 | "name": "Music" 49 | }, 50 | { 51 | "id": 9648, 52 | "name": "Mystery" 53 | }, 54 | { 55 | "id": 10749, 56 | "name": "Romance" 57 | }, 58 | { 59 | "id": 878, 60 | "name": "Science Fiction" 61 | }, 62 | { 63 | "id": 10770, 64 | "name": "TV Movie" 65 | }, 66 | { 67 | "id": 53, 68 | "name": "Thriller" 69 | }, 70 | { 71 | "id": 10752, 72 | "name": "War" 73 | }, 74 | { 75 | "id": 37, 76 | "name": "Western" 77 | } 78 | ] 79 | -------------------------------------------------------------------------------- /test/fixtures/movie_detail.json: -------------------------------------------------------------------------------- 1 | { 2 | "adult": false, 3 | "backdrop_path": "/inJjDhCjfhh3RtrJWBmmDqeuSYC.jpg", 4 | "belongs_to_collection": { 5 | "id": 535313, 6 | "name": "Godzilla Collection", 7 | "poster_path": "/inNN466SKHNjbGmpfhfsaPQNleS.jpg", 8 | "backdrop_path": "/oboBn4VYB79uDxnyIri0Nt3U3N2.jpg" 9 | }, 10 | "budget": 200000000, 11 | "genres": [ 12 | { 13 | "id": 878, 14 | "name": "Science Fiction" 15 | }, 16 | { 17 | "id": 28, 18 | "name": "Action" 19 | }, 20 | { 21 | "id": 18, 22 | "name": "Drama" 23 | } 24 | ], 25 | "homepage": "https://www.godzillavskong.net/", 26 | "id": 399566, 27 | "imdb_id": "tt5034838", 28 | "original_language": "en", 29 | "original_title": "Godzilla vs. Kong", 30 | "overview": "In atime when monsters walk the Earth, humanity’s fight for its future sets Godzilla and Kong on a collision course that will see the two most powerful forces of nature on the planet collide in a spectacular battle for the ages.", 31 | "popularity": 1564.627, 32 | "poster_path": "/pgqgaUx1cJb5oZQQ5v0tNARCeBp.jpg", 33 | "production_companies": [ 34 | { 35 | "id": 174, 36 | "logo_path": "/IuAlhI9eVC9Z8UQWOIDdWRKSEJ.png", 37 | "name": "Warner Bros. Pictures", 38 | "origin_country": "US" 39 | }, 40 | { 41 | "id": 923, 42 | "logo_path": "/5UQsZrfbfG2dYJbx8DxfoTr2Bvu.png", 43 | "name": "Legendary Pictures", 44 | "origin_country": "US" 45 | } 46 | ], 47 | "production_countries": [ 48 | { 49 | "iso_3166_1": "US", 50 | "name": "United States of America" 51 | } 52 | ], 53 | "release_date": "2021-03-24", 54 | "revenue": 415590000, 55 | "runtime": 113, 56 | "spoken_languages": [ 57 | { 58 | "english_name": "English", 59 | "iso_639_1": "en", 60 | "name": "English" 61 | }, 62 | { 63 | "english_name": "Turkish", 64 | "iso_639_1": "tr", 65 | "name": "Türkçe" 66 | } 67 | ], 68 | "status": "Released", 69 | "tagline": "One Will Fall", 70 | "title": "Godzilla vs. Kong", 71 | "video": false, 72 | "vote_average": 8.1, 73 | "vote_count": 5559 74 | } 75 | -------------------------------------------------------------------------------- /test/presentation/custom_drawer/business_logic/drawer_nav_cubit/drawer_nav_cubit_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:my_movie_list/domain/entities/genre.dart'; 3 | import 'package:my_movie_list/presentation/custom_drawer/business_logic/drawer_nav_cubit/drawer_nav_cubit.dart'; 4 | 5 | void main() { 6 | DrawerNavCubit cubit; 7 | 8 | setUp(() { 9 | cubit = DrawerNavCubit(); 10 | }); 11 | 12 | final tGenre = Genre(id: 28, name: "Action"); 13 | 14 | test( 15 | 'initState should be DrawerNavPopular', 16 | () { 17 | // assert 18 | expect(cubit.state, equals(DrawerNavInitial())); 19 | }, 20 | ); 21 | 22 | test( 23 | 'should emit DrawerNavWithGenres when requested', 24 | () { 25 | // assert layer 26 | final expected = [DrawerNavGenre(tGenre)]; 27 | expectLater(cubit.stream, emitsInOrder(expected)); 28 | //act 29 | cubit.getWithGenre(tGenre); 30 | }, 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /test/presentation/genres/business_logic/genres_cubit_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:mockito/mockito.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | import 'package:my_movie_list/core/errors/failure.dart'; 5 | import 'package:my_movie_list/core/api/movies_api.dart'; 6 | import 'package:my_movie_list/domain/entities/genre.dart'; 7 | import 'package:my_movie_list/domain/usecases/get_genres.dart'; 8 | import 'package:my_movie_list/presentation/genres/business_logic/genres_cubit.dart'; 9 | 10 | class MockGetGenres extends Mock implements GetGenres {} 11 | 12 | void main() { 13 | GenresCubit cubit; 14 | MockGetGenres mockGetGenres; 15 | 16 | setUp(() { 17 | mockGetGenres = MockGetGenres(); 18 | cubit = GenresCubit(getGenres: mockGetGenres); 19 | }); 20 | 21 | test('initialState should be GenresInitial', () { 22 | // assert 23 | expect(cubit.state, equals(GenresInitial())); 24 | }); 25 | 26 | group('genresGet', () { 27 | final List tGenreList = []; 28 | final tLanguage = MoviesApi.en; 29 | 30 | test( 31 | 'should get data from getGenres usecase', 32 | () async { 33 | // arrange 34 | when(mockGetGenres(any)).thenAnswer((_) async => Right(tGenreList)); 35 | // act 36 | cubit.genresGet(language: tLanguage); 37 | await untilCalled(mockGetGenres(any)); 38 | // assert 39 | verify(mockGetGenres(GenresParams(language: tLanguage))); 40 | }, 41 | ); 42 | 43 | test( 44 | 'should emit [GenresLoadInProgress, GenresLoadSuccess] when data is gotten successfully', 45 | () async { 46 | // arrange 47 | when(mockGetGenres(any)).thenAnswer((_) async => Right(tGenreList)); 48 | // act 49 | final expected = [ 50 | GenresLoadInProgress(), 51 | GenresLoadSuccess(genres: tGenreList), 52 | ]; 53 | expectLater(cubit.stream, emitsInOrder(expected)); 54 | // assert 55 | cubit.genresGet(language: tLanguage); 56 | }, 57 | ); 58 | 59 | test( 60 | 'should emit [GenresLoadInProgress, GenresLoadFailure] when data is gotten unsuccessfully', 61 | () async { 62 | // arrange 63 | when(mockGetGenres(any)).thenAnswer((_) async => Left(ServerFailure())); 64 | // act 65 | final expected = [ 66 | GenresLoadInProgress(), 67 | GenresLoadFailure(message: FailureMessage.server), 68 | ]; 69 | expectLater(cubit.stream, emitsInOrder(expected)); 70 | // assert 71 | cubit.genresGet(language: tLanguage); 72 | }, 73 | ); 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /test/presentation/movies/business_logic/appbar_search_mode_cubit_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:my_movie_list/presentation/movies/business_logic/appbar_search_mode_cubit.dart'; 3 | 4 | void main() { 5 | AppbarSearhModeCubit cubit; 6 | 7 | setUp(() { 8 | cubit = AppbarSearhModeCubit(); 9 | }); 10 | 11 | test( 12 | 'initState should be false', 13 | () { 14 | // assert 15 | expect(cubit.state, equals(false)); 16 | }, 17 | ); 18 | 19 | test( 20 | 'should emit true when appbarModeSearch requested', 21 | () { 22 | // assert layer 23 | final expected = [true]; 24 | expectLater(cubit.stream, emitsInOrder(expected)); 25 | //act 26 | cubit.appbarModeSearch(); 27 | }, 28 | ); 29 | 30 | test( 31 | 'should emit true when appbarModeNormal requested', 32 | () { 33 | // assert layer 34 | final expected = [false]; 35 | expectLater(cubit.stream, emitsInOrder(expected)); 36 | //act 37 | cubit.appbarModeNormal(); 38 | }, 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /test/presentation/movies/business_logic/movie_cast_cubit/movie_cast_cubit_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mockito/mockito.dart'; 4 | import 'package:my_movie_list/core/api/movies_api.dart'; 5 | import 'package:my_movie_list/core/errors/failure.dart'; 6 | import 'package:my_movie_list/domain/entities/actor.dart'; 7 | import 'package:my_movie_list/domain/usecases/get_movie_cast.dart'; 8 | import 'package:my_movie_list/presentation/movies/business_logic/movie_cast_cubit/movie_cast_cubit.dart'; 9 | 10 | class MockGetMovieCast extends Mock implements GetMovieCast {} 11 | 12 | void main() { 13 | MovieCastCubit cubit; 14 | MockGetMovieCast mockGetMovieCast; 15 | 16 | setUp(() { 17 | mockGetMovieCast = MockGetMovieCast(); 18 | cubit = MovieCastCubit(getMovieCast: mockGetMovieCast); 19 | }); 20 | 21 | test( 22 | 'initState should be MovieCastLoadInProgress', 23 | () { 24 | // assert 25 | expect(cubit.state, equals(MovieCastLoadInProgress())); 26 | }, 27 | ); 28 | 29 | group('getMovieCast', () { 30 | final tCast = [Actor()]; 31 | final tLanguage = MoviesApi.en; 32 | final tMovieId = 399566; 33 | 34 | test( 35 | 'should get data from getMovieCast usecase', 36 | () async { 37 | // arrange 38 | when(mockGetMovieCast(any)).thenAnswer((_) async => Right(tCast)); 39 | // act 40 | cubit.movieCastGet(language: tLanguage, movieId: tMovieId); 41 | await untilCalled(mockGetMovieCast(any)); 42 | // assert 43 | verify(mockGetMovieCast( 44 | CastParams(language: tLanguage, movieId: tMovieId), 45 | )); 46 | }, 47 | ); 48 | 49 | test( 50 | 'should emit [MovieCastLoadInProgress, MovieCastLoadSuccess] when data is gotten successfully', 51 | () async { 52 | // arrange 53 | when(mockGetMovieCast(any)).thenAnswer((_) async => Right(tCast)); 54 | // assert 55 | final expected = [ 56 | MovieCastLoadInProgress(), 57 | MovieCastLoadSuccess(cast: tCast), 58 | ]; 59 | expectLater(cubit.stream, emitsInOrder(expected)); 60 | //act 61 | cubit.movieCastGet(language: tLanguage, movieId: tMovieId); 62 | }, 63 | ); 64 | 65 | test( 66 | 'should emit [MovieCastLoadInProgress, MovieCastLoadFailure] when getting data fails', 67 | () async { 68 | // arrange 69 | when(mockGetMovieCast(any)).thenAnswer( 70 | (_) async => Left(ServerFailure()), 71 | ); 72 | // assert layer 73 | final expected = [ 74 | MovieCastLoadInProgress(), 75 | MovieCastLoadFailure(message: FailureMessage.server), 76 | ]; 77 | expectLater(cubit.stream, emitsInOrder(expected)); 78 | //act 79 | cubit.movieCastGet(language: tLanguage, movieId: tMovieId); 80 | }, 81 | ); 82 | 83 | test( 84 | 'should emit [MoviesCastLoadInProgress, MoviesCastLoadFailure] when there is no internet', 85 | () async { 86 | // arrange 87 | when(mockGetMovieCast(any)).thenAnswer( 88 | (_) async => Left(InternetFailure()), 89 | ); 90 | // assert layer 91 | final expected = [ 92 | MovieCastLoadInProgress(), 93 | MovieCastLoadFailure(message: FailureMessage.internet), 94 | ]; 95 | expectLater(cubit.stream, emitsInOrder(expected)); 96 | //act 97 | cubit.movieCastGet(language: tLanguage, movieId: tMovieId); 98 | }, 99 | ); 100 | }); 101 | } 102 | -------------------------------------------------------------------------------- /test/presentation/movies/business_logic/movie_details_cubit/movie_details_cubit_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mockito/mockito.dart'; 4 | import 'package:my_movie_list/core/api/movies_api.dart'; 5 | import 'package:my_movie_list/core/errors/failure.dart'; 6 | import 'package:my_movie_list/domain/entities/movie.dart'; 7 | import 'package:my_movie_list/domain/usecases/get_movie_detail.dart'; 8 | import 'package:my_movie_list/presentation/movies/business_logic/movie_details_cubit/movie_details_cubit.dart'; 9 | 10 | class MockGetMovieDetails extends Mock implements GetMovieDetail {} 11 | 12 | void main() { 13 | MovieDetailsCubit cubit; 14 | MockGetMovieDetails mockGetMovieDetails; 15 | 16 | setUp(() { 17 | mockGetMovieDetails = MockGetMovieDetails(); 18 | cubit = MovieDetailsCubit(getMovieDetail: mockGetMovieDetails); 19 | }); 20 | 21 | test( 22 | 'initState should be MoviesSearchInitial ', 23 | () { 24 | // assert 25 | expect(cubit.state, equals(MovieDetailsLoadInProgress())); 26 | }, 27 | ); 28 | 29 | group('getMovieDetail', () { 30 | final tLanguage = MoviesApi.en; 31 | final tMovieId = 399566; 32 | final tMovie = Movie(); 33 | 34 | test( 35 | 'should get data from getMovieDetail usecase', 36 | () async { 37 | // arrange 38 | when(mockGetMovieDetails(any)).thenAnswer((_) async => Right(tMovie)); 39 | // act 40 | cubit.movieDetailsGet(language: tLanguage, movieId: tMovieId); 41 | await untilCalled(mockGetMovieDetails(any)); 42 | // assert 43 | verify(mockGetMovieDetails( 44 | MovieDetailParams(language: tLanguage, movieId: tMovieId), 45 | )); 46 | }, 47 | ); 48 | 49 | test( 50 | 'should emit [MovieDetailsLoadInProgress, MovieDetailsLoadSuccess] when data is gotten successfully', 51 | () async { 52 | // arrange 53 | when(mockGetMovieDetails(any)).thenAnswer((_) async => Right(tMovie)); 54 | // assert 55 | final expected = [ 56 | MovieDetailsLoadInProgress(), 57 | MovieDetailsLoadSuccess(movie: tMovie), 58 | ]; 59 | expectLater(cubit.stream, emitsInOrder(expected)); 60 | //act 61 | cubit.movieDetailsGet(language: tLanguage, movieId: tMovieId); 62 | }, 63 | ); 64 | 65 | test( 66 | 'should emit [MovieDetailsLoadInProgress, MovieDetailsLoadFailure] when getting data fails', 67 | () async { 68 | // arrange 69 | when(mockGetMovieDetails(any)).thenAnswer( 70 | (_) async => Left(ServerFailure()), 71 | ); 72 | // assert layer 73 | final expected = [ 74 | MovieDetailsLoadInProgress(), 75 | MovieDetailsLoadFailure(message: FailureMessage.server), 76 | ]; 77 | expectLater(cubit.stream, emitsInOrder(expected)); 78 | //act 79 | cubit.movieDetailsGet(language: tLanguage, movieId: tMovieId); 80 | }, 81 | ); 82 | 83 | test( 84 | 'should emit [MoviesDetailsLoadInProgress, MoviesDetailsLoadFailure] when there is no internet', 85 | () async { 86 | // arrange 87 | when(mockGetMovieDetails(any)).thenAnswer( 88 | (_) async => Left(InternetFailure()), 89 | ); 90 | // assert layer 91 | final expected = [ 92 | MovieDetailsLoadInProgress(), 93 | MovieDetailsLoadFailure(message: FailureMessage.internet), 94 | ]; 95 | expectLater(cubit.stream, emitsInOrder(expected)); 96 | //act 97 | cubit.movieDetailsGet(language: tLanguage, movieId: tMovieId); 98 | }, 99 | ); 100 | }); 101 | } 102 | -------------------------------------------------------------------------------- /test/presentation/movies/business_logic/movies_bloc/movies_bloc_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:mockito/mockito.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | import 'package:my_movie_list/core/errors/failure.dart'; 5 | import 'package:my_movie_list/core/api/movies_api.dart'; 6 | import 'package:my_movie_list/core/api/movies_endpoint.dart'; 7 | import 'package:my_movie_list/domain/entities/movie.dart'; 8 | import 'package:my_movie_list/domain/usecases/get_movies.dart'; 9 | import 'package:my_movie_list/presentation/movies/business_logic/movies_bloc/movies_bloc.dart'; 10 | 11 | class MockGetMovies extends Mock implements GetMovies {} 12 | 13 | void main() { 14 | MoviesBloc bloc; 15 | MockGetMovies mockGetMovies; 16 | 17 | setUp(() { 18 | mockGetMovies = MockGetMovies(); 19 | bloc = MoviesBloc(getMovies: mockGetMovies); 20 | }); 21 | 22 | test('initialState should be MoviesInitial', () { 23 | // assert 24 | expect(bloc.state, equals(MoviesInitial())); 25 | }); 26 | 27 | group('MoviesGet', () { 28 | final List tMovieList = []; 29 | final tLanguage = MoviesApi.en; 30 | final genreId = -1; 31 | final tEndpoint = MoviesEndpoint.withGenre; 32 | 33 | test( 34 | 'should get data from getMovies usecase', 35 | () async { 36 | // arrange 37 | when(mockGetMovies(any)).thenAnswer((_) async => Right(tMovieList)); 38 | // act 39 | bloc.add( 40 | MoviesGet(endpoint: tEndpoint, language: tLanguage, genre: genreId), 41 | ); 42 | await untilCalled(mockGetMovies(any)); 43 | // assert 44 | verify(mockGetMovies(Params( 45 | endpoint: tEndpoint, language: tLanguage, genreId: genreId))); 46 | }, 47 | ); 48 | 49 | test( 50 | 'should emit [MoviesLoadInProgress, MoviesLoadSuccess] when data is gotten successfully', 51 | () async { 52 | // arrange 53 | when(mockGetMovies(any)).thenAnswer((_) async => Right(tMovieList)); 54 | // assert 55 | final expected = [ 56 | MoviesLoadInProgress(), 57 | MoviesLoadSuccess(movies: tMovieList), 58 | ]; 59 | expectLater(bloc.stream, emitsInOrder(expected)); 60 | //act 61 | bloc.add( 62 | MoviesGet(endpoint: tEndpoint, language: tLanguage, genre: genreId), 63 | ); 64 | }, 65 | ); 66 | 67 | test( 68 | 'should emit [LoadingMovies, ErrorMovies] when getting data fails', 69 | () async { 70 | // arrange 71 | when(mockGetMovies(any)).thenAnswer((_) async => Left(ServerFailure())); 72 | // assert layer 73 | final expected = [ 74 | MoviesLoadInProgress(), 75 | MoviesLoadFailure(message: FailureMessage.server), 76 | ]; 77 | expectLater(bloc.stream, emitsInOrder(expected)); 78 | //act 79 | bloc.add( 80 | MoviesGet(endpoint: tEndpoint, language: tLanguage, genre: genreId), 81 | ); 82 | }, 83 | ); 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /test/presentation/movies/business_logic/movies_search_cubit/movies_search_cubit_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dartz/dartz.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mockito/mockito.dart'; 4 | import 'package:my_movie_list/core/errors/failure.dart'; 5 | import 'package:my_movie_list/core/api/movies_api.dart'; 6 | import 'package:my_movie_list/domain/entities/movie.dart'; 7 | import 'package:my_movie_list/domain/usecases/search_movies.dart'; 8 | import 'package:my_movie_list/presentation/movies/business_logic/movies_search_cubit/movies_search_cubit.dart'; 9 | 10 | class MockSearchMovies extends Mock implements SearchMovies {} 11 | 12 | void main() { 13 | MoviesSearchCubit cubit; 14 | MockSearchMovies mockSearchMovies; 15 | 16 | setUp(() { 17 | mockSearchMovies = MockSearchMovies(); 18 | cubit = MoviesSearchCubit(searchMovies: mockSearchMovies); 19 | }); 20 | 21 | test( 22 | 'initState should be MoviesSearchInitial ', 23 | () { 24 | // assert 25 | expect(cubit.state, equals(MoviesSearchInitial())); 26 | }, 27 | ); 28 | 29 | group('moviesSearchRestart', () { 30 | test( 31 | 'should emit MoviesSearchInitial when it is call', 32 | () async { 33 | // act 34 | cubit.moviesSearchRestart(); 35 | // assert 36 | expect(cubit.state, equals(MoviesSearchInitial())); 37 | }, 38 | ); 39 | }); 40 | 41 | group('searchMovies', () { 42 | String tLanguage = MoviesApi.es; 43 | String tQuery = 'k'; 44 | final List tMovieList = []; 45 | 46 | test( 47 | 'should get data from searchMovies usecase', 48 | () async { 49 | // arrange 50 | when(mockSearchMovies(any)).thenAnswer((_) async => Right(tMovieList)); 51 | // act 52 | cubit.moviesSearch(language: tLanguage, query: tQuery); 53 | await untilCalled(mockSearchMovies(any)); 54 | // assert 55 | verify(mockSearchMovies( 56 | SearchMoviesParams(language: tLanguage, query: tQuery), 57 | )); 58 | }, 59 | ); 60 | 61 | test( 62 | 'should emit [MoviesSearchLoadInProgress, MoviesSearchLoadSuccess] when data is gotten successfully', 63 | () async { 64 | // arrange 65 | when(mockSearchMovies(any)).thenAnswer((_) async => Right(tMovieList)); 66 | // assert 67 | final expected = [ 68 | MoviesSearchLoadInProgress(), 69 | MoviesSearchLoadSuccess(movies: tMovieList), 70 | ]; 71 | expectLater(cubit.stream, emitsInOrder(expected)); 72 | //act 73 | cubit.moviesSearch(language: tLanguage, query: tQuery); 74 | }, 75 | ); 76 | 77 | test( 78 | 'should emit [MoviesSearchLoadInProgress, MoviesSearchLoadFailure] when getting data fails', 79 | () async { 80 | // arrange 81 | when(mockSearchMovies(any)) 82 | .thenAnswer((_) async => Left(ServerFailure())); 83 | // assert layer 84 | final expected = [ 85 | MoviesSearchLoadInProgress(), 86 | MoviesSearchLoadFailure(message: FailureMessage.server), 87 | ]; 88 | expectLater(cubit.stream, emitsInOrder(expected)); 89 | //act 90 | cubit.moviesSearch(language: tLanguage, query: tQuery); 91 | }, 92 | ); 93 | 94 | test( 95 | 'should emit [MoviesSearchLoadInProgress, MoviesSearchLoadFailure] when there is no internet', 96 | () async { 97 | // arrange 98 | when(mockSearchMovies(any)) 99 | .thenAnswer((_) async => Left(InternetFailure())); 100 | // assert layer 101 | final expected = [ 102 | MoviesSearchLoadInProgress(), 103 | MoviesSearchLoadFailure(message: FailureMessage.internet), 104 | ]; 105 | expectLater(cubit.stream, emitsInOrder(expected)); 106 | //act 107 | cubit.moviesSearch(language: tLanguage, query: tQuery); 108 | }, 109 | ); 110 | }); 111 | } 112 | --------------------------------------------------------------------------------