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'