diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..ebd43db0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,20 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+/.idea/.gitignore
+/.idea/compiler.xml
+/.idea/gradle.xml
+/.idea/misc.xml
+/.idea/vcs.xml
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 00000000..0c241e12
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,92 @@
+plugins {
+ id 'com.android.application'
+ id 'kotlin-android'
+ id 'kotlin-kapt'
+ id 'dagger.hilt.android.plugin'
+ id 'kotlin-android-extensions'
+}
+
+android {
+ compileSdk 31
+
+ defaultConfig {
+ applicationId "com.mousavi.hashem.mymoviesapp"
+ minSdk 21
+ targetSdk 31
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+}
+
+dependencies {
+
+ //androidx dependencies
+ implementation 'androidx.fragment:fragment-ktx:1.4.0'
+ implementation 'androidx.core:core-ktx:1.7.0'
+ implementation 'androidx.appcompat:appcompat:1.4.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
+
+ //material
+ implementation 'com.google.android.material:material:1.4.0'
+
+ //Retrofit
+ implementation 'com.squareup.retrofit2:retrofit:2.9.0'
+ implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
+ implementation 'com.squareup.okhttp3:logging-interceptor:4.9.3'
+
+ //Gson
+ implementation 'com.google.code.gson:gson:2.8.9'
+
+ // Coroutines
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
+
+ // Coroutine Lifecycle Scopes
+ implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
+ implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0"
+
+ //Dagger - Hilt
+ implementation "com.google.dagger:hilt-android:2.38.1"
+ kapt "com.google.dagger:hilt-android-compiler:2.38.1"
+ implementation "androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03"
+ kapt "androidx.hilt:hilt-compiler:1.0.0"
+
+ //Navigation Component
+ def nav_version = "2.4.0-rc01"
+ implementation("androidx.navigation:navigation-fragment-ktx:$nav_version")
+ implementation("androidx.navigation:navigation-ui-ktx:$nav_version")
+
+ // Room
+ implementation "androidx.room:room-runtime:2.4.0"
+ kapt "androidx.room:room-compiler:2.4.0"
+ // Kotlin Extensions and Coroutines support for Room
+ implementation "androidx.room:room-ktx:2.3.0"
+
+ //ImageLoader
+ implementation("io.coil-kt:coil:1.4.0")
+
+ testImplementation 'junit:junit:4.13.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.3'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
+ androidTestImplementation "com.google.truth:truth:1.1.3"
+ testImplementation "com.google.truth:truth:1.1.3"
+ testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0"
+ testImplementation "org.mockito:mockito-core:4.0.0"
+
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/mousavi/hashem/mymoviesapp/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/mousavi/hashem/mymoviesapp/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..f3929bd7
--- /dev/null
+++ b/app/src/androidTest/java/com/mousavi/hashem/mymoviesapp/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.mousavi.hashem.mymoviesapp
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.mousavi.hashem.mymoviesapp", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..0eb98870
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/common/Either.kt b/app/src/main/java/com/mousavi/hashem/common/Either.kt
new file mode 100644
index 00000000..54a9c6f0
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/common/Either.kt
@@ -0,0 +1,7 @@
+package com.mousavi.hashem.common
+
+sealed class Either {
+ data class Success(val data: T) : Either()
+
+ data class Error(val error: E) : Either()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/MoviesApp.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/MoviesApp.kt
new file mode 100644
index 00000000..c9a840aa
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/MoviesApp.kt
@@ -0,0 +1,7 @@
+package com.mousavi.hashem.mymoviesapp
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class MoviesApp: Application()
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/local/Converters.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/local/Converters.kt
new file mode 100644
index 00000000..9623db7a
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/local/Converters.kt
@@ -0,0 +1,17 @@
+package com.mousavi.hashem.mymoviesapp.data.local
+
+import androidx.room.TypeConverter
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+
+class Converters {
+
+ @TypeConverter
+ fun listToString(list: List) = Gson().toJson(list)
+
+ @TypeConverter
+ fun stringToList(value: String): List {
+ val type = object : TypeToken>() {}.type
+ return Gson().fromJson(value, type)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/local/MovieDao.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/local/MovieDao.kt
new file mode 100644
index 00000000..00e97886
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/local/MovieDao.kt
@@ -0,0 +1,20 @@
+package com.mousavi.hashem.mymoviesapp.data.local
+
+import androidx.room.*
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface MovieDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertMovieEntity(movieEntity: MovieEntity)
+
+ @Query("SELECT * FROM movies_table")
+ fun getMovieEntities(): Flow>
+
+ @Delete
+ suspend fun deleteMovieEntity(movieEntity: MovieEntity)
+
+ @Query("SELECT * FROM movies_table WHERE id=:id")
+ suspend fun getMovieEntity(id: Int): MovieEntity?
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/local/MovieEntity.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/local/MovieEntity.kt
new file mode 100644
index 00000000..d97a5709
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/local/MovieEntity.kt
@@ -0,0 +1,33 @@
+package com.mousavi.hashem.mymoviesapp.data.local
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+
+@Entity(tableName = "movies_table")
+class MovieEntity(
+ @PrimaryKey(autoGenerate = false)
+ val id: Int,
+ val backdropPath: String?,
+ val genreIds: List,
+ val overview: String,
+ val posterPath: String?,
+ val releaseDate: String?,
+ val title: String,
+ val voteAverage: Double,
+ val voteCount: Int,
+) {
+ fun toMovie(): Movie {
+ return Movie(
+ backdropPath = backdropPath,
+ genreNames = genreIds.toMutableList(),
+ id = id,
+ overview = overview,
+ posterPath = posterPath,
+ releaseDate = releaseDate,
+ title = title,
+ voteAverage = voteAverage,
+ voteCount = voteCount,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/local/MoviesDatabase.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/local/MoviesDatabase.kt
new file mode 100644
index 00000000..ffd0bcae
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/local/MoviesDatabase.kt
@@ -0,0 +1,18 @@
+package com.mousavi.hashem.mymoviesapp.data.local
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+
+@Database(
+ entities = [MovieEntity::class],
+ version = 1
+)
+@TypeConverters(Converters::class)
+abstract class MoviesDatabase: RoomDatabase() {
+ abstract val dao: MovieDao
+
+ companion object {
+ const val DATABASE_NAME = "movies_db"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/Api.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/Api.kt
new file mode 100644
index 00000000..3602e5a4
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/Api.kt
@@ -0,0 +1,33 @@
+package com.mousavi.hashem.mymoviesapp.data.remote
+
+import com.mousavi.hashem.mymoviesapp.data.remote.dto.GenresDto
+import com.mousavi.hashem.mymoviesapp.data.remote.dto.PageDataDto
+import com.mousavi.hashem.mymoviesapp.data.remote.dto.ReviewsDto
+import retrofit2.http.GET
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface Api {
+
+ companion object {
+ const val BASE_URL = "https://api.themoviedb.org/3/"
+ const val API_KEY = "7457b8e2de9fbd83fe4e24eaa79912f2"
+ const val IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500"
+ }
+
+ @GET("movie/popular")
+ suspend fun getPopularMovies(
+ @Query("language") language: String,
+ @Query("page") page: Int,
+ ): PageDataDto
+
+ @GET("genre/movie/list")
+ suspend fun getGenres(): GenresDto
+
+ @GET("movie/{movie_id}/reviews")
+ suspend fun getReviews(
+ @Path("movie_id") movieId: Int,
+ @Query("language") language: String,
+ @Query("page") page: Int,
+ ): ReviewsDto
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/NetworkDataSource.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/NetworkDataSource.kt
new file mode 100644
index 00000000..5430b22e
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/NetworkDataSource.kt
@@ -0,0 +1,22 @@
+package com.mousavi.hashem.mymoviesapp.data.remote
+
+import com.mousavi.hashem.common.Either
+import com.mousavi.hashem.mymoviesapp.data.remote.dto.GenresDto
+import com.mousavi.hashem.mymoviesapp.data.remote.dto.PageDataDto
+import com.mousavi.hashem.mymoviesapp.data.remote.dto.ReviewsDto
+
+interface NetworkDataSource {
+
+ suspend fun getPopularMovies(
+ language: String,
+ page: Int,
+ ): Either
+
+ suspend fun getGenres(): Either
+
+ suspend fun getReviews(
+ movieId: Int,
+ language: String,
+ page: Int,
+ ): Either
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/NetworkDataSourceImpl.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/NetworkDataSourceImpl.kt
new file mode 100644
index 00000000..d58223dc
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/NetworkDataSourceImpl.kt
@@ -0,0 +1,65 @@
+package com.mousavi.hashem.mymoviesapp.data.remote
+
+import com.mousavi.hashem.common.Either
+import com.mousavi.hashem.mymoviesapp.R
+import com.mousavi.hashem.mymoviesapp.data.remote.dto.GenresDto
+import com.mousavi.hashem.mymoviesapp.data.remote.dto.PageDataDto
+import com.mousavi.hashem.mymoviesapp.data.remote.dto.ReviewsDto
+import retrofit2.HttpException
+import java.io.IOException
+
+class NetworkDataSourceImpl(
+ private val api: Api,
+ private var stringProvider: StringProvider
+) : NetworkDataSource {
+
+ override suspend fun getPopularMovies(
+ language: String,
+ page: Int,
+ ): Either {
+ return try {
+ val popularMovies = api.getPopularMovies(language, page)
+ Either.Success(popularMovies)
+ } catch (e: HttpException) {
+ Either.Error(error = e.message ?: stringProvider.getString(R.string.error_occurred))
+ } catch (e: IOException) {
+ Either.Error(error = e.message ?: stringProvider.getString(R.string.check_internet_connection))
+ } catch (e: Exception) {
+ Either.Error(error = e.message ?: stringProvider.getString(R.string.unknown_error))
+ }
+ }
+
+ override suspend fun getGenres(): Either {
+ return try {
+ val genresDto = api.getGenres()
+ Either.Success(genresDto)
+ } catch (e: HttpException) {
+ Either.Error(error = e.message ?: stringProvider.getString(R.string.error_occurred))
+ } catch (e: IOException) {
+ Either.Error(error = e.message ?: stringProvider.getString(R.string.check_internet_connection))
+ } catch (e: Exception) {
+ Either.Error(error = e.message ?: stringProvider.getString(R.string.unknown_error))
+ }
+ }
+
+ override suspend fun getReviews(
+ movieId: Int,
+ language: String,
+ page: Int,
+ ): Either {
+ return try {
+ val reviewsDto = api.getReviews(
+ movieId,
+ language,
+ page
+ )
+ Either.Success(reviewsDto)
+ } catch (e: HttpException) {
+ Either.Error(error = e.message ?: stringProvider.getString(R.string.error_occurred))
+ } catch (e: IOException) {
+ Either.Error(error = e.message ?: stringProvider.getString(R.string.check_internet_connection))
+ } catch (e: Exception) {
+ Either.Error(error = e.message ?: stringProvider.getString(R.string.unknown_error))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/StringProvider.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/StringProvider.kt
new file mode 100644
index 00000000..adeb1eb0
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/StringProvider.kt
@@ -0,0 +1,8 @@
+package com.mousavi.hashem.mymoviesapp.data.remote
+
+import androidx.annotation.StringRes
+
+interface StringProvider {
+
+ fun getString(@StringRes id: Int): String
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/StringProviderImpl.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/StringProviderImpl.kt
new file mode 100644
index 00000000..2782638d
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/StringProviderImpl.kt
@@ -0,0 +1,10 @@
+package com.mousavi.hashem.mymoviesapp.data.remote
+
+import android.content.Context
+import androidx.annotation.StringRes
+
+class StringProviderImpl(
+ private val appContext: Context,
+) : StringProvider {
+ override fun getString(@StringRes id: Int): String = appContext.getString(id)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/dto/GenresDto.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/dto/GenresDto.kt
new file mode 100644
index 00000000..77abc885
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/dto/GenresDto.kt
@@ -0,0 +1,30 @@
+package com.mousavi.hashem.mymoviesapp.data.remote.dto
+
+import com.google.gson.annotations.SerializedName
+import com.mousavi.hashem.mymoviesapp.domain.model.Genre
+import com.mousavi.hashem.mymoviesapp.domain.model.Genres
+
+data class GenresDto(
+ @SerializedName("genres")
+ val genres: List,
+) {
+ fun toGenres(): Genres {
+ return Genres(
+ genres = genres.map { it.toGenre() }
+ )
+ }
+}
+
+data class GenreDto(
+ @SerializedName("id")
+ val id: Int,
+ @SerializedName("name")
+ val name: String,
+){
+ fun toGenre(): Genre {
+ return Genre(
+ id = id,
+ name = name
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/dto/PageDataDto.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/dto/PageDataDto.kt
new file mode 100644
index 00000000..96cfb9fe
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/dto/PageDataDto.kt
@@ -0,0 +1,71 @@
+package com.mousavi.hashem.mymoviesapp.data.remote.dto
+
+import com.google.gson.annotations.SerializedName
+import com.mousavi.hashem.mymoviesapp.data.remote.Api
+import com.mousavi.hashem.mymoviesapp.domain.model.Genres
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.mymoviesapp.domain.model.PageData
+data class PageDataDto(
+ @SerializedName("page")
+ val page: Int,
+ @SerializedName("results")
+ val movies: List,
+ @SerializedName("total_pages")
+ val totalPages: Int,
+ @SerializedName("total_results")
+ val totalResults: Int,
+) {
+ fun toPageData(): PageData {
+ return PageData(
+ page = page,
+ movies = movies.map { it.toMovie() }.toMutableList(),
+ totalPages = totalPages,
+ totalResults = totalResults
+ )
+ }
+}
+
+data class MovieDto(
+ @SerializedName("adult")
+ val adult: Boolean,
+ @SerializedName("backdrop_path")
+ val backdropPath: String?,
+ @SerializedName("genre_ids")
+ val genreIds: List,
+ @SerializedName("id")
+ val id: Int,
+ @SerializedName("original_language")
+ val originalLanguage: String,
+ @SerializedName("original_title")
+ val originalTitle: String,
+ @SerializedName("overview")
+ val overview: String,
+ @SerializedName("popularity")
+ val popularity: Double,
+ @SerializedName("poster_path")
+ val posterPath: String?,
+ @SerializedName("release_date")
+ val releaseDate: String?,
+ @SerializedName("title")
+ val title: String,
+ @SerializedName("video")
+ val video: Boolean,
+ @SerializedName("vote_average")
+ val voteAverage: Double,
+ @SerializedName("vote_count")
+ val voteCount: Int,
+) {
+ fun toMovie(): Movie {
+ return Movie(
+ backdropPath = Api.IMAGE_BASE_URL + backdropPath,
+ id = id,
+ genreIds = genreIds,
+ overview = overview,
+ posterPath = Api.IMAGE_BASE_URL + posterPath,
+ releaseDate = releaseDate,
+ title = title,
+ voteAverage = voteAverage,
+ voteCount = voteCount
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/dto/ReviewsDto.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/dto/ReviewsDto.kt
new file mode 100644
index 00000000..dd910f5e
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/remote/dto/ReviewsDto.kt
@@ -0,0 +1,78 @@
+package com.mousavi.hashem.mymoviesapp.data.remote.dto
+
+
+import com.google.gson.annotations.SerializedName
+import com.mousavi.hashem.mymoviesapp.data.remote.Api
+import com.mousavi.hashem.mymoviesapp.domain.model.AuthorDetails
+import com.mousavi.hashem.mymoviesapp.domain.model.Review
+import com.mousavi.hashem.mymoviesapp.domain.model.Reviews
+
+data class ReviewsDto(
+ @SerializedName("id")
+ val id: Int,
+ @SerializedName("page")
+ val page: Int,
+ @SerializedName("results")
+ val reviewDtos: List,
+ @SerializedName("total_pages")
+ val totalPages: Int,
+ @SerializedName("total_results")
+ val totalResults: Int,
+) {
+ fun toReviews(): Reviews {
+ return Reviews(
+ id = id,
+ page = page,
+ reviews = reviewDtos.map { it.toReview() },
+ totalPages = totalPages,
+ totalResults = totalResults
+ )
+ }
+}
+
+data class ReviewDto(
+ @SerializedName("author")
+ val author: String,
+ @SerializedName("author_details")
+ val authorDetailsDto: AuthorDetailsDto,
+ @SerializedName("content")
+ val content: String,
+ @SerializedName("created_at")
+ val createdAt: String?,
+ @SerializedName("id")
+ val id: String,
+ @SerializedName("updated_at")
+ val updatedAt: String?,
+ @SerializedName("url")
+ val url: String?,
+) {
+ fun toReview(): Review {
+ return Review(
+ author = author,
+ authorDetails = authorDetailsDto.toAuthorDetails(),
+ content = content,
+ createdAt = createdAt,
+ id = id
+ )
+ }
+}
+
+data class AuthorDetailsDto(
+ @SerializedName("avatar_path")
+ val avatarPath: String?,
+ @SerializedName("name")
+ val name: String?,
+ @SerializedName("rating")
+ val rating: Float?,
+ @SerializedName("username")
+ val username: String,
+) {
+ fun toAuthorDetails(): AuthorDetails {
+ return AuthorDetails(
+ avatarPath = Api.IMAGE_BASE_URL + avatarPath,
+ name = name,
+ rating = rating,
+ username = username
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/repository/MoviesRepositoryImpl.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/repository/MoviesRepositoryImpl.kt
new file mode 100644
index 00000000..b6324b3d
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/data/repository/MoviesRepositoryImpl.kt
@@ -0,0 +1,81 @@
+package com.mousavi.hashem.mymoviesapp.data.repository
+
+import com.mousavi.hashem.common.Either
+import com.mousavi.hashem.mymoviesapp.data.local.MovieDao
+import com.mousavi.hashem.mymoviesapp.data.remote.NetworkDataSource
+import com.mousavi.hashem.mymoviesapp.domain.model.Genres
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.mymoviesapp.domain.model.PageData
+import com.mousavi.hashem.mymoviesapp.domain.model.Reviews
+import com.mousavi.hashem.mymoviesapp.domain.repository.MoviesRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import java.util.concurrent.atomic.AtomicReference
+import javax.inject.Inject
+
+class MoviesRepositoryImpl @Inject constructor(
+ private val networkDataSource: NetworkDataSource,
+ private val dao: MovieDao,
+ ) : MoviesRepository {
+
+ //Nothing make cachedGenres null, but in future it's maybe the case, so we use atomic version
+ private val cachedGenres = AtomicReference()
+
+ override suspend fun getPopularMovies(language: String, page: Int): Either {
+ return when (val popularMovies = networkDataSource.getPopularMovies(language, page)) {
+ is Either.Success -> {
+ Either.Success(popularMovies.data.toPageData())
+ }
+ is Either.Error -> {
+ Either.Error(popularMovies.error)
+ }
+ }
+ }
+
+ override suspend fun checkIfFavoriteMovie(movie: Movie): Boolean {
+ return dao.getMovieEntity(movie.id) != null
+ }
+
+ override fun getFavoriteMoviesFromDatabase(): Flow> {
+ return dao.getMovieEntities().map { it.map { movieEntity -> movieEntity.toMovie() } }
+ }
+
+ override suspend fun deleteFavoriteMovieFromDatabase(movie: Movie) {
+ dao.deleteMovieEntity(movie.toMovieEntity())
+ }
+
+ override suspend fun saveToFavoriteMovieDatabase(movie: Movie) {
+ dao.insertMovieEntity(movie.toMovieEntity())
+ }
+
+ override suspend fun getReviews(
+ movieId: Int,
+ language: String,
+ page: Int,
+ ): Either {
+ return when (val popularMovies = networkDataSource.getReviews(movieId, language, page)) {
+ is Either.Success -> {
+ Either.Success(popularMovies.data.toReviews())
+ }
+ is Either.Error -> {
+ Either.Error(popularMovies.error)
+ }
+ }
+ }
+
+ override suspend fun getGenres(): Either {
+ val genresCached = cachedGenres.get()
+ if (genresCached != null) {
+ return Either.Success(genresCached)
+ }
+ return when (val genres = networkDataSource.getGenres()) {
+ is Either.Success -> {
+ cachedGenres.set(genres.data.toGenres())
+ Either.Success(genres.data.toGenres())
+ }
+ is Either.Error -> {
+ Either.Error(genres.error)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/di/AppModule.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/di/AppModule.kt
new file mode 100644
index 00000000..72f803fe
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/di/AppModule.kt
@@ -0,0 +1,157 @@
+package com.mousavi.hashem.mymoviesapp.di
+
+import android.app.Application
+import android.content.Context
+import android.util.Log
+import androidx.room.Room
+import com.mousavi.hashem.mymoviesapp.BuildConfig
+import com.mousavi.hashem.mymoviesapp.data.local.MovieDao
+import com.mousavi.hashem.mymoviesapp.data.local.MoviesDatabase
+import com.mousavi.hashem.mymoviesapp.data.local.MoviesDatabase.Companion.DATABASE_NAME
+import com.mousavi.hashem.mymoviesapp.data.remote.*
+import com.mousavi.hashem.mymoviesapp.data.remote.Api.Companion.API_KEY
+import com.mousavi.hashem.mymoviesapp.data.remote.Api.Companion.BASE_URL
+import com.mousavi.hashem.mymoviesapp.data.repository.MoviesRepositoryImpl
+import com.mousavi.hashem.mymoviesapp.domain.repository.MoviesRepository
+import com.mousavi.hashem.mymoviesapp.domain.usecases.*
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.Response
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+
+ @Provides
+ @Singleton
+ fun provideOkHttp(): OkHttpClient {
+ val logging = HttpLoggingInterceptor { message ->
+ Log.d("okHttpLog", message)
+ }.apply {
+ setLevel(
+ if (BuildConfig.DEBUG) {
+ HttpLoggingInterceptor.Level.BASIC
+ } else {
+ HttpLoggingInterceptor.Level.NONE
+ }
+ )
+ }
+
+ return OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .addInterceptor { chain ->
+ return@addInterceptor addApiKeyToRequests(chain)
+ }
+ .addInterceptor(logging)
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideRetrofitApi(client: OkHttpClient): Api {
+ return Retrofit
+ .Builder()
+ .baseUrl(BASE_URL)
+ .client(client)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ .create(Api::class.java)
+ }
+
+ @Provides
+ @Singleton
+ fun provideNetworkDataSource(api: Api, stringProvider: StringProvider): NetworkDataSource {
+ return NetworkDataSourceImpl(api, stringProvider)
+ }
+
+ @Provides
+ fun provideStringProvider(@ApplicationContext appContext: Context): StringProvider {
+ return StringProviderImpl(appContext)
+ }
+
+ @Provides
+ @Singleton
+ fun provideMoviesRepository(repository: MoviesRepositoryImpl): MoviesRepository {
+ return repository
+ }
+
+ @Provides
+ fun provideGetPopularMoviesUseCase(
+ repository: MoviesRepository,
+ getGenresUseCase: GetGenresUseCase,
+ ): GetPopularMoviesUseCase {
+ return GetPopularMoviesUseCaseImpl(repository, getGenresUseCase)
+ }
+
+ @Provides
+ fun provideGetGenresUseCase(repository: MoviesRepository): GetGenresUseCase {
+ return GetGenresUseCaseImpl(repository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideRoomDatabase(app: Application): MoviesDatabase {
+ return Room.databaseBuilder(
+ app,
+ MoviesDatabase::class.java,
+ DATABASE_NAME
+ ).build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideDao(moviesDatabase: MoviesDatabase): MovieDao {
+ return moviesDatabase.dao
+ }
+
+ @Provides
+ @Singleton
+ fun provideGetFavoriteMoviesFromDatabaseUseCase(repository: MoviesRepository): GetFavoriteMoviesFromDatabaseUseCase {
+ return GetFavoriteMoviesFromDatabaseUseCaseImpl(repository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideDeleteFavoriteMovieUseCase(repository: MoviesRepository): DeleteFavoriteMovieUseCase {
+ return DeleteFavoriteMovieUseCaseImpl(repository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideCheckIfFavoriteUseCase(repository: MoviesRepository): CheckIfFavoriteUseCase {
+ return CheckIfFavoriteUseCaseImpl(repository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideSaveFavoriteMovieToDatabaseUseCase(repository: MoviesRepository): SaveFavoriteMovieToDatabaseUseCase {
+ return SaveFavoriteMovieToDatabaseUseCaseImpl(repository)
+ }
+
+ @Provides
+ @Singleton
+ fun provideGetReviewsUseCase(repository: MoviesRepository): GetReviewsUseCase {
+ return GetReviewsUseCaseImpl(repository)
+ }
+}
+
+private fun addApiKeyToRequests(chain: Interceptor.Chain): Response {
+ val request = chain.request().newBuilder()
+ val originalHttpUrl = chain.request().url
+ val newUrl = originalHttpUrl.newBuilder()
+ .addQueryParameter("api_key", API_KEY).build()
+ request.url(newUrl)
+ return chain.proceed(request.build())
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/model/Genres.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/model/Genres.kt
new file mode 100644
index 00000000..3e9f2b11
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/model/Genres.kt
@@ -0,0 +1,10 @@
+package com.mousavi.hashem.mymoviesapp.domain.model
+
+data class Genres(
+ val genres: List
+)
+
+data class Genre(
+ val id: Int,
+ val name: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/model/PageData.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/model/PageData.kt
new file mode 100644
index 00000000..6fb2cfcb
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/model/PageData.kt
@@ -0,0 +1,41 @@
+package com.mousavi.hashem.mymoviesapp.domain.model
+
+import android.os.Parcelable
+import com.mousavi.hashem.mymoviesapp.data.local.MovieEntity
+import kotlinx.android.parcel.Parcelize
+
+//-1 indicates dummy data for using as MutableSharedFlow default value
+data class PageData(
+ val page: Int = -1,
+ val movies: MutableList = mutableListOf(),
+ val totalPages: Int = -1,
+ val totalResults: Int = -1,
+)
+
+@Parcelize
+data class Movie(
+ val backdropPath: String? = null,
+ val genreNames: MutableList = arrayListOf(),
+ val genreIds: List = emptyList(),
+ val id: Int = -1,
+ val overview: String = "",
+ val posterPath: String? = null,
+ val releaseDate: String? = null,
+ val title: String = "",
+ val voteAverage: Double = 0.0,
+ val voteCount: Int = 0,
+) : Parcelable {
+ fun toMovieEntity(): MovieEntity {
+ return MovieEntity(
+ backdropPath = this.backdropPath,
+ genreIds = this.genreNames,
+ id = this.id,
+ overview = this.overview,
+ posterPath = this.posterPath,
+ releaseDate = this.releaseDate,
+ title = this.title,
+ voteAverage = this.voteAverage,
+ voteCount = this.voteCount
+ )
+ }
+}
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/model/Reviews.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/model/Reviews.kt
new file mode 100644
index 00000000..099d5074
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/model/Reviews.kt
@@ -0,0 +1,25 @@
+package com.mousavi.hashem.mymoviesapp.domain.model
+
+
+data class Reviews(
+ val id: Int = -1,
+ val page: Int = -1,
+ val reviews: List = emptyList(),
+ val totalPages: Int = -1,
+ val totalResults: Int = -1,
+)
+
+data class Review(
+ val author: String,
+ val authorDetails: AuthorDetails,
+ val content: String?,
+ val createdAt: String?,
+ val id: String,
+)
+
+data class AuthorDetails(
+ val avatarPath: String?,
+ val name: String?,
+ val rating: Float?,
+ val username: String,
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/repository/MoviesRepository.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/repository/MoviesRepository.kt
new file mode 100644
index 00000000..d8c0708e
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/repository/MoviesRepository.kt
@@ -0,0 +1,32 @@
+package com.mousavi.hashem.mymoviesapp.domain.repository
+
+import com.mousavi.hashem.common.Either
+import com.mousavi.hashem.mymoviesapp.domain.model.Genres
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.mymoviesapp.domain.model.PageData
+import com.mousavi.hashem.mymoviesapp.domain.model.Reviews
+import kotlinx.coroutines.flow.Flow
+
+interface MoviesRepository {
+
+ suspend fun getPopularMovies(
+ language: String,
+ page: Int,
+ ): Either
+
+ suspend fun getGenres(): Either
+
+ suspend fun checkIfFavoriteMovie(movie: Movie): Boolean
+
+ fun getFavoriteMoviesFromDatabase(): Flow>
+
+ suspend fun deleteFavoriteMovieFromDatabase(movie: Movie)
+
+ suspend fun saveToFavoriteMovieDatabase(movie: Movie)
+
+ suspend fun getReviews(
+ movieId: Int,
+ language: String,
+ page: Int,
+ ): Either
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/CheckIfFavoriteUseCaseImpl.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/CheckIfFavoriteUseCaseImpl.kt
new file mode 100644
index 00000000..1570e83a
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/CheckIfFavoriteUseCaseImpl.kt
@@ -0,0 +1,17 @@
+package com.mousavi.hashem.mymoviesapp.domain.usecases
+
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.mymoviesapp.domain.repository.MoviesRepository
+
+class CheckIfFavoriteUseCaseImpl(
+ private val repository: MoviesRepository,
+) : CheckIfFavoriteUseCase {
+ override suspend operator fun invoke(movie: Movie): Boolean {
+ return repository.checkIfFavoriteMovie(movie)
+ }
+
+}
+
+interface CheckIfFavoriteUseCase {
+ suspend operator fun invoke(movie: Movie): Boolean
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/DeleteFavoriteMovieUseCaseImpl.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/DeleteFavoriteMovieUseCaseImpl.kt
new file mode 100644
index 00000000..48a1107b
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/DeleteFavoriteMovieUseCaseImpl.kt
@@ -0,0 +1,18 @@
+package com.mousavi.hashem.mymoviesapp.domain.usecases
+
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.mymoviesapp.domain.repository.MoviesRepository
+
+
+class DeleteFavoriteMovieUseCaseImpl(
+ private val repository: MoviesRepository,
+) : DeleteFavoriteMovieUseCase {
+
+ override suspend operator fun invoke(movie: Movie) {
+ repository.deleteFavoriteMovieFromDatabase(movie)
+ }
+}
+
+interface DeleteFavoriteMovieUseCase {
+ suspend operator fun invoke(movie: Movie)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetFavoriteMoviesFromDatabaseUseCaseImpl.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetFavoriteMoviesFromDatabaseUseCaseImpl.kt
new file mode 100644
index 00000000..9d6fb0eb
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetFavoriteMoviesFromDatabaseUseCaseImpl.kt
@@ -0,0 +1,17 @@
+package com.mousavi.hashem.mymoviesapp.domain.usecases
+
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.mymoviesapp.domain.repository.MoviesRepository
+import kotlinx.coroutines.flow.Flow
+
+class GetFavoriteMoviesFromDatabaseUseCaseImpl(
+ private val repository: MoviesRepository,
+): GetFavoriteMoviesFromDatabaseUseCase {
+ override operator fun invoke(): Flow> {
+ return repository.getFavoriteMoviesFromDatabase()
+ }
+}
+
+interface GetFavoriteMoviesFromDatabaseUseCase{
+ operator fun invoke(): Flow>
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetGenresUseCaseImpl.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetGenresUseCaseImpl.kt
new file mode 100644
index 00000000..0fbc9407
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetGenresUseCaseImpl.kt
@@ -0,0 +1,18 @@
+package com.mousavi.hashem.mymoviesapp.domain.usecases
+
+import com.mousavi.hashem.common.Either
+import com.mousavi.hashem.mymoviesapp.domain.model.Genres
+import com.mousavi.hashem.mymoviesapp.domain.repository.MoviesRepository
+
+class GetGenresUseCaseImpl(
+ private val repository: MoviesRepository,
+) : GetGenresUseCase {
+
+ override suspend operator fun invoke(): Either {
+ return repository.getGenres()
+ }
+}
+
+interface GetGenresUseCase {
+ suspend operator fun invoke(): Either
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetPopularMoviesUseCaseImpl.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetPopularMoviesUseCaseImpl.kt
new file mode 100644
index 00000000..b09e5fd5
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetPopularMoviesUseCaseImpl.kt
@@ -0,0 +1,52 @@
+package com.mousavi.hashem.mymoviesapp.domain.usecases
+
+import com.mousavi.hashem.common.Either
+import com.mousavi.hashem.mymoviesapp.domain.model.Genres
+import com.mousavi.hashem.mymoviesapp.domain.model.PageData
+import com.mousavi.hashem.mymoviesapp.domain.repository.MoviesRepository
+
+
+class GetPopularMoviesUseCaseImpl(
+ private val repository: MoviesRepository,
+ private var getGenres: GetGenresUseCase
+): GetPopularMoviesUseCase {
+ override suspend operator fun invoke(language: String, page: Int): Either {
+ return when (val genres = getGenres()) {
+ is Either.Success -> {
+ return when (val popularMovies = repository.getPopularMovies(language, page)) {
+ is Either.Success -> {
+ popularMovies.data.movies.forEach { movie ->
+ movie.genreNames.addAll(mapGenreIdToName(movie.genreIds, genres.data))
+ }
+ Either.Success(popularMovies.data)
+ }
+
+ is Either.Error -> {
+ Either.Error(popularMovies.error)
+ }
+ }
+ }
+ is Either.Error -> {
+ Either.Error(genres.error)
+ }
+ }
+ }
+
+ private fun mapGenreIdToName(list: List, genres: Genres): List {
+ val genreNames = mutableListOf()
+ genres.genres.forEach { genre ->
+ list.forEach { id ->
+ if (id == genre.id) {
+ genreNames.add(genre.name)
+ }
+ }
+ }
+ return genreNames
+ }
+
+}
+
+interface GetPopularMoviesUseCase {
+ suspend operator fun invoke(language: String, page: Int): Either
+}
+
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetReviewsUseCaseImpl.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetReviewsUseCaseImpl.kt
new file mode 100644
index 00000000..449da80e
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetReviewsUseCaseImpl.kt
@@ -0,0 +1,28 @@
+package com.mousavi.hashem.mymoviesapp.domain.usecases
+
+import com.mousavi.hashem.common.Either
+import com.mousavi.hashem.mymoviesapp.domain.model.Genres
+import com.mousavi.hashem.mymoviesapp.domain.model.PageData
+import com.mousavi.hashem.mymoviesapp.domain.model.Reviews
+import com.mousavi.hashem.mymoviesapp.domain.repository.MoviesRepository
+
+
+class GetReviewsUseCaseImpl(
+ private val repository: MoviesRepository,
+): GetReviewsUseCase {
+ override suspend operator fun invoke(movieId:Int, language: String, page: Int): Either {
+ return when (val reviews = repository.getReviews(movieId, language,page)) {
+ is Either.Success -> {
+ Either.Success(reviews.data)
+ }
+ is Either.Error -> {
+ Either.Error(reviews.error)
+ }
+ }
+ }
+}
+
+interface GetReviewsUseCase {
+ suspend operator fun invoke(movieId:Int, language: String, page: Int): Either
+}
+
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/SaveFavoriteMovieToDatabaseUseCaseImpl.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/SaveFavoriteMovieToDatabaseUseCaseImpl.kt
new file mode 100644
index 00000000..ed37359f
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/domain/usecases/SaveFavoriteMovieToDatabaseUseCaseImpl.kt
@@ -0,0 +1,17 @@
+package com.mousavi.hashem.mymoviesapp.domain.usecases
+
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.mymoviesapp.domain.repository.MoviesRepository
+
+class SaveFavoriteMovieToDatabaseUseCaseImpl(
+ private val repository: MoviesRepository
+) : SaveFavoriteMovieToDatabaseUseCase{
+
+ override suspend operator fun invoke(movie: Movie){
+ repository.saveToFavoriteMovieDatabase(movie)
+ }
+}
+
+interface SaveFavoriteMovieToDatabaseUseCase {
+ suspend operator fun invoke(movie: Movie)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/BaseFragment.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/BaseFragment.kt
new file mode 100644
index 00000000..a3f92e4b
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/BaseFragment.kt
@@ -0,0 +1,29 @@
+package com.mousavi.hashem.mymoviesapp.presentaion
+
+import android.os.Bundle
+import android.view.View
+import androidx.activity.OnBackPressedCallback
+import androidx.annotation.CallSuper
+import androidx.annotation.LayoutRes
+import androidx.fragment.app.Fragment
+
+open class BaseFragment(
+ @LayoutRes private val layoutId: Int,
+) : Fragment(layoutId) {
+
+ @CallSuper
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ activity?.onBackPressedDispatcher?.addCallback(
+ viewLifecycleOwner,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ onBackPressed()
+ }
+ }
+ )
+ }
+
+ open fun onBackPressed() {}
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/CircleImageView.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/CircleImageView.kt
new file mode 100644
index 00000000..c29d01c1
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/CircleImageView.kt
@@ -0,0 +1,40 @@
+package com.mousavi.hashem.mymoviesapp.presentaion
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Path
+import android.util.AttributeSet
+import androidx.appcompat.widget.AppCompatImageView
+
+class CircleImageView @JvmOverloads constructor(
+ context: Context,
+ attributeSet: AttributeSet? = null,
+) : AppCompatImageView(context, attributeSet) {
+
+ private val pathToClip = Path()
+
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ super.onMeasure(widthMeasureSpec, widthMeasureSpec)
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ canvas.clipPath(pathToClip)
+ super.onDraw(canvas)
+ }
+
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ if (w > 0 && h > 0) {
+ pathToClip.reset()
+
+ pathToClip.addCircle(
+ w / 2f,
+ h / 2f,
+ w / 2f,
+ Path.Direction.CCW
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/MainActivity.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/MainActivity.kt
new file mode 100644
index 00000000..fa31a0b6
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/MainActivity.kt
@@ -0,0 +1,49 @@
+package com.mousavi.hashem.mymoviesapp.presentaion
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.navigation.NavController
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.ui.setupWithNavController
+import com.google.android.material.bottomnavigation.BottomNavigationView
+import com.mousavi.hashem.mymoviesapp.R
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var navController: NavController
+ private lateinit var bottomNavigationView: BottomNavigationView
+
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+
+ setupBottomNavigation()
+
+ }
+
+ private fun setupBottomNavigation() {
+ val navHostFragment = supportFragmentManager.findFragmentById(
+ R.id.main_nav_host
+ ) as NavHostFragment
+ navController = navHostFragment.navController
+
+ bottomNavigationView = findViewById(R.id.bottom_navigation_view)
+ bottomNavigationView.setupWithNavController(navController)
+
+ bottomNavigationView.setOnItemReselectedListener {
+ if ((navController.currentDestination?.id == R.id.detailsFragment) ||
+ navController.currentDestination?.id == R.id.reviewsFragment
+ ) {
+ navController.popBackStack()
+ }
+ }
+ }
+
+ fun selectExplorePage() {
+ bottomNavigationView.selectedItemId = R.id.explore
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/details/DetailsFragment.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/details/DetailsFragment.kt
new file mode 100644
index 00000000..238c7b44
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/details/DetailsFragment.kt
@@ -0,0 +1,176 @@
+package com.mousavi.hashem.mymoviesapp.presentaion.explore.details
+
+import android.content.res.ColorStateList
+import android.os.Bundle
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.constraintlayout.helper.widget.Flow
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.findNavController
+import androidx.transition.TransitionInflater
+import coil.load
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.snackbar.Snackbar
+import com.mousavi.hashem.mymoviesapp.R
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.mymoviesapp.presentaion.BaseFragment
+import com.mousavi.hashem.mymoviesapp.presentaion.reviews.ReviewsFragment.Companion.ARG_MOVIE_ID
+import com.mousavi.hashem.util.dateFormat
+import com.mousavi.hashem.util.dp
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.collectLatest
+
+@AndroidEntryPoint
+class DetailsFragment : BaseFragment(R.layout.fragment_details) {
+
+ companion object {
+ const val ARG_MOVIE = "arg_movie"
+ }
+
+ private val viewModel: DetailsViewModel by viewModels()
+
+ private lateinit var backDropImageView: ImageView
+ private lateinit var posterImageView: ImageView
+ private lateinit var rateTextView: TextView
+ private lateinit var allVotesTextView: TextView
+ private lateinit var titleTextView: TextView
+ private lateinit var overviewTextView: TextView
+ private lateinit var backButton: MaterialButton
+ private lateinit var favoriteButton: MaterialButton
+ private lateinit var flowLayout: ConstraintLayout
+ private lateinit var flowHelper: Flow
+ private lateinit var releaseDateTextView: TextView
+ private lateinit var btnReviews: MaterialButton
+
+ private lateinit var movie: Movie
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ movie = arguments?.getParcelable(ARG_MOVIE)
+ ?: throw IllegalArgumentException("Movie must be passed")
+
+ sharedElementEnterTransition =
+ TransitionInflater.from(context ?: return).inflateTransition(android.R.transition.move)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ viewModel.checkIsFavorite(movie)
+ bindViews(view)
+ listeners(view)
+ observers()
+ showData(movie)
+ }
+
+ private fun bindViews(view: View) {
+ backDropImageView = view.findViewById(R.id.imageview_backdrop)
+ posterImageView = view.findViewById(R.id.imageview_poster)
+ rateTextView = view.findViewById(R.id.tv_rate)
+ allVotesTextView = view.findViewById(R.id.tv_all_votes)
+ titleTextView = view.findViewById(R.id.tv_title)
+ overviewTextView = view.findViewById(R.id.tv_overview)
+ backButton = view.findViewById(R.id.btn_back)
+ favoriteButton = view.findViewById(R.id.btn_add_remove_favorite)
+ flowLayout = view.findViewById(R.id.flow_layout)
+ flowHelper = view.findViewById(R.id.flow)
+ releaseDateTextView = view.findViewById(R.id.tv_release_date)
+ btnReviews = view.findViewById(R.id.btn_reviews)
+ }
+
+ private fun listeners(view: View) {
+ backButton.setOnClickListener { onBackPressed() }
+ favoriteButton.setOnClickListener {
+ if (!viewModel.ifFavorite.value) {
+ viewModel.saveAsFavorite(movie)
+ Snackbar.make(view,
+ getString(R.string.message_add_to_favorite),
+ Snackbar.LENGTH_SHORT).show()
+ } else {
+ viewModel.deleteFavorite(movie)
+ Snackbar.make(view,
+ getString(R.string.message_removed_from_favorite),
+ Snackbar.LENGTH_SHORT).show()
+ }
+ }
+
+ btnReviews.setOnClickListener {
+ findNavController().navigate(
+ R.id.action_detailsFragment_to_reviewsFragment,
+ args = Bundle().apply {
+ putInt(ARG_MOVIE_ID, movie.id)
+ }
+ )
+ }
+ }
+
+ private fun observers() {
+ lifecycleScope.launchWhenStarted {
+ viewModel.ifFavorite.collectLatest { isFavorite ->
+ favoriteButton.setIconResource(if (isFavorite) R.drawable.ic_bookmark else R.drawable.ic_not_bookmark)
+ }
+ }
+ }
+
+ private fun showData(movie: Movie) {
+ with(movie) {
+ backDropImageView.load(backdropPath)
+ posterImageView.load(posterPath)
+ rateTextView.text = voteAverage.toString()
+ allVotesTextView.text = context?.getString(R.string.votes_place_holder, voteCount)
+ titleTextView.text = title
+ overviewTextView.text = overview
+ releaseDateTextView.text = dateFormat(releaseDate)
+
+ val listOfGenreViews = createGenresVIew()
+
+ flowHelper.referencedIds = IntArray(listOfGenreViews.size) {
+ listOfGenreViews[it].id
+ }
+
+ listOfGenreViews.forEach {
+ flowLayout.addView(it)
+ }
+ }
+ }
+
+ private fun Movie.createGenresVIew(): List {
+ val listOfGenreViews = mutableListOf()
+ this.genreNames.forEach { genre ->
+ val materialButton = MaterialButton(context ?: return emptyList(),
+ null,
+ android.R.style.Widget_Material_Button_Small).apply {
+ id = View.generateViewId()
+ text = genre
+ isAllCaps = false
+ strokeWidth = 1.dp
+ setStrokeColorResource(R.color.gray)
+ cornerRadius = 4.dp
+ insetBottom = 0
+ insetTop = 0
+ includeFontPadding = false
+ minHeight = 0
+ minWidth = 0
+ setPadding(8.dp, 4.dp, 8.dp, 4.dp)
+ setTextColor(ContextCompat.getColor(context ?: return emptyList(),
+ R.color.on_surface))
+ backgroundTintList = ColorStateList.valueOf(
+ ContextCompat.getColor(context ?: return emptyList(),
+ R.color.transparent)
+ )
+ }
+ listOfGenreViews.add(materialButton)
+ }
+ return listOfGenreViews
+ }
+
+
+ override fun onBackPressed() {
+ findNavController().popBackStack()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/details/DetailsViewModel.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/details/DetailsViewModel.kt
new file mode 100644
index 00000000..ed33c6f9
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/details/DetailsViewModel.kt
@@ -0,0 +1,44 @@
+package com.mousavi.hashem.mymoviesapp.presentaion.explore.details
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.mymoviesapp.domain.usecases.*
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+
+@HiltViewModel
+class DetailsViewModel @Inject constructor(
+ private val saveMovieToDatabaseUseCaseImpl: SaveFavoriteMovieToDatabaseUseCase,
+ private val deleteFavoriteMovieUseCaseImpl: DeleteFavoriteMovieUseCase,
+ private val checkIfFavoriteUseCaseImpl: CheckIfFavoriteUseCase,
+) : ViewModel() {
+
+ private var _ifFavorite = MutableStateFlow(false)
+ val ifFavorite = _ifFavorite.asStateFlow()
+
+ fun saveAsFavorite(movie: Movie) {
+ viewModelScope.launch(Dispatchers.IO) {
+ saveMovieToDatabaseUseCaseImpl.invoke(movie)
+ checkIsFavorite(movie)
+ }
+ }
+
+ fun deleteFavorite(movie: Movie) {
+ viewModelScope.launch(Dispatchers.IO) {
+ deleteFavoriteMovieUseCaseImpl.invoke(movie)
+ checkIsFavorite(movie)
+ }
+ }
+
+ fun checkIsFavorite(movie: Movie) {
+ viewModelScope.launch(Dispatchers.IO) {
+ _ifFavorite.value = checkIfFavoriteUseCaseImpl(movie)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/list/PopularMoviesAdapter.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/list/PopularMoviesAdapter.kt
new file mode 100644
index 00000000..65372b94
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/list/PopularMoviesAdapter.kt
@@ -0,0 +1,191 @@
+package com.mousavi.hashem.mymoviesapp.presentaion.explore.list
+
+import android.graphics.Rect
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import coil.load
+import com.google.android.material.button.MaterialButton
+import com.mousavi.hashem.mymoviesapp.R
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.util.dp
+
+
+class PopularMoviesAdapter(
+ private var onLoadMoreListener: (Int) -> Unit,
+ private val onItemClicked: (Movie, View) -> Unit,
+) : RecyclerView.Adapter() {
+
+ companion object {
+ private const val VIEW_TYPE_LOADING = 0
+ private const val VIEW_TYPE_DATA = 1
+ private const val VIEW_TYPE_ERROR = 2
+ }
+
+ private val items = mutableListOf()
+
+ var isLoading = false
+ set(value) {
+ field = value
+ notifyItemChanged(itemCount)
+ }
+
+ var isError = false
+ set(value) {
+ field = value
+ notifyItemChanged(itemCount)
+ }
+
+ var noMoreData = false
+ var currentPage = 0
+
+ private val itemDecoration = object : RecyclerView.ItemDecoration() {
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State,
+ ) {
+ super.getItemOffsets(outRect, view, parent, state)
+ val spanIndex = (view.layoutParams as GridLayoutManager.LayoutParams).spanIndex
+ val dp16 = 16.dp
+ if (spanIndex == 0) {
+ outRect.left = dp16
+ outRect.right = dp16 / 2
+ } else {
+ outRect.right = dp16
+ outRect.left = dp16 / 2
+ }
+ outRect.top = dp16 / 2
+ outRect.bottom = dp16 / 2
+
+ }
+ }
+
+ override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
+ super.onAttachedToRecyclerView(recyclerView)
+ recyclerView.addItemDecoration(itemDecoration)
+ (recyclerView.layoutManager as? GridLayoutManager)?.let { layoutManager ->
+ layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
+ override fun getSpanSize(position: Int): Int {
+ val itemViewType = getItemViewType(position)
+ if (itemViewType == VIEW_TYPE_LOADING || itemViewType == VIEW_TYPE_ERROR) return 2
+ return 1
+ }
+ }
+ }
+ }
+
+ override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
+ super.onDetachedFromRecyclerView(recyclerView)
+ recyclerView.removeItemDecoration(itemDecoration)
+ }
+
+ fun appendData(list: List, page: Int, totalPages: Int) {
+ if (page == -1) return
+ if (currentPage == page) return // if we go to details and back, the viewmodel give redundant data(last list)
+ currentPage = page
+ val currentItemsSize = items.size
+ if (list.isNotEmpty()) {
+ items.addAll(list)
+ val newItemsSize = items.size
+ notifyItemRangeChanged(currentItemsSize, newItemsSize - currentItemsSize)
+ }
+
+ if (page >= totalPages) {
+ noMoreData = true
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ if (viewType == VIEW_TYPE_LOADING) {
+ return LoadingViewHolder(inflater.inflate(R.layout.item_loading, parent, false))
+ }
+ if (viewType == VIEW_TYPE_ERROR) {
+ return ErrorViewHolder(inflater.inflate(R.layout.item_error, parent, false))
+ }
+
+ return MovieViewHolder(inflater.inflate(R.layout.item_movie, parent, false))
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+
+ if (holder is MovieViewHolder) {
+ holder.bind(position)
+ }
+ if (!noMoreData && !isError && !isLoading && position == itemCount - 1) {
+ onLoadMoreListener.invoke(currentPage + 1)
+ }
+ }
+
+
+ override fun getItemCount(): Int {
+ var dataCount = items.size
+ if (isLoading) {
+ dataCount++
+ }
+ if (isError) {//isError and isLoading never be true at the same time
+ dataCount++
+ }
+ return dataCount
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ val dataCount = items.size
+
+ return if (isLoading) {
+ if (position < dataCount) {
+ VIEW_TYPE_DATA
+ } else {
+ VIEW_TYPE_LOADING
+ }
+ } else if (isError) {
+ if (position < dataCount) {
+ VIEW_TYPE_DATA
+ } else {
+ VIEW_TYPE_ERROR
+ }
+ } else {
+ VIEW_TYPE_DATA
+ }
+ }
+
+ inner class MovieViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ private val titleTextView: TextView = itemView.findViewById(R.id.tv_title)
+ private val posterImageView: ImageView = itemView.findViewById(R.id.iv_poster)
+ private val rateTextView: TextView = itemView.findViewById(R.id.tv_rate)
+
+ fun bind(position: Int) {
+ val movie = items[position]
+ titleTextView.text = movie.title
+ posterImageView.load(movie.posterPath)
+ rateTextView.text = movie.voteAverage.toString()
+
+ itemView.setOnClickListener {
+ val pos = adapterPosition
+ if (pos != RecyclerView.NO_POSITION) {
+ onItemClicked(items[pos], posterImageView)
+ }
+ }
+ }
+ }
+
+ class LoadingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ internal val loading: View = itemView.findViewById(R.id.loading)
+ }
+
+ inner class ErrorViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ private val errorButton: MaterialButton = itemView.findViewById(R.id.btn_Error)
+
+ init {
+ errorButton.setOnClickListener {
+ onLoadMoreListener(currentPage + 1)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/list/PopularMoviesFragment.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/list/PopularMoviesFragment.kt
new file mode 100644
index 00000000..91a575bf
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/list/PopularMoviesFragment.kt
@@ -0,0 +1,93 @@
+package com.mousavi.hashem.mymoviesapp.presentaion.explore.list
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.FragmentNavigatorExtras
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.RecyclerView
+import com.mousavi.hashem.mymoviesapp.R
+import com.mousavi.hashem.mymoviesapp.presentaion.BaseFragment
+import com.mousavi.hashem.mymoviesapp.presentaion.explore.details.DetailsFragment
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.collectLatest
+
+@AndroidEntryPoint
+class PopularMoviesFragment : BaseFragment(R.layout.fragment_popular_movies) {
+
+ private val viewModel: PopularMoviesViewModel by viewModels()
+
+ private lateinit var recyclerView: RecyclerView
+
+ private val adapter = PopularMoviesAdapter(
+ onLoadMoreListener = { newPage ->
+ viewModel.getPopularMovies(
+ page = newPage
+ )
+ },
+ onItemClicked = { movie, view ->
+ view.transitionName =
+ context?.getString(R.string.transition_name_poster) ?: return@PopularMoviesAdapter
+ val extras =
+ FragmentNavigatorExtras(
+ view to context!!.getString(R.string.transition_name_poster)
+ )
+
+ findNavController().navigate(
+ resId = R.id.action_popularMoviesFragment_to_detailsFragment,
+ args = Bundle().apply {
+ putParcelable(DetailsFragment.ARG_MOVIE, movie)
+ },
+ navOptions = null,
+ navigatorExtras = extras
+ )
+ }
+ )
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ viewModel.getPopularMovies()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ bindView(view)
+ observers()
+ recycler()
+ }
+
+ private fun bindView(view: View) {
+ recyclerView = view.findViewById(R.id.recycler_view)
+ }
+
+ private fun recycler() {
+ recyclerView.adapter = adapter
+ }
+
+ private fun observers() {
+ lifecycleScope.launchWhenStarted {
+ viewModel.popularMovies.collectLatest { pageData ->
+ adapter.appendData(pageData.movies, pageData.page, pageData.totalPages)
+ }
+ }
+
+ lifecycleScope.launchWhenStarted {
+ viewModel.popularMoviesError.collectLatest {
+ adapter.isError = it
+ }
+ }
+
+ lifecycleScope.launchWhenStarted {
+ viewModel.popularMoviesLoading.collectLatest {
+ adapter.isLoading = it
+ }
+ }
+ }
+
+ override fun onBackPressed() {
+ super.onBackPressed()
+ activity?.finish()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/list/PopularMoviesViewModel.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/list/PopularMoviesViewModel.kt
new file mode 100644
index 00000000..dd09c4c8
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/explore/list/PopularMoviesViewModel.kt
@@ -0,0 +1,52 @@
+package com.mousavi.hashem.mymoviesapp.presentaion.explore.list
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.mousavi.hashem.common.Either
+import com.mousavi.hashem.mymoviesapp.domain.model.PageData
+import com.mousavi.hashem.mymoviesapp.domain.usecases.GetPopularMoviesUseCase
+import com.mousavi.hashem.mymoviesapp.domain.usecases.GetPopularMoviesUseCaseImpl
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class PopularMoviesViewModel @Inject constructor(
+ private val getPopularMoviesUseCaseImplUseCase: GetPopularMoviesUseCase,
+) : ViewModel() {
+
+ private val _popularMovies = MutableStateFlow(PageData())
+ val popularMovies = _popularMovies.asStateFlow()
+
+ private val _popularMoviesError = MutableStateFlow(false)
+ val popularMoviesError = _popularMoviesError.asStateFlow()
+
+ private val _popularMoviesLoading = MutableStateFlow(false)
+ val popularMoviesLoading = _popularMoviesLoading.asStateFlow()
+
+ fun getPopularMovies(
+ language: String = "en-US",
+ page: Int = 1,
+ ) {
+ viewModelScope.launch(Dispatchers.IO) {
+ _popularMoviesError.emit(false)
+ _popularMoviesLoading.emit(true)
+ when (val result = getPopularMoviesUseCaseImplUseCase(language, page)) {
+ is Either.Success -> {
+ _popularMoviesLoading.emit(false)
+ _popularMovies.value = result.data
+ }
+ is Either.Error -> {
+ _popularMoviesLoading.emit(false)
+ _popularMoviesError.emit(true)
+ }
+ }
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/favorite/FavoriteMoviesAdapter.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/favorite/FavoriteMoviesAdapter.kt
new file mode 100644
index 00000000..f7f019d1
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/favorite/FavoriteMoviesAdapter.kt
@@ -0,0 +1,96 @@
+package com.mousavi.hashem.mymoviesapp.presentaion.favorite
+
+import android.annotation.SuppressLint
+import android.graphics.Rect
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import coil.load
+import com.mousavi.hashem.mymoviesapp.R
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.util.dp
+
+
+class FavoriteMoviesAdapter(
+ private val onItemClicked: (Movie, View) -> Unit,
+) : RecyclerView.Adapter() {
+
+ var items: List = emptyList()
+ @SuppressLint("NotifyDataSetChanged")
+ set(value) {
+ field = value
+ notifyDataSetChanged()
+ }
+
+ private val itemDecoration = object : RecyclerView.ItemDecoration() {
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State,
+ ) {
+ super.getItemOffsets(outRect, view, parent, state)
+ val spanIndex = (view.layoutParams as GridLayoutManager.LayoutParams).spanIndex
+ val dp16 = 16.dp
+ if (spanIndex == 0) {
+ outRect.left = dp16
+ outRect.right = dp16 / 2
+ } else {
+ outRect.right = dp16
+ outRect.left = dp16 / 2
+ }
+ outRect.top = dp16 / 2
+ outRect.bottom = dp16 / 2
+
+ }
+ }
+
+ override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
+ super.onAttachedToRecyclerView(recyclerView)
+ recyclerView.addItemDecoration(itemDecoration)
+ }
+
+ override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
+ super.onDetachedFromRecyclerView(recyclerView)
+ recyclerView.removeItemDecoration(itemDecoration)
+ }
+
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MovieViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ return MovieViewHolder(inflater.inflate(R.layout.item_movie, parent, false))
+ }
+
+ override fun onBindViewHolder(holder: MovieViewHolder, position: Int) {
+ holder.bind(position)
+ }
+
+
+ override fun getItemCount() = items.size
+
+ inner class MovieViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ private val titleTextView: TextView = itemView.findViewById(R.id.tv_title)
+ private val posterImageView: ImageView = itemView.findViewById(R.id.iv_poster)
+ private val rateTextView: TextView = itemView.findViewById(R.id.tv_rate)
+
+ fun bind(position: Int) {
+ val movie = items[position]
+ titleTextView.text = movie.title
+ posterImageView.load(movie.posterPath)
+ rateTextView.text = movie.voteAverage.toString()
+
+ itemView.setOnClickListener {
+ val pos = adapterPosition
+ if (pos != RecyclerView.NO_POSITION) {
+ onItemClicked(items[pos], posterImageView)
+ }
+ }
+ }
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/favorite/FavoritesFragment.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/favorite/FavoritesFragment.kt
new file mode 100644
index 00000000..591ac3ef
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/favorite/FavoritesFragment.kt
@@ -0,0 +1,76 @@
+package com.mousavi.hashem.mymoviesapp.presentaion.favorite
+
+import android.os.Bundle
+import android.view.View
+import android.widget.TextView
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.FragmentNavigatorExtras
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.RecyclerView
+import com.mousavi.hashem.mymoviesapp.R
+import com.mousavi.hashem.mymoviesapp.presentaion.BaseFragment
+import com.mousavi.hashem.mymoviesapp.presentaion.MainActivity
+import com.mousavi.hashem.mymoviesapp.presentaion.explore.details.DetailsFragment
+import com.mousavi.hashem.util.showGone
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.collectLatest
+
+@AndroidEntryPoint
+class FavoritesFragment : BaseFragment(R.layout.fragment_favorites) {
+
+ private val viewModel: FavoritesViewModel by viewModels()
+
+ private lateinit var recyclerView: RecyclerView
+ private lateinit var emptyStateTextView: TextView
+
+ private val adapter = FavoriteMoviesAdapter(
+ onItemClicked = { movie, view ->
+ view.transitionName =
+ context?.getString(R.string.transition_name_poster) ?: return@FavoriteMoviesAdapter
+ val extras =
+ FragmentNavigatorExtras(
+ view to context!!.getString(R.string.transition_name_poster)
+ )
+
+ findNavController().navigate(
+ resId = R.id.action_favoritesFragment_to_detailsFragment,
+ args = Bundle().apply {
+ putParcelable(DetailsFragment.ARG_MOVIE, movie)
+ },
+ navOptions = null,
+ navigatorExtras = extras
+ )
+ }
+ )
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ bindViews(view)
+ recycler()
+ observers()
+ }
+
+ private fun observers() {
+ lifecycleScope.launchWhenStarted {
+ viewModel.favorites.collectLatest {
+ adapter.items = it
+ emptyStateTextView.showGone(it.isNullOrEmpty())
+ }
+ }
+ }
+
+ private fun bindViews(view: View) {
+ recyclerView = view.findViewById(R.id.recycler_view)
+ emptyStateTextView = view.findViewById(R.id.tv_empty_state)
+ }
+
+ private fun recycler() {
+ recyclerView.adapter = adapter
+ }
+
+ override fun onBackPressed() {
+ (activity as? MainActivity)?.selectExplorePage()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/favorite/FavoritesViewModel.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/favorite/FavoritesViewModel.kt
new file mode 100644
index 00000000..5fdd67d4
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/favorite/FavoritesViewModel.kt
@@ -0,0 +1,37 @@
+package com.mousavi.hashem.mymoviesapp.presentaion.favorite
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.mymoviesapp.domain.usecases.GetFavoriteMoviesFromDatabaseUseCase
+import com.mousavi.hashem.mymoviesapp.domain.usecases.GetFavoriteMoviesFromDatabaseUseCaseImpl
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class FavoritesViewModel @Inject constructor(
+ private val useCase: GetFavoriteMoviesFromDatabaseUseCase,
+) : ViewModel() {
+
+ private var _favorites = MutableStateFlow>(emptyList())
+ val favorites = _favorites.asStateFlow()
+
+ init {
+ getFavorites()
+ }
+
+ private fun getFavorites() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val result = useCase()
+ result.onEach {
+ _favorites.value = it
+ }.launchIn(this)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/reviews/ReviewsAdapter.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/reviews/ReviewsAdapter.kt
new file mode 100644
index 00000000..1b771027
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/reviews/ReviewsAdapter.kt
@@ -0,0 +1,180 @@
+package com.mousavi.hashem.mymoviesapp.presentaion.reviews
+
+import android.annotation.SuppressLint
+import android.graphics.Rect
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import coil.load
+import com.google.android.material.button.MaterialButton
+import com.mousavi.hashem.mymoviesapp.R
+import com.mousavi.hashem.mymoviesapp.domain.model.Review
+import com.mousavi.hashem.mymoviesapp.presentaion.explore.list.PopularMoviesAdapter
+import com.mousavi.hashem.util.dateFormat
+import com.mousavi.hashem.util.gone
+import com.mousavi.hashem.util.show
+import com.mousavi.hashem.util.showHide
+
+
+class ReviewsAdapter(
+ private var onLoadMoreListener: (Int) -> Unit,
+ private var showEmptyState: () -> Unit,
+) : RecyclerView.Adapter() {
+
+ companion object {
+ private const val VIEW_TYPE_LOADING = 0
+ private const val VIEW_TYPE_DATA = 1
+ private const val VIEW_TYPE_ERROR = 2
+ }
+
+ private val items = mutableListOf()
+
+ var isLoading = false
+ set(value) {
+ field = value
+ notifyItemChanged(itemCount)
+ }
+
+ var isError = false
+ set(value) {
+ field = value
+ notifyItemChanged(itemCount)
+ }
+
+ var noMoreData = false
+ var currentPage = 0
+
+ fun appendData(list: List, page: Int, totalPages: Int) {
+ if (page == -1) return
+ if (currentPage == page) return
+ currentPage = page
+ val currentItemsSize = items.size
+ if (list.isNotEmpty()) {
+ items.addAll(list)
+ val newItemsSize = items.size
+ notifyItemRangeChanged(currentItemsSize, newItemsSize - currentItemsSize)
+ }
+ if (page >= totalPages) {
+ noMoreData = true
+ }
+ if (items.isEmpty() && page != -1) {
+ showEmptyState()
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ if (viewType == VIEW_TYPE_LOADING) {
+ return LoadingViewHolder(inflater.inflate(R.layout.item_loading, parent, false))
+ }
+
+ if (viewType == VIEW_TYPE_ERROR) {
+ return ErrorViewHolder(inflater.inflate(R.layout.item_error, parent, false))
+ }
+
+ return ReviewViewHolder(inflater.inflate(R.layout.item_review, parent, false))
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+
+ if (holder is ReviewViewHolder) {
+ holder.bind(position)
+ }
+ if (!noMoreData && !isError && !isLoading && position == itemCount - 1) {
+ onLoadMoreListener.invoke(currentPage + 1)
+ }
+ }
+
+
+ override fun getItemCount(): Int {
+ var dataCount = items.size
+ if (isLoading) {
+ dataCount++
+ }
+ if(isError){//isError and isLoading never be true at the same time
+ dataCount++
+ }
+ return dataCount
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ val dataCount = items.size
+
+ return if (isLoading) {
+ if (position < dataCount) {
+ VIEW_TYPE_DATA
+ } else {
+ VIEW_TYPE_LOADING
+ }
+ } else if (isError) {
+ if (position < dataCount) {
+ VIEW_TYPE_DATA
+ } else {
+ VIEW_TYPE_ERROR
+ }
+ } else {
+ VIEW_TYPE_DATA
+ }
+ }
+
+ inner class ReviewViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ private val nameTextView: TextView = itemView.findViewById(R.id.tv_name)
+ private val imageViewAvatar: ImageView = itemView.findViewById(R.id.iv_avatar)
+ private val rateTextView: TextView = itemView.findViewById(R.id.tv_rate)
+ private val dateTextView: TextView = itemView.findViewById(R.id.tv_date)
+ private val contentTextView: TextView = itemView.findViewById(R.id.tv_content)
+ private val readMoreButton: MaterialButton = itemView.findViewById(R.id.btn_read_more)
+
+ @SuppressLint("SetTextI18n")
+ fun bind(position: Int) {
+ with(items[position]) {
+ nameTextView.text = authorDetails.name ?: ""
+ imageViewAvatar.load(authorDetails.avatarPath) {
+ placeholder(R.drawable.avatar_place_holder)
+ error(R.drawable.avatar_place_holder)
+ }
+ rateTextView.text = authorDetails.rating.toString() + "/10"
+ rateTextView.showHide(authorDetails.rating != null)
+ dateTextView.text = dateFormat(createdAt?.substringBefore("T"))
+ contentTextView.text = content
+ contentTextView.maxLines = 4
+
+ contentTextView.post {
+ val lines = contentTextView.lineCount
+ if (lines >= 1) {
+ val ellipsisCount = contentTextView.layout.getEllipsisCount(lines - 1)
+ if (ellipsisCount > 0) {
+ readMoreButton.show()
+ readMoreButton.setOnClickListener {
+ contentTextView.maxLines = 1000
+ it.gone()
+ }
+ } else {
+ readMoreButton.gone()
+ }
+ }
+
+ }
+
+ }
+ }
+ }
+
+ class LoadingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ internal val loading: View = itemView.findViewById(R.id.loading)
+ }
+
+ inner class ErrorViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ private val errorButton: MaterialButton = itemView.findViewById(R.id.btn_Error)
+
+ init {
+ errorButton.setOnClickListener {
+ onLoadMoreListener(currentPage + 1)
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/reviews/ReviewsFragment.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/reviews/ReviewsFragment.kt
new file mode 100644
index 00000000..c6543504
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/reviews/ReviewsFragment.kt
@@ -0,0 +1,100 @@
+package com.mousavi.hashem.mymoviesapp.presentaion.reviews
+
+import android.os.Bundle
+import android.view.TextureView
+import android.view.View
+import android.widget.TextView
+import androidx.appcompat.widget.Toolbar
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.RecyclerView
+import com.mousavi.hashem.mymoviesapp.R
+import com.mousavi.hashem.mymoviesapp.presentaion.BaseFragment
+import com.mousavi.hashem.util.show
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.collectLatest
+
+@AndroidEntryPoint
+class ReviewsFragment : BaseFragment(R.layout.fragment_reviews) {
+
+ companion object {
+ const val ARG_MOVIE_ID = "arg_movie_id"
+ }
+
+ private val viewModel: ReviewsViewModel by viewModels()
+
+ private lateinit var toolbar: Toolbar
+ private lateinit var recyclerView: RecyclerView
+ private lateinit var emptyStateTextView: TextView
+
+ private val adapter = ReviewsAdapter(
+ onLoadMoreListener = { newPage ->
+ viewModel.getReviews(movieId, page = newPage)
+ },
+ showEmptyState = {
+ emptyStateTextView.show()
+ }
+ )
+
+ private var movieId: Int = 0
+
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ movieId = arguments?.getInt(ARG_MOVIE_ID)
+ ?: throw IllegalArgumentException("Movie Id must be passed")
+
+ viewModel.getReviews(movieId)
+ }
+
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ bindViews(view)
+ listeners()
+ observers()
+ recycler()
+ }
+
+ private fun observers() {
+ lifecycleScope.launchWhenStarted {
+ viewModel.reviews.collectLatest {
+ adapter.appendData(it.reviews, it.page, it.totalPages)
+ }
+ }
+
+ lifecycleScope.launchWhenStarted {
+ viewModel.loading.collectLatest {
+ adapter.isLoading = it
+ }
+ }
+
+ lifecycleScope.launchWhenStarted {
+ viewModel.error.collectLatest {
+ adapter.isError = it
+ }
+ }
+ }
+
+ private fun bindViews(view: View) {
+ toolbar = view.findViewById(R.id.toolbar)
+ recyclerView = view.findViewById(R.id.recycler_view)
+ emptyStateTextView = view.findViewById(R.id.tv_empty_state)
+ }
+
+ private fun listeners() {
+ toolbar.setNavigationOnClickListener {
+ onBackPressed()
+ }
+ }
+
+ private fun recycler() {
+ recyclerView.adapter = adapter
+ }
+
+ override fun onBackPressed() {
+ findNavController().popBackStack()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/reviews/ReviewsViewModel.kt b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/reviews/ReviewsViewModel.kt
new file mode 100644
index 00000000..0c06c9f5
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/mymoviesapp/presentaion/reviews/ReviewsViewModel.kt
@@ -0,0 +1,52 @@
+package com.mousavi.hashem.mymoviesapp.presentaion.reviews
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.mousavi.hashem.common.Either
+import com.mousavi.hashem.mymoviesapp.domain.model.Reviews
+import com.mousavi.hashem.mymoviesapp.domain.usecases.GetReviewsUseCase
+import com.mousavi.hashem.mymoviesapp.domain.usecases.GetReviewsUseCaseImpl
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ReviewsViewModel @Inject constructor(
+ private val useCase: GetReviewsUseCase,
+) : ViewModel() {
+
+ private val _reviews = MutableStateFlow(Reviews())
+ val reviews = _reviews.asStateFlow()
+
+ private val _loading = MutableStateFlow(false)
+ val loading = _loading.asStateFlow()
+
+ private val _error = MutableStateFlow(false)
+ val error = _error.asStateFlow()
+
+
+ fun getReviews(
+ movieId: Int,
+ language: String = "en-US",
+ page: Int = 1,
+ ) {
+ viewModelScope.launch(Dispatchers.IO) {
+ _error.emit(false)
+ _loading.emit(true)
+ when (val result = useCase(movieId, language, page)) {
+ is Either.Success -> {
+ _loading.emit(false)
+ _reviews.value = result.data
+ }
+ is Either.Error -> {
+ _loading.emit(false)
+ _error.emit(true)
+ }
+ }
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/util/DateUtil.kt b/app/src/main/java/com/mousavi/hashem/util/DateUtil.kt
new file mode 100644
index 00000000..059fb544
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/util/DateUtil.kt
@@ -0,0 +1,21 @@
+package com.mousavi.hashem.util
+
+import java.text.SimpleDateFormat
+import java.util.*
+
+private val calendar = Calendar.getInstance()
+fun dateFormat(dateString: String?): String {
+ if (dateString == null) return ""
+ if (!dateString.contains("-")) return ""
+
+ val arr = dateString.split("-")
+ val year = arr[0].toIntOrNull() ?: return ""
+ val month = arr[1].toIntOrNull() ?: return ""
+ val day = arr[2].toIntOrNull() ?: return ""
+ calendar.set(Calendar.YEAR, year)
+ calendar.set(Calendar.MONTH, month - 1)//in java month is zero index but api get Jan as 1
+ calendar.set(Calendar.DAY_OF_MONTH, day)
+ val date = Date(calendar.timeInMillis)
+ val dateFormatter = SimpleDateFormat("yyyy MMM d", Locale.US)
+ return dateFormatter.format(date) ?: ""
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mousavi/hashem/util/Extensions.kt b/app/src/main/java/com/mousavi/hashem/util/Extensions.kt
new file mode 100644
index 00000000..3d79031b
--- /dev/null
+++ b/app/src/main/java/com/mousavi/hashem/util/Extensions.kt
@@ -0,0 +1,57 @@
+package com.mousavi.hashem.util
+
+import android.content.res.Resources
+import android.view.View
+import android.view.ViewTreeObserver
+import kotlin.math.roundToInt
+
+
+val Int.dp: Int get() = (this * Resources.getSystem().displayMetrics.density).roundToInt()
+
+val Int.dpF: Float get() = (this * Resources.getSystem().displayMetrics.density)
+
+fun View?.show() {
+ this?.visibility = View.VISIBLE
+}
+
+fun View?.gone() {
+ this?.visibility = View.GONE
+}
+
+fun View?.hide() {
+ this?.visibility = View.INVISIBLE
+}
+
+fun View?.showGone(show: Boolean?) {
+ if (show == null) return
+
+ if (show) {
+ this?.show()
+ } else {
+ this?.gone()
+ }
+}
+
+fun View?.showHide(show: Boolean?) {
+ if (show == null) return
+
+ if (show) {
+ this?.show()
+ } else {
+ this?.hide()
+ }
+}
+
+inline fun T.afterMeasured(crossinline f: T.() -> Unit) {
+ viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
+ override fun onGlobalLayout() {
+ if (!viewTreeObserver.isAlive) {
+ return
+ }
+ if (measuredWidth > 0 && measuredHeight > 0) {
+ viewTreeObserver.removeOnGlobalLayoutListener(this)
+ f()
+ }
+ }
+ })
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 00000000..2b068d11
--- /dev/null
+++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/avatar_place_holder.png b/app/src/main/res/drawable/avatar_place_holder.png
new file mode 100644
index 00000000..c4b46c80
Binary files /dev/null and b/app/src/main/res/drawable/avatar_place_holder.png differ
diff --git a/app/src/main/res/drawable/backdrop.jpg b/app/src/main/res/drawable/backdrop.jpg
new file mode 100644
index 00000000..3655c6dc
Binary files /dev/null and b/app/src/main/res/drawable/backdrop.jpg differ
diff --git a/app/src/main/res/drawable/backdrop_gradient.xml b/app/src/main/res/drawable/backdrop_gradient.xml
new file mode 100644
index 00000000..a72e0126
--- /dev/null
+++ b/app/src/main/res/drawable/backdrop_gradient.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml
new file mode 100644
index 00000000..8c7f9efb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_back.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_bookmark.xml b/app/src/main/res/drawable/ic_bookmark.xml
new file mode 100644
index 00000000..c83f33cd
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bookmark.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_iback.xml b/app/src/main/res/drawable/ic_iback.xml
new file mode 100644
index 00000000..b9f2f171
--- /dev/null
+++ b/app/src/main/res/drawable/ic_iback.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..07d5da9c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_movie.xml b/app/src/main/res/drawable/ic_movie.xml
new file mode 100644
index 00000000..27c315eb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_movie.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_not_bookmark.xml b/app/src/main/res/drawable/ic_not_bookmark.xml
new file mode 100644
index 00000000..5118fb58
--- /dev/null
+++ b/app/src/main/res/drawable/ic_not_bookmark.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_star.xml b/app/src/main/res/drawable/ic_star.xml
new file mode 100644
index 00000000..5d010a04
--- /dev/null
+++ b/app/src/main/res/drawable/ic_star.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/poster.jpg b/app/src/main/res/drawable/poster.jpg
new file mode 100644
index 00000000..1f30a92a
Binary files /dev/null and b/app/src/main/res/drawable/poster.jpg differ
diff --git a/app/src/main/res/drawable/rate_background.xml b/app/src/main/res/drawable/rate_background.xml
new file mode 100644
index 00000000..c2e79a06
--- /dev/null
+++ b/app/src/main/res/drawable/rate_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 00000000..a35e202c
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_details.xml b/app/src/main/res/layout/fragment_details.xml
new file mode 100644
index 00000000..e412b3aa
--- /dev/null
+++ b/app/src/main/res/layout/fragment_details.xml
@@ -0,0 +1,256 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_favorites.xml b/app/src/main/res/layout/fragment_favorites.xml
new file mode 100644
index 00000000..f397f54f
--- /dev/null
+++ b/app/src/main/res/layout/fragment_favorites.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_popular_movies.xml b/app/src/main/res/layout/fragment_popular_movies.xml
new file mode 100644
index 00000000..15eabe5a
--- /dev/null
+++ b/app/src/main/res/layout/fragment_popular_movies.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_reviews.xml b/app/src/main/res/layout/fragment_reviews.xml
new file mode 100644
index 00000000..9e55fea4
--- /dev/null
+++ b/app/src/main/res/layout/fragment_reviews.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_error.xml b/app/src/main/res/layout/item_error.xml
new file mode 100644
index 00000000..dda703db
--- /dev/null
+++ b/app/src/main/res/layout/item_error.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_loading.xml b/app/src/main/res/layout/item_loading.xml
new file mode 100644
index 00000000..86b6a633
--- /dev/null
+++ b/app/src/main/res/layout/item_loading.xml
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_movie.xml b/app/src/main/res/layout/item_movie.xml
new file mode 100644
index 00000000..5bd40fb7
--- /dev/null
+++ b/app/src/main/res/layout/item_movie.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_review.xml b/app/src/main/res/layout/item_review.xml
new file mode 100644
index 00000000..afaf56a3
--- /dev/null
+++ b/app/src/main/res/layout/item_review.xml
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/bottom_bar_menu.xml b/app/src/main/res/menu/bottom_bar_menu.xml
new file mode 100644
index 00000000..0be9356c
--- /dev/null
+++ b/app/src/main/res/menu/bottom_bar_menu.xml
@@ -0,0 +1,11 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..eca70cfe
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..eca70cfe
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 00000000..c209e78e
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..b2dfe3d1
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 00000000..4f0f1d64
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..62b611da
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 00000000..948a3070
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..1b9a6956
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..28d4b77f
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9287f508
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..aa7d6427
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9126ae37
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/navigation/favorites_nav_graph.xml b/app/src/main/res/navigation/favorites_nav_graph.xml
new file mode 100644
index 00000000..a359c5c2
--- /dev/null
+++ b/app/src/main/res/navigation/favorites_nav_graph.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/main_nav_graph.xml b/app/src/main/res/navigation/main_nav_graph.xml
new file mode 100644
index 00000000..8e1aeeda
--- /dev/null
+++ b/app/src/main/res/navigation/main_nav_graph.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/movies_nav_graph.xml b/app/src/main/res/navigation/movies_nav_graph.xml
new file mode 100644
index 00000000..f37909c9
--- /dev/null
+++ b/app/src/main/res/navigation/movies_nav_graph.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..5c2bcf1f
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,18 @@
+
+
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+ #646464
+
+ #801B1C18
+ #303329
+ #CCCCB8
+ #1B1C18
+ #E6E6DC
+ #9FD74B
+ #000000
+ #00000000
+ #C5C8B9
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..b51e8c2f
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,23 @@
+
+ MyMoviesApp
+ Explore
+ Favorites
+ Top Movies
+ Poster Image
+ backdrop image
+ poster
+ From %1$d Votes
+ Favorites
+ You have no favorite movie!
+ Release Date
+ Added to favorites
+ Removed from favorites
+ User Reviews
+ Reviews
+ No reviews for this movie
+ Error occurred
+ Check your internet connection!
+ Unknown error!
+ Retry
+ Read More
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..5d8a22a6
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,16 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/com/mousavi/hashem/mymoviesapp/ExampleUnitTest.kt b/app/src/test/java/com/mousavi/hashem/mymoviesapp/ExampleUnitTest.kt
new file mode 100644
index 00000000..0b358f28
--- /dev/null
+++ b/app/src/test/java/com/mousavi/hashem/mymoviesapp/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.mousavi.hashem.mymoviesapp
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/mousavi/hashem/mymoviesapp/data/remote/NetworkDataSourceImplTest.kt b/app/src/test/java/com/mousavi/hashem/mymoviesapp/data/remote/NetworkDataSourceImplTest.kt
new file mode 100644
index 00000000..197dcb28
--- /dev/null
+++ b/app/src/test/java/com/mousavi/hashem/mymoviesapp/data/remote/NetworkDataSourceImplTest.kt
@@ -0,0 +1,55 @@
+package com.mousavi.hashem.mymoviesapp.data.remote
+
+import com.google.common.truth.Truth.assertThat
+import com.mousavi.hashem.common.Either
+import com.mousavi.hashem.mymoviesapp.R
+import com.mousavi.hashem.mymoviesapp.data.remote.dto.PageDataDto
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mockito
+import org.mockito.Mockito.`when`
+import retrofit2.HttpException
+
+class NetworkDataSourceImplTest {
+
+ private lateinit var api: Api
+ private lateinit var networkDataSource: NetworkDataSource
+ private lateinit var stringProvider: StringProvider
+
+ @Before
+ fun setUp() {
+ api = Mockito.mock(Api::class.java)
+ stringProvider = Mockito.mock(StringProvider::class.java)
+ networkDataSource = NetworkDataSourceImpl(api, stringProvider)
+ }
+
+ @Test
+ fun `test http exception in get popular movies`(): Unit = runBlocking {
+ val exception = Mockito.mock(HttpException::class.java)
+ `when`(exception.message).thenReturn("Http test message")
+ `when`(api.getPopularMovies(language = "en-US", page = 1))
+ .thenThrow(exception)
+
+ val popularMovies: Either =
+ networkDataSource.getPopularMovies(language = "en-US", page = 1)
+ assertThat(popularMovies).isInstanceOf(Either.Error::class.java)
+ val error = popularMovies as Either.Error
+ assertThat(error.error).isEqualTo("Http test message")
+ }
+
+ @Test
+ fun `test exception in get popular movies when http message is null`(): Unit = runBlocking {
+ val exception = Mockito.mock(HttpException::class.java)
+ `when`(exception.message).thenReturn(null)
+ `when`(api.getPopularMovies(language = "en-US", page = 1))
+ .thenThrow(exception)
+
+ `when`(stringProvider.getString(R.string.error_occurred)).thenReturn("Error occurred")
+ val popularMovies: Either =
+ networkDataSource.getPopularMovies(language = "en-US", page = 1)
+ assertThat(popularMovies).isInstanceOf(Either.Error::class.java)
+ val error = popularMovies as Either.Error
+ assertThat(error.error).isEqualTo("Error occurred")
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/mousavi/hashem/mymoviesapp/data/repository/MoviesRepositoryImplTest.kt b/app/src/test/java/com/mousavi/hashem/mymoviesapp/data/repository/MoviesRepositoryImplTest.kt
new file mode 100644
index 00000000..0959b0b4
--- /dev/null
+++ b/app/src/test/java/com/mousavi/hashem/mymoviesapp/data/repository/MoviesRepositoryImplTest.kt
@@ -0,0 +1,83 @@
+package com.mousavi.hashem.mymoviesapp.data.repository
+
+
+import com.google.common.truth.Truth.assertThat
+import com.mousavi.hashem.common.Either
+import com.mousavi.hashem.mymoviesapp.data.local.MovieDao
+import com.mousavi.hashem.mymoviesapp.data.remote.Api
+import com.mousavi.hashem.mymoviesapp.data.remote.NetworkDataSource
+import com.mousavi.hashem.mymoviesapp.data.remote.dto.MovieDto
+import com.mousavi.hashem.mymoviesapp.data.remote.dto.PageDataDto
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.mymoviesapp.domain.model.PageData
+import com.mousavi.hashem.mymoviesapp.domain.repository.MoviesRepository
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mockito
+import org.mockito.Mockito.`when`
+
+class MoviesRepositoryImplTest {
+
+ private lateinit var networkDataSource: NetworkDataSource
+ private lateinit var dao: MovieDao
+ private lateinit var repository: MoviesRepository
+
+
+ @Before
+ fun setUp() {
+ networkDataSource = Mockito.mock(NetworkDataSource::class.java)
+ dao = Mockito.mock(MovieDao::class.java)
+ repository = MoviesRepositoryImpl(networkDataSource, dao)
+ }
+
+ @Test
+ fun `test get popular movies success`(): Unit = runBlocking {
+ `when`(networkDataSource.getPopularMovies("en-US", 1))
+ .thenReturn(Either.Success(PageDataDto(page = 1,
+ getMovieDtoList(),
+ totalResults = 100,
+ totalPages = 3)))
+
+ val popularMovies = repository.getPopularMovies("en-US", 1)
+ assertThat(popularMovies).isInstanceOf(Either.Success::class.java)
+ val success: Either.Success = popularMovies as Either.Success
+ val pageData: PageData = success.data
+ assertThat(pageData.movies).hasSize(1)
+ val movie = Movie(
+ backdropPath = Api.IMAGE_BASE_URL + "backdropPath",
+ genreIds = listOf(1, 2),
+ id = 20,
+ overview = "fun movie",
+ posterPath = Api.IMAGE_BASE_URL + "posterPath",
+ releaseDate = "2020-02-14",
+ title = "Title",
+ voteAverage = 5.5,
+ voteCount = 1001
+ )
+
+ assertThat(pageData.movies[0]).isEqualTo(movie)
+ }
+
+ private fun getMovieDtoList(): List {
+ return listOf(
+ MovieDto(
+ adult = false,
+ backdropPath = "backdropPath",
+ genreIds = listOf(1, 2),
+ id = 20,
+ originalLanguage = "en",
+ originalTitle = "Test Movie",
+ overview = "fun movie",
+ popularity = 2.4,
+ posterPath = "posterPath",
+ releaseDate = "2020-02-14",
+ title = "Title",
+ video = false,
+ voteAverage = 5.5,
+ voteCount = 1001
+ )
+ )
+ }
+}
+
diff --git a/app/src/test/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetGenresUseCaseImplTest.kt b/app/src/test/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetGenresUseCaseImplTest.kt
new file mode 100644
index 00000000..835657de
--- /dev/null
+++ b/app/src/test/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetGenresUseCaseImplTest.kt
@@ -0,0 +1,29 @@
+package com.mousavi.hashem.mymoviesapp.domain.usecases
+
+
+import com.mousavi.hashem.mymoviesapp.domain.repository.MoviesRepository
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mockito
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+
+class GetGenresUseCaseImplTest {
+
+ private lateinit var getGenres: GetGenresUseCase
+ private lateinit var moviesRepository: MoviesRepository
+
+ @Before
+ fun setUp() {
+ moviesRepository = Mockito.mock(MoviesRepository::class.java)
+ getGenres = GetGenresUseCaseImpl(moviesRepository)
+ }
+
+ @Test
+ fun `test getGenre must call movie repository`(): Unit = runBlocking {
+ getGenres()
+ verify(moviesRepository, times(1)).getGenres()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetPopularMoviesUseCaseImplTest.kt b/app/src/test/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetPopularMoviesUseCaseImplTest.kt
new file mode 100644
index 00000000..ff1ec5a9
--- /dev/null
+++ b/app/src/test/java/com/mousavi/hashem/mymoviesapp/domain/usecases/GetPopularMoviesUseCaseImplTest.kt
@@ -0,0 +1,54 @@
+package com.mousavi.hashem.mymoviesapp.domain.usecases
+
+
+import com.google.common.truth.Truth.assertThat
+import com.mousavi.hashem.common.Either
+import com.mousavi.hashem.mymoviesapp.domain.model.Genre
+import com.mousavi.hashem.mymoviesapp.domain.model.Genres
+import com.mousavi.hashem.mymoviesapp.domain.model.Movie
+import com.mousavi.hashem.mymoviesapp.domain.model.PageData
+import com.mousavi.hashem.mymoviesapp.domain.repository.MoviesRepository
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mockito
+import org.mockito.Mockito.`when`
+
+class GetPopularMoviesUseCaseImplTest {
+
+ private lateinit var getPopularMoviesUseCaseImpl: GetPopularMoviesUseCaseImpl
+ private lateinit var moviesRepository: MoviesRepository
+ private lateinit var getGenresUseCase: GetGenresUseCase
+
+
+ @Before
+ fun setUp() {
+ moviesRepository = Mockito.mock(MoviesRepository::class.java)
+ getGenresUseCase = Mockito.mock(GetGenresUseCase::class.java)
+ getPopularMoviesUseCaseImpl = GetPopularMoviesUseCaseImpl(moviesRepository, getGenresUseCase)
+ }
+
+ @Test
+ fun test(): Unit = runBlocking {
+ `when`(getGenresUseCase()).thenReturn(Either.Success(Genres(listOf(Genre(1, "action")))))
+ `when`(moviesRepository.getPopularMovies("en-US", 1))
+ .thenReturn(
+ Either.Success(
+ PageData(page = 1,
+ movies = arrayListOf(
+ Movie(id = 1, genreIds = listOf(1)),
+ Movie(id = 2, genreIds = listOf(2))
+ )
+ )
+ )
+ )
+
+ val popularMovies: Either = getPopularMoviesUseCaseImpl("en-US", 1)
+ assertThat(popularMovies).isInstanceOf(Either.Success::class.java)
+ val pageData: PageData = (popularMovies as Either.Success).data
+ assertThat(pageData.movies).hasSize(2)
+ assertThat(pageData.movies).contains(Movie(id = 1, genreIds = listOf(1), genreNames = mutableListOf("action")))
+ assertThat(pageData.movies).contains(Movie(id = 2, genreIds = listOf(2), genreNames = arrayListOf()))
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/mousavi/hashem/util/DateUtilKtTest.kt b/app/src/test/java/com/mousavi/hashem/util/DateUtilKtTest.kt
new file mode 100644
index 00000000..853fbb1f
--- /dev/null
+++ b/app/src/test/java/com/mousavi/hashem/util/DateUtilKtTest.kt
@@ -0,0 +1,38 @@
+package com.mousavi.hashem.util
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+
+class DateUtilKtTest{
+
+ @Test
+ fun `return empty if input is not in yyyy-mm-dd format`(){
+ val input = "2020/12/25"
+ val dateFormat = dateFormat(input)
+ assertThat(dateFormat).isEmpty()
+ }
+
+ @Test
+ fun `return empty if input is not numerical`(){
+ val input = "yyyy-12-25"
+ val dateFormat = dateFormat(input)
+ assertThat(dateFormat).isEmpty()
+ }
+
+ @Test
+ fun `return true if convert right`(){
+ val input = "2021-12-25"
+ val expected = "2021 Dec 25"
+ val dateFormat = dateFormat(input)
+ assertThat(dateFormat).isEqualTo(expected)
+ }
+
+ @Test
+ fun `return empty if input in null`(){
+ val input = null
+ val dateFormat = dateFormat(input)
+ assertThat(dateFormat).isEmpty()
+ }
+
+}
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 00000000..42881210
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,19 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath "com.android.tools.build:gradle:7.0.4"
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.0"
+ classpath "com.google.dagger:hilt-android-gradle-plugin:2.38.1"
+
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle files
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 00000000..98bed167
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,21 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app"s APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Automatically convert third-party libraries to use AndroidX
+android.enableJetifier=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..cf8bddd8
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Fri Dec 24 15:13:54 IRST 2021
+distributionBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
+distributionPath=wrapper/dists
+zipStorePath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
new file mode 100755
index 00000000..4f906e0c
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 00000000..107acd32
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 00000000..d5f019d9
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,10 @@
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ jcenter() // Warning: this repository is going to shut down soon
+ }
+}
+rootProject.name = "MyMoviesApp"
+include ':app'