diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d4c3a57e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.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/ 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..9040808d --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,129 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'kotlin-kapt' + id 'androidx.navigation.safeargs' +} + +android { + compileSdk 32 + + defaultConfig { + applicationId "com.github.hramogin.movieapp" + minSdk 21 + targetSdk 32 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + debug { + applicationIdSuffix ".debug" + minifyEnabled false + debuggable true + manifestPlaceholders = [crashlyticsCollectionEnabled: "false"] + + buildConfigField "String", "REST_BASE_URL", "\"https://api.themoviedb.org/3/\"" + buildConfigField "String", "API_TOKEN", "\"499865c4f3d665373c5f8c17c40d55e3\"" + buildConfigField "String", "BASE_IMAGE_URL", "\"https://image.tmdb.org/t/p/original\"" + } + stage { + applicationIdSuffix ".stage" + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + manifestPlaceholders = [crashlyticsCollectionEnabled: "true"] + + buildConfigField "String", "REST_BASE_URL", "\"https://api.themoviedb.org/3/\"" + buildConfigField "String", "API_TOKEN", "\"499865c4f3d665373c5f8c17c40d55e3\"" + buildConfigField "String", "BASE_IMAGE_URL", "\"https://image.tmdb.org/t/p/original\"" + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + manifestPlaceholders = [crashlyticsCollectionEnabled: "true"] + + buildConfigField "String", "REST_BASE_URL", "\"https://api.themoviedb.org/3/\"" + buildConfigField "String", "API_TOKEN", "\"499865c4f3d665373c5f8c17c40d55e3\"" + buildConfigField "String", "BASE_IMAGE_URL", "\"https://image.tmdb.org/t/p/original\"" + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + viewBinding { + enabled = true + } +} + +dependencies { + + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation libraries.kotlinStdLib + implementation libraries.kotlinStdLib + implementation libraries.appCompat + implementation libraries.constraintLayout + + //AndroidX + implementation libraries.coreKtx + implementation libraries.coreX + implementation libraries.legacySupport + testImplementation libraries.coreTestX + + //junit + testImplementation libraries.junit + androidTestImplementation libraries.junitExtention + + //espresso + androidTestImplementation libraries.espresso + + //Material components + implementation libraries.material + + //leakcanary + implementation libraries.leakcanary + + //lifecycle + implementation libraries.lifecycleViewModelKtx + implementation libraries.lifecycleLiveDataKtx + + //Navigation + implementation libraries.navigation + implementation libraries.navigationKtx + + //Timber + implementation libraries.timber + + //Retrofit + implementation libraries.retrofit + implementation libraries.retrofitConverterGson + implementation libraries.okhttp + implementation libraries.okhttpLoggingInterceptor + + //koin + implementation libraries.koinCore + implementation libraries.koinAndroid + + //firebase + implementation libraries.firebaseCrashlytics + implementation libraries.firebaseCloudMessaging + + //Glide + implementation libraries.glide + kapt libraries.glideCompiler + + //room + implementation libraries.room + implementation libraries.roomKtx + kapt libraries.annotationProcessorRoom + + testImplementation libraries.mockotlin + testImplementation libraries.coroutinesTest + testImplementation libraries.androidXCoreTesting +} \ 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/github/hramogin/movieapp/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/github/hramogin/movieapp/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..0ee38b3a --- /dev/null +++ b/app/src/androidTest/java/com/github/hramogin/movieapp/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.github.hramogin.movieapp + +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.github.hramogin.movieapp", 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..480f1129 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/App.kt b/app/src/main/java/com/github/hramogin/movieapp/App.kt new file mode 100644 index 00000000..14b623ef --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/App.kt @@ -0,0 +1,39 @@ +package com.github.hramogin.movieapp + +import android.app.Application +import com.github.hramogin.movieapp.di.modules.applicationModule +import com.github.hramogin.movieapp.di.modules.databaseModule +import com.github.hramogin.movieapp.di.modules.mapperModule +import com.github.hramogin.movieapp.di.modules.networkModule +import com.github.hramogin.movieapp.di.modules.networkServiceModule +import com.github.hramogin.movieapp.di.modules.repositoryModule +import com.github.hramogin.movieapp.di.modules.useCaseModule +import com.github.hramogin.movieapp.di.modules.viewModelModule +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.GlobalContext.startKoin + +class App : Application() { + + override fun onCreate() { + super.onCreate() + setupKoin() + } + + private fun setupKoin() { + startKoin { + androidContext(this@App) + modules( + listOf( + applicationModule, + viewModelModule, + repositoryModule, + useCaseModule, + networkServiceModule, + networkModule, + databaseModule, + mapperModule, + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/AppActivity.kt b/app/src/main/java/com/github/hramogin/movieapp/AppActivity.kt new file mode 100644 index 00000000..10608dc8 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/AppActivity.kt @@ -0,0 +1,11 @@ +package com.github.hramogin.movieapp + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle + +class AppActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/database/AppDatabase.kt b/app/src/main/java/com/github/hramogin/movieapp/data/database/AppDatabase.kt new file mode 100644 index 00000000..886f0647 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/database/AppDatabase.kt @@ -0,0 +1,18 @@ +package com.github.hramogin.movieapp.data.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.github.hramogin.movieapp.data.database.model.MovieDb +import com.github.hramogin.movieapp.data.database.model.MovieDetailsDb +import com.github.hramogin.movieapp.data.database.model.MovieReviewsDb +import com.github.hramogin.movieapp.data.repository.movie.DaoMovie + +@Database( + entities = [MovieDb::class, MovieDetailsDb::class, MovieReviewsDb::class], + version = 1, + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + + abstract fun moviesDao(): DaoMovie +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/database/model/MovieDb.kt b/app/src/main/java/com/github/hramogin/movieapp/data/database/model/MovieDb.kt new file mode 100644 index 00000000..62d253b5 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/database/model/MovieDb.kt @@ -0,0 +1,18 @@ +package com.github.hramogin.movieapp.data.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "Movie") +class MovieDb( + @PrimaryKey + @ColumnInfo + val id: String, + + @ColumnInfo + val posterPath: String, + + @ColumnInfo(name = "title") + val title: String, +) diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/database/model/MovieDetailsDb.kt b/app/src/main/java/com/github/hramogin/movieapp/data/database/model/MovieDetailsDb.kt new file mode 100644 index 00000000..29da3425 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/database/model/MovieDetailsDb.kt @@ -0,0 +1,20 @@ +package com.github.hramogin.movieapp.data.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "MovieDetails") +class MovieDetailsDb( + @PrimaryKey + @ColumnInfo + val id: String, + @ColumnInfo + val posterPath: String, + @ColumnInfo(name = "original_title") + val title: String, + @ColumnInfo(name = "tagline") + val tagline: String, + @ColumnInfo(name = "overview") + val overview: String, +) diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/database/model/MovieReviewsDb.kt b/app/src/main/java/com/github/hramogin/movieapp/data/database/model/MovieReviewsDb.kt new file mode 100644 index 00000000..43dadc03 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/database/model/MovieReviewsDb.kt @@ -0,0 +1,18 @@ +package com.github.hramogin.movieapp.data.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "MovieReview") +class MovieReviewsDb( + @PrimaryKey + @ColumnInfo + val id: String, + @ColumnInfo(name = "content") + val content: String, + @ColumnInfo(name = "created_at") + val createdAt: String, + @ColumnInfo(name = "author") + val author: String, +) diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/model/ListResponse.kt b/app/src/main/java/com/github/hramogin/movieapp/data/model/ListResponse.kt new file mode 100644 index 00000000..26e97f52 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/model/ListResponse.kt @@ -0,0 +1,10 @@ +package com.github.hramogin.movieapp.data.model + +import com.google.gson.annotations.SerializedName + +class ListResponse( + @SerializedName("page") + val page: String, + @SerializedName("results") + val results: List, +) \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/model/MovieApi.kt b/app/src/main/java/com/github/hramogin/movieapp/data/model/MovieApi.kt new file mode 100644 index 00000000..dc74f7e1 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/model/MovieApi.kt @@ -0,0 +1,12 @@ +package com.github.hramogin.movieapp.data.model + +import com.google.gson.annotations.SerializedName + +class MovieApi( + @SerializedName("id") + val id: String, + @SerializedName("poster_path") + val posterPath: String, + @SerializedName("title") + val title: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/model/MovieDetailsApi.kt b/app/src/main/java/com/github/hramogin/movieapp/data/model/MovieDetailsApi.kt new file mode 100644 index 00000000..aeb8c73f --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/model/MovieDetailsApi.kt @@ -0,0 +1,16 @@ +package com.github.hramogin.movieapp.data.model + +import com.google.gson.annotations.SerializedName + +class MovieDetailsApi( + @SerializedName("id") + val id: String, + @SerializedName("poster_path") + val posterPath: String, + @SerializedName("original_title") + val title: String, + @SerializedName("tagline") + val tagline: String, + @SerializedName("overview") + val overview: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/model/ReviewApi.kt b/app/src/main/java/com/github/hramogin/movieapp/data/model/ReviewApi.kt new file mode 100644 index 00000000..789bc9ac --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/model/ReviewApi.kt @@ -0,0 +1,14 @@ +package com.github.hramogin.movieapp.data.model + +import com.google.gson.annotations.SerializedName + +class ReviewApi( + @SerializedName("id") + val id: String, + @SerializedName("content") + val content: String, + @SerializedName("created_at") + val createdAt: String, + @SerializedName("author") + val author: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/network/MovieAppGlideModule.kt b/app/src/main/java/com/github/hramogin/movieapp/data/network/MovieAppGlideModule.kt new file mode 100644 index 00000000..c981814d --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/network/MovieAppGlideModule.kt @@ -0,0 +1,34 @@ +package com.github.hramogin.movieapp.data.network + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.Log +import com.bumptech.glide.GlideBuilder +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.load.engine.cache.LruResourceCache +import com.bumptech.glide.load.engine.cache.MemorySizeCalculator +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.bumptech.glide.module.AppGlideModule +import com.bumptech.glide.request.RequestOptions +import com.github.hramogin.movieapp.BuildConfig + +@GlideModule +class MovieAppGlideModule : AppGlideModule() { + + override fun applyOptions(context: Context, builder: GlideBuilder) { + super.applyOptions(context, builder) + val memorySize = MemorySizeCalculator.Builder(context) + .setMemoryCacheScreens(5f) + .build() + builder.setMemoryCache(LruResourceCache(memorySize.memoryCacheSize.toLong())) + builder.setLogLevel(if (BuildConfig.DEBUG) Log.DEBUG else Log.ERROR) + builder.setDefaultTransitionOptions( + Drawable::class.java, + DrawableTransitionOptions.withCrossFade() + ) + builder.setDefaultRequestOptions( + RequestOptions().transform(CenterCrop()) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/network/TokenInterceptor.kt b/app/src/main/java/com/github/hramogin/movieapp/data/network/TokenInterceptor.kt new file mode 100644 index 00000000..554a4f6d --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/network/TokenInterceptor.kt @@ -0,0 +1,33 @@ +package com.github.hramogin.movieapp.data.network + +import com.github.hramogin.movieapp.BuildConfig +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response +import timber.log.Timber + +class TokenInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + var original = chain.request() + val token = getToken() + val url = original.url.newBuilder().addQueryParameter(API_KEY, token).build() + original = original.newBuilder().url(url).build() + return chain.proceed(original) + } + + private fun getToken(): String { + return runBlocking { + try { + BuildConfig.API_TOKEN + } catch (exception: Exception) { + Timber.e(exception) + "" + } + } + } + + private companion object { + const val API_KEY = "api_key" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/repository/movie/DaoMovie.kt b/app/src/main/java/com/github/hramogin/movieapp/data/repository/movie/DaoMovie.kt new file mode 100644 index 00000000..cb05cf10 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/repository/movie/DaoMovie.kt @@ -0,0 +1,34 @@ +package com.github.hramogin.movieapp.data.repository.movie + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.hramogin.movieapp.data.database.model.MovieDb +import com.github.hramogin.movieapp.data.database.model.MovieDetailsDb +import com.github.hramogin.movieapp.data.database.model.MovieReviewsDb + +@Dao +interface DaoMovie { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMovie(movie: MovieDb) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllMovies(list: List) + + @Query("SELECT * FROM Movie") + suspend fun getAllMovies(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMovieDetails(movie: MovieDetailsDb) + + @Query("SELECT * FROM MovieDetails WHERE MovieDetails.id == :id") + suspend fun getMovieDetails(id: String): MovieDetailsDb + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllMovieReviews(list: List) + + @Query("SELECT * FROM MovieReview") + suspend fun getAllMovieReviews(): List +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/repository/movie/MovieRepository.kt b/app/src/main/java/com/github/hramogin/movieapp/data/repository/movie/MovieRepository.kt new file mode 100644 index 00000000..2311a1f0 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/repository/movie/MovieRepository.kt @@ -0,0 +1,30 @@ +package com.github.hramogin.movieapp.data.repository.movie + +import com.github.hramogin.movieapp.data.database.model.MovieDb +import com.github.hramogin.movieapp.data.database.model.MovieDetailsDb +import com.github.hramogin.movieapp.data.database.model.MovieReviewsDb +import com.github.hramogin.movieapp.data.model.MovieApi +import com.github.hramogin.movieapp.data.model.MovieDetailsApi +import com.github.hramogin.movieapp.data.model.ReviewApi + +interface MovieRepository { + + suspend fun getFilmsFromServer(): List + + suspend fun getFilmsFromDb(): List + + suspend fun setFilmsToDb(films: List) + + suspend fun getFilmDetailsFromServer(filId: String): MovieDetailsApi + + suspend fun getFilmDetailsFromDb(filmId: String): MovieDetailsDb + + suspend fun setFilmDetailsToDb(movieDetailsDb: MovieDetailsDb) + + suspend fun getFilmReviewsFromServer(filId: String): List + + suspend fun getFilmReviewsFromDb(filId: String): List + + suspend fun setFilmReviewsToDb(reviews: List) + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/repository/movie/MovieRepositoryImpl.kt b/app/src/main/java/com/github/hramogin/movieapp/data/repository/movie/MovieRepositoryImpl.kt new file mode 100644 index 00000000..fcd93c66 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/repository/movie/MovieRepositoryImpl.kt @@ -0,0 +1,47 @@ +package com.github.hramogin.movieapp.data.repository.movie + +import com.github.hramogin.movieapp.data.database.model.MovieDb +import com.github.hramogin.movieapp.data.database.model.MovieDetailsDb +import com.github.hramogin.movieapp.data.database.model.MovieReviewsDb +import com.github.hramogin.movieapp.data.model.MovieApi +import com.github.hramogin.movieapp.data.model.MovieDetailsApi +import com.github.hramogin.movieapp.data.model.ReviewApi + +class MovieRepositoryImpl(private val movieService: MovieService, private val movieDao: DaoMovie) : + MovieRepository { + override suspend fun getFilmsFromServer(): List { + return movieService.getFilms().results + } + + override suspend fun getFilmDetailsFromServer(filId: String): MovieDetailsApi { + return movieService.getFilmDetails(filId) + } + + override suspend fun getFilmReviewsFromServer(filId: String): List { + return movieService.getFilmReviews(filId).results + } + + override suspend fun getFilmsFromDb(): List { + return movieDao.getAllMovies() + } + + override suspend fun setFilmsToDb(films: List) { + movieDao.insertAllMovies(films) + } + + override suspend fun getFilmDetailsFromDb(filmId: String): MovieDetailsDb { + return movieDao.getMovieDetails(filmId) + } + + override suspend fun setFilmDetailsToDb(movieDetailsDb: MovieDetailsDb) { + movieDao.insertMovieDetails(movieDetailsDb) + } + + override suspend fun getFilmReviewsFromDb(filId: String): List { + return movieDao.getAllMovieReviews() + } + + override suspend fun setFilmReviewsToDb(reviews: List) { + movieDao.insertAllMovieReviews(reviews) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/data/repository/movie/MovieService.kt b/app/src/main/java/com/github/hramogin/movieapp/data/repository/movie/MovieService.kt new file mode 100644 index 00000000..a98cc0d3 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/data/repository/movie/MovieService.kt @@ -0,0 +1,20 @@ +package com.github.hramogin.movieapp.data.repository.movie + +import com.github.hramogin.movieapp.data.model.ListResponse +import com.github.hramogin.movieapp.data.model.MovieApi +import com.github.hramogin.movieapp.data.model.MovieDetailsApi +import com.github.hramogin.movieapp.data.model.ReviewApi +import retrofit2.http.GET +import retrofit2.http.Path + +interface MovieService { + + @GET("movie/top_rated") + suspend fun getFilms(): ListResponse + + @GET("movie/{movie_id}") + suspend fun getFilmDetails(@Path("movie_id") movieId: String): MovieDetailsApi + + @GET("movie/{movie_id}/reviews") + suspend fun getFilmReviews(@Path("movie_id") movieId: String): ListResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/di/modules/ApplicationModule.kt b/app/src/main/java/com/github/hramogin/movieapp/di/modules/ApplicationModule.kt new file mode 100644 index 00000000..0e3c513a --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/di/modules/ApplicationModule.kt @@ -0,0 +1,10 @@ +package com.github.hramogin.movieapp.di.modules + +import com.github.hramogin.movieapp.presentation.base.ResourceProvider +import com.github.hramogin.movieapp.presentation.base.ResourceProviderImpl +import org.koin.dsl.module + +val applicationModule = module { + + single { ResourceProviderImpl(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/di/modules/DataBaseModule.kt b/app/src/main/java/com/github/hramogin/movieapp/di/modules/DataBaseModule.kt new file mode 100644 index 00000000..2fc4800e --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/di/modules/DataBaseModule.kt @@ -0,0 +1,15 @@ +package com.github.hramogin.movieapp.di.modules + +import android.content.Context +import androidx.room.Room +import com.github.hramogin.movieapp.data.database.AppDatabase +import org.koin.dsl.module + +const val DATABASE_NAME = "APP_DATABASE" + +val databaseModule = module { + single { Room.databaseBuilder(get(), AppDatabase::class.java, DATABASE_NAME).build() } + single { get().cacheDir } + + single { get().moviesDao() } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/di/modules/MapperModule.kt b/app/src/main/java/com/github/hramogin/movieapp/di/modules/MapperModule.kt new file mode 100644 index 00000000..4263224b --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/di/modules/MapperModule.kt @@ -0,0 +1,9 @@ +package com.github.hramogin.movieapp.di.modules + +import com.github.hramogin.movieapp.presentation.screens.details.MovieDetailsMapper +import org.koin.dsl.module + +val mapperModule = module { + + single { MovieDetailsMapper(get())} +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/di/modules/NetworkModule.kt b/app/src/main/java/com/github/hramogin/movieapp/di/modules/NetworkModule.kt new file mode 100644 index 00000000..5ba3a732 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/di/modules/NetworkModule.kt @@ -0,0 +1,64 @@ +package com.github.hramogin.movieapp.di.modules + +import com.github.hramogin.movieapp.BuildConfig +import com.github.hramogin.movieapp.data.network.TokenInterceptor +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import okhttp3.Cache +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.koin.android.ext.koin.androidApplication +import org.koin.core.qualifier.named +import org.koin.dsl.bind +import org.koin.dsl.module +import retrofit2.Converter +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +const val REST_API_URL_TAG = "REST_API_URL_TAG" +const val GENERAL_OK_HTTP_TAG = "GENERAL_OK_HTTP_TAG" +const val OPEN_OK_HTTP_TAG = "OPEN_OK_HTTP_TAG" +private const val TIME_OUT = 30L +private const val CACHE_TAG = "CACHE_TAG" + +const val GENERAL_BACKEND = "GENERAL_BACKEND" + +val networkModule = module { + + single(named(REST_API_URL_TAG)) { BuildConfig.REST_BASE_URL } + + single(named(CACHE_TAG)) { androidApplication().cacheDir } + single() { TokenInterceptor() } + + single { getGson() } + + single { Cache(get(named(CACHE_TAG)), 1 * 1024 * 1024) } + + single(named(OPEN_OK_HTTP_TAG)) { + OkHttpClient.Builder() + .readTimeout(TIME_OUT, TimeUnit.SECONDS) + .writeTimeout(TIME_OUT, TimeUnit.SECONDS) + .connectTimeout(TIME_OUT, TimeUnit.SECONDS) + .addInterceptor(HttpLoggingInterceptor().apply { + level = + if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE + }) + .addInterceptor(get()) + .build() + } bind OkHttpClient::class + + single { GsonConverterFactory.create(get()) } bind Converter.Factory::class + + single { + Retrofit.Builder() + .baseUrl(get(named(REST_API_URL_TAG)) as String) + .client(get(named(OPEN_OK_HTTP_TAG))) + .addConverterFactory(get()) + .build() + } +} + +fun getGson(): Gson = GsonBuilder() + .create() \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/di/modules/NetworkServiceModule.kt b/app/src/main/java/com/github/hramogin/movieapp/di/modules/NetworkServiceModule.kt new file mode 100644 index 00000000..bc51cc67 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/di/modules/NetworkServiceModule.kt @@ -0,0 +1,9 @@ +package com.github.hramogin.movieapp.di.modules + +import com.github.hramogin.movieapp.data.repository.movie.MovieService +import org.koin.dsl.module +import retrofit2.Retrofit + +val networkServiceModule = module { + single { get().create(MovieService::class.java) } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/di/modules/RepositoryModule.kt b/app/src/main/java/com/github/hramogin/movieapp/di/modules/RepositoryModule.kt new file mode 100644 index 00000000..d16f1a13 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/di/modules/RepositoryModule.kt @@ -0,0 +1,9 @@ +package com.github.hramogin.movieapp.di.modules + +import com.github.hramogin.movieapp.data.repository.movie.MovieRepository +import com.github.hramogin.movieapp.data.repository.movie.MovieRepositoryImpl +import org.koin.dsl.module + +val repositoryModule = module { + single { MovieRepositoryImpl(get(), get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/di/modules/UseCaseModule.kt b/app/src/main/java/com/github/hramogin/movieapp/di/modules/UseCaseModule.kt new file mode 100644 index 00000000..9ec72fdc --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/di/modules/UseCaseModule.kt @@ -0,0 +1,12 @@ +package com.github.hramogin.movieapp.di.modules + +import com.github.hramogin.movieapp.domain.useCases.movie.GetFilmDetailsUseCase +import com.github.hramogin.movieapp.domain.useCases.movie.GetFilmReviewsUseCase +import com.github.hramogin.movieapp.domain.useCases.movie.GetMoviesUseCase +import org.koin.dsl.module + +val useCaseModule = module { + single { GetMoviesUseCase(get()) } + single { GetFilmDetailsUseCase(get()) } + single { GetFilmReviewsUseCase(get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/di/modules/ViewModelVodule.kt b/app/src/main/java/com/github/hramogin/movieapp/di/modules/ViewModelVodule.kt new file mode 100644 index 00000000..486ebb15 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/di/modules/ViewModelVodule.kt @@ -0,0 +1,13 @@ +package com.github.hramogin.movieapp.di.modules + +import com.github.hramogin.movieapp.presentation.screens.details.DetailsViewModel +import com.github.hramogin.movieapp.presentation.screens.moviesList.MoviesListViewModel +import com.github.hramogin.movieapp.presentation.screens.splash.SplashViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +val viewModelModule = module { + viewModel { SplashViewModel() } + viewModel { MoviesListViewModel(get()) } + viewModel { (id: String) -> DetailsViewModel(id, get(), get()) } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/domain/mappers/MovieMappers.kt b/app/src/main/java/com/github/hramogin/movieapp/domain/mappers/MovieMappers.kt new file mode 100644 index 00000000..ffe98d13 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/domain/mappers/MovieMappers.kt @@ -0,0 +1,94 @@ +package com.github.hramogin.movieapp.domain.mappers + +import com.github.hramogin.movieapp.data.database.model.MovieDb +import com.github.hramogin.movieapp.data.database.model.MovieDetailsDb +import com.github.hramogin.movieapp.data.database.model.MovieReviewsDb +import com.github.hramogin.movieapp.data.model.MovieApi +import com.github.hramogin.movieapp.data.model.MovieDetailsApi +import com.github.hramogin.movieapp.data.model.ReviewApi +import com.github.hramogin.movieapp.domain.model.Movie +import com.github.hramogin.movieapp.domain.model.MovieDetails +import com.github.hramogin.movieapp.domain.model.Review + +fun MovieApi.toFilm(): Movie { + return Movie( + id = id, + posterPath = posterPath, + title = title, + ) +} + +fun Movie.toMovieDb(): MovieDb { + return MovieDb( + id = id, + posterPath = posterPath, + title = title, + ) +} + +fun MovieDb.toFilm(): Movie { + return Movie( + id = id, + posterPath = posterPath, + title = title, + ) +} + +fun ReviewApi.toReview(): Review { + return Review( + id = id, + name = author, + content = content, + date = createdAt, + ) +} + +fun Review.toMovieReviewDb(): MovieReviewsDb { + return MovieReviewsDb( + id = id, + content = content, + createdAt = date, + author = name, + ) +} + +fun MovieReviewsDb.toFilmReview(): Review { + return Review( + id = id, + content = content, + date = createdAt, + name = author + ) +} + +fun MovieDetailsDb.toFilmDetails(): MovieDetails { + return MovieDetails( + id = id, + reviews = emptyList(), + posterPath = posterPath, + title = title, + tagline = tagline, + description = overview, + ) +} + +fun MovieDetailsApi.toDetails(): MovieDetails { + return MovieDetails( + id = id, + reviews = emptyList(), + posterPath = posterPath, + title = title, + tagline = tagline, + description = overview, + ) +} + +fun MovieDetails.toMovieDetailsDb(): MovieDetailsDb { + return MovieDetailsDb( + id = id, + posterPath = posterPath, + title = title, + overview = description, + tagline = tagline, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/domain/model/Movie.kt b/app/src/main/java/com/github/hramogin/movieapp/domain/model/Movie.kt new file mode 100644 index 00000000..36ca7276 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/domain/model/Movie.kt @@ -0,0 +1,7 @@ +package com.github.hramogin.movieapp.domain.model + +class Movie( + val id: String, + val posterPath: String, + val title: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/domain/model/MovieDetails.kt b/app/src/main/java/com/github/hramogin/movieapp/domain/model/MovieDetails.kt new file mode 100644 index 00000000..687aff13 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/domain/model/MovieDetails.kt @@ -0,0 +1,10 @@ +package com.github.hramogin.movieapp.domain.model + +data class MovieDetails( + val id: String, + val reviews: List, + val posterPath: String, + val description: String, + val title: String, + val tagline: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/domain/model/Review.kt b/app/src/main/java/com/github/hramogin/movieapp/domain/model/Review.kt new file mode 100644 index 00000000..df2e140d --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/domain/model/Review.kt @@ -0,0 +1,8 @@ +package com.github.hramogin.movieapp.domain.model + +class Review( + val id: String, + val content: String, + val name: String, + val date: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/base/ApiError.kt b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/base/ApiError.kt new file mode 100644 index 00000000..a735fcf4 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/base/ApiError.kt @@ -0,0 +1,17 @@ +package com.github.hramogin.movieapp.domain.useCases.base + +import com.github.hramogin.movieapp.R + + +sealed class ApiError( + val type: String, + val stringResId: Int = R.string.unknown_error, + val errorCode: Int = 0 +) { + + object UnknownApiError : ApiError(UNKNOWN, R.string.unknown_error, 100) + + companion object { + const val UNKNOWN = "Unknown error" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/base/BaseUseCase.kt b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/base/BaseUseCase.kt new file mode 100644 index 00000000..c3baa7fc --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/base/BaseUseCase.kt @@ -0,0 +1,42 @@ +package com.github.hramogin.movieapp.domain.useCases.base + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import java.net.ConnectException +import java.net.UnknownHostException + +abstract class BaseUseCase where Type : Any { + + abstract suspend fun run(param: Param): Either + + open val dispatcher: CoroutineDispatcher = Dispatchers.Default + + open operator fun invoke( + scope: CoroutineScope, + param: Param, + result: (Either) -> Unit = {} + ): Job { + val backgroundJob = scope.async(dispatcher) { run(param) } + return scope.launch(Dispatchers.Main) { result.invoke(backgroundJob.await()) } + } + + protected fun onWrapException(exception: Exception): Failure { + return try { + when (exception) { + is UnknownHostException -> Failure.NetworkFailureL( + exception + ) + is ConnectException -> Failure.NetworkFailureL( + exception + ) + else -> Failure.UnknownFailure(exception) + } + } catch (exception: Exception) { + Failure.UnknownFailure(exception) + } + } +} diff --git a/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/base/Either.kt b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/base/Either.kt new file mode 100644 index 00000000..f88ff6b2 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/base/Either.kt @@ -0,0 +1,44 @@ +package com.github.hramogin.movieapp.domain.useCases.base + +/** + * Represents a value of one of two possible types (a disjoint union). + * Instances of [Either] are either an instance of [Left] or [Right]. + * FP Convention dictates that [Left] is used for "failure" + * and [Right] is used for "success". + * + * @see Left + * @see Right + */ +sealed class Either { + /** * Represents the left side of [Either] class which by convention is a "Failure". */ + data class Left(val left: L) : Either() + + /** * Represents the right side of [Either] class which by convention is a "Success". */ + data class Right(val right: R) : Either() + + val isRight get() = this is Right + val isLeft get() = this is Left + + fun left(left: L) = Left(left) + fun right(right: R) = Right(right) + + fun either(fnL: (L) -> Any, fnR: (R) -> Any): Any = + when (this) { + is Left -> fnL(left) + is Right -> fnR(right) + } +} + +fun ((A) -> B).c(f: (B) -> C): (A) -> C = { + f(this(it)) +} + +fun Either.flatMap(fn: (R) -> Either): Either = + when (this) { + is Either.Left -> Either.Left( + left + ) + is Either.Right -> fn(right) + } + +fun Either.map(fn: (R) -> (T)): Either = this.flatMap(fn.c(::right)) \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/base/Failure.kt b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/base/Failure.kt new file mode 100644 index 00000000..974d3352 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/base/Failure.kt @@ -0,0 +1,10 @@ +package com.github.hramogin.movieapp.domain.useCases.base + +sealed class Failure(val exception: Exception) { + open class FeatureFailure(exception: Exception) : Failure(exception) + open class UnknownFailure(exception: Exception) : Failure(exception) + open class NetworkFailureL(exception: Exception) : Failure(exception) + open class ApiFailure(val apiError: ApiError) : Failure(Exception()) + + class NoDataFailure : FeatureFailure(java.lang.Exception("No data")) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/movie/GetFilmDetailsUseCase.kt b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/movie/GetFilmDetailsUseCase.kt new file mode 100644 index 00000000..7b203223 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/movie/GetFilmDetailsUseCase.kt @@ -0,0 +1,38 @@ +package com.github.hramogin.movieapp.domain.useCases.movie + +import com.github.hramogin.movieapp.data.repository.movie.MovieRepository +import com.github.hramogin.movieapp.domain.mappers.toDetails +import com.github.hramogin.movieapp.domain.mappers.toFilmDetails +import com.github.hramogin.movieapp.domain.mappers.toMovieDetailsDb +import com.github.hramogin.movieapp.domain.mappers.toMovieReviewDb +import com.github.hramogin.movieapp.domain.mappers.toReview +import com.github.hramogin.movieapp.domain.model.MovieDetails +import com.github.hramogin.movieapp.domain.useCases.base.BaseUseCase +import com.github.hramogin.movieapp.domain.useCases.base.Either +import com.github.hramogin.movieapp.domain.useCases.base.Failure +import java.net.UnknownHostException + +class GetFilmDetailsUseCase(private val movieRepository: MovieRepository) : + BaseUseCase() { + + override suspend fun run(param: Param): Either { + return try { + val filmDetails = movieRepository.getFilmDetailsFromServer(param.movieId).toDetails() + val reviews = + movieRepository.getFilmReviewsFromServer(param.movieId).map { it.toReview() } + movieRepository.setFilmDetailsToDb(filmDetails.toMovieDetailsDb()) + movieRepository.setFilmReviewsToDb(reviews.map { it.toMovieReviewDb() }) + Either.Right(filmDetails.copy(reviews = reviews)) + } catch (exception: Exception) { + if (exception is UnknownHostException) { + Either.Right( + movieRepository.getFilmDetailsFromDb(param.movieId).toFilmDetails() + ) + } else { + Either.Left(onWrapException(exception)) + } + } + } + + class Param(val movieId: String) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/movie/GetFilmReviewsUseCase.kt b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/movie/GetFilmReviewsUseCase.kt new file mode 100644 index 00000000..f131abb2 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/movie/GetFilmReviewsUseCase.kt @@ -0,0 +1,33 @@ +package com.github.hramogin.movieapp.domain.useCases.movie + +import com.github.hramogin.movieapp.data.repository.movie.MovieRepository +import com.github.hramogin.movieapp.domain.mappers.toFilmReview +import com.github.hramogin.movieapp.domain.mappers.toMovieReviewDb +import com.github.hramogin.movieapp.domain.mappers.toReview +import com.github.hramogin.movieapp.domain.model.Review +import com.github.hramogin.movieapp.domain.useCases.base.BaseUseCase +import com.github.hramogin.movieapp.domain.useCases.base.Either +import com.github.hramogin.movieapp.domain.useCases.base.Failure +import java.net.UnknownHostException + +class GetFilmReviewsUseCase(private val movieRepository: MovieRepository) : + BaseUseCase>() { + + override suspend fun run(param: Param): Either> { + return try { + val reviews = + movieRepository.getFilmReviewsFromServer(param.filmId).map { it.toReview() } + movieRepository.setFilmReviewsToDb(reviews.map { it.toMovieReviewDb() }) + Either.Right(reviews) + } catch (exception: Exception) { + if (exception is UnknownHostException) { + Either.Right( + movieRepository.getFilmReviewsFromDb(param.filmId).map { it.toFilmReview() }) + } else { + Either.Left(onWrapException(exception)) + } + } + } + + class Param(val filmId: String) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/movie/GetMoviesUseCase.kt b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/movie/GetMoviesUseCase.kt new file mode 100644 index 00000000..3f9c7aca --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/domain/useCases/movie/GetMoviesUseCase.kt @@ -0,0 +1,30 @@ +package com.github.hramogin.movieapp.domain.useCases.movie + +import com.github.hramogin.movieapp.data.repository.movie.MovieRepository +import com.github.hramogin.movieapp.domain.mappers.toFilm +import com.github.hramogin.movieapp.domain.mappers.toMovieDb +import com.github.hramogin.movieapp.domain.model.Movie +import com.github.hramogin.movieapp.domain.useCases.base.BaseUseCase +import com.github.hramogin.movieapp.domain.useCases.base.Either +import com.github.hramogin.movieapp.domain.useCases.base.Failure +import java.net.UnknownHostException + +open class GetMoviesUseCase(private val moviesRepository: MovieRepository) : + BaseUseCase>() { + + override suspend fun run(param: Params): Either> { + return try { + val movies = moviesRepository.getFilmsFromServer().map { it.toFilm() } + moviesRepository.setFilmsToDb(movies.map { it.toMovieDb() }) + return Either.Right(movies) + } catch (exception: Exception) { + if (exception is UnknownHostException) { + Either.Right(moviesRepository.getFilmsFromDb().map { it.toFilm() }) + } else { + Either.Left(onWrapException(exception)) + } + } + } + + open class Params +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/navigation/AppNavigator.kt b/app/src/main/java/com/github/hramogin/movieapp/navigation/AppNavigator.kt new file mode 100644 index 00000000..320a1a4b --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/navigation/AppNavigator.kt @@ -0,0 +1,47 @@ +package com.github.hramogin.movieapp.navigation + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.navigation.NavDirections +import androidx.navigation.fragment.FragmentNavigatorExtras +import androidx.navigation.fragment.NavHostFragment.findNavController +import com.github.hramogin.movieapp.R +import com.github.hramogin.movieapp.presentation.screens.moviesList.MoviesListFragmentDirections +import com.github.hramogin.movieapp.presentation.screens.splash.SplashFragment + +object AppNavigator : Navigator { + + override fun toMainScreen(component: Fragment) { + when (component) { + is SplashFragment -> navigateTo( + component, + R.id.action_splashFragment_to_mainFragment + ) + } + } + + override fun toDetailsScreen(component: Fragment, id: String, view: View) { + val action = MoviesListFragmentDirections.actionMoviesListFragmentToDetailsFragment(id) + val extras = FragmentNavigatorExtras() + navigateTo( + component = component, + action = action, + ) + } + + override fun onHandleBack(component: Fragment): Boolean { + val controller = findNavController(component) + return controller.popBackStack() + } + + + private fun navigateTo(component: Fragment, action: Int, bundle: Bundle? = null) { + val controller = findNavController(component) + controller.navigate(action, bundle) + } + + private fun navigateTo(component: Fragment, action: NavDirections) { + findNavController(component).navigate(action) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/navigation/Navigator.kt b/app/src/main/java/com/github/hramogin/movieapp/navigation/Navigator.kt new file mode 100644 index 00000000..606e0d50 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/navigation/Navigator.kt @@ -0,0 +1,13 @@ +package com.github.hramogin.movieapp.navigation + +import android.view.View +import androidx.fragment.app.Fragment + +interface Navigator { + + fun toMainScreen(component: Fragment) + + fun toDetailsScreen(component: Fragment, id: String, view: View) + + fun onHandleBack(component: Fragment): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/base/BaseFragment.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/BaseFragment.kt new file mode 100644 index 00000000..1591f6b7 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/BaseFragment.kt @@ -0,0 +1,40 @@ +package com.github.hramogin.movieapp.presentation.base + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.viewbinding.ViewBinding +import com.github.hramogin.movieapp.presentation.base.viewModel.BaseViewModel + +abstract class BaseFragment : Fragment() { + + abstract val viewModel: VM + abstract fun getLayoutId(): Int + abstract val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> VB + + private var _binding: ViewBinding? = null + + @Suppress("UNCHECKED_CAST") + protected val binding: VB + get() = _binding as VB + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.onCreate() + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + _binding = bindingInflater.invoke(inflater, container, false) + return requireNotNull(_binding).root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.onViewCreated() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/base/IntExtensions.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/IntExtensions.kt new file mode 100644 index 00000000..e001331b --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/IntExtensions.kt @@ -0,0 +1,10 @@ +package com.github.hramogin.movieapp.presentation.base + +import android.content.res.Resources + +val Float.toPx: Int + get() = (this * Resources.getSystem().displayMetrics.density).toInt() + +val Int.toDp: Int + get() = (this / Resources.getSystem().displayMetrics.density).toInt() + diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/base/ResourceProvider.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/ResourceProvider.kt new file mode 100644 index 00000000..34be4fcc --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/ResourceProvider.kt @@ -0,0 +1,20 @@ +package com.github.hramogin.movieapp.presentation.base + +import android.content.Context +import android.content.res.Resources +import androidx.annotation.StringRes +import javax.inject.Inject + +interface ResourceProvider { + + fun getString(@StringRes id: Int): String +} + +class ResourceProviderImpl @Inject constructor(context: Context): ResourceProvider { + + private val resources: Resources = context.resources + + override fun getString(id: Int): String { + return resources.getString(id) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/base/adapter/BaseAdapter.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/adapter/BaseAdapter.kt new file mode 100644 index 00000000..cb7ff7ce --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/adapter/BaseAdapter.kt @@ -0,0 +1,35 @@ +package com.github.hramogin.movieapp.presentation.base.adapter + +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding + +abstract class BaseAdapter( + protected var items: List = emptyList(), + protected val listener: (item: T) -> Unit = {} +) : + RecyclerView.Adapter>() { + + override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { + if (items.isEmpty()) { + holder.onBind(null) + } else { + holder.onBind(items[position]) + } + } + + override fun getItemCount(): Int = if (items.isEmpty()) 1 else items.size + + override fun getItemViewType(position: Int): Int { + return if (items.isEmpty()) EMPTY_TYPE else GENERAL_TYPE + } + + open fun setNewData(newItems: List) { + items = newItems + notifyDataSetChanged() + } + + companion object { + const val EMPTY_TYPE = 0 + const val GENERAL_TYPE = 1 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/base/adapter/BaseViewHolder.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/adapter/BaseViewHolder.kt new file mode 100644 index 00000000..044776c5 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/adapter/BaseViewHolder.kt @@ -0,0 +1,15 @@ +package com.github.hramogin.movieapp.presentation.base.adapter + +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding + +abstract class BaseViewHolder(binding: VB, protected val listener: (item: T) -> Unit = {}) : + RecyclerView.ViewHolder(binding.root){ + + open fun onBind(item: T?) { + val data = item ?: return + itemView.setOnClickListener { + listener.invoke(data) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/base/extensions/LiveDataExtensions.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/extensions/LiveDataExtensions.kt new file mode 100644 index 00000000..37aa95e0 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/extensions/LiveDataExtensions.kt @@ -0,0 +1,13 @@ +package com.github.hramogin.movieapp.presentation.base.extensions + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import com.github.hramogin.movieapp.presentation.base.viewModel.Event + +inline fun LiveData>.observeEvent( + owner: LifecycleOwner, + crossinline onHandleEvent: (T) -> Unit +) { + observe(owner, Observer { it?.updateIfNeed()?.let(onHandleEvent) }) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/base/viewModel/ActionLiveData.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/viewModel/ActionLiveData.kt new file mode 100644 index 00000000..cea0ee2e --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/viewModel/ActionLiveData.kt @@ -0,0 +1,32 @@ +package com.github.hramogin.movieapp.presentation.base.viewModel + +import androidx.annotation.MainThread +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean + +class ActionLiveData : MutableLiveData() { + + private var mPending: AtomicBoolean = AtomicBoolean(false) + + override fun observe(owner: LifecycleOwner, observer: Observer) { + + if (hasActiveObservers()) { + Timber.w("Has multiple observers") + } + + super.observe(owner, Observer { + if (mPending.compareAndSet(true, false)) { + observer.onChanged(it) + } + }) + } + + @MainThread + override fun setValue(value: T?) { + mPending.set(true) + super.setValue(value) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/base/viewModel/BaseViewModel.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/viewModel/BaseViewModel.kt new file mode 100644 index 00000000..f7b525fd --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/viewModel/BaseViewModel.kt @@ -0,0 +1,23 @@ +package com.github.hramogin.movieapp.presentation.base.viewModel + +import androidx.lifecycle.ViewModel +import com.github.hramogin.movieapp.domain.useCases.base.Failure +import timber.log.Timber + +abstract class BaseViewModel: ViewModel() { + + private val _backAction: ActionLiveData> = ActionLiveData() + + open fun onCreate() {} + + open fun onViewCreated() {} + + open fun onBackClicked() { + _backAction.value = Event(Any()) + } + + open fun onHandleError(failure: Failure) { + Timber.e(failure.exception) + //Todo implement logic for display error on UI + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/base/viewModel/Event.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/viewModel/Event.kt new file mode 100644 index 00000000..b4f7ea4b --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/base/viewModel/Event.kt @@ -0,0 +1,16 @@ +package com.github.hramogin.movieapp.presentation.base.viewModel + +class Event(private val data: T) { + + private var hasBeenHandled = false + private set + + fun updateIfNeed(): T?{ + return if(hasBeenHandled){ + null + }else{ + hasBeenHandled = true + data + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/BaseDetailItem.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/BaseDetailItem.kt new file mode 100644 index 00000000..e8bba23b --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/BaseDetailItem.kt @@ -0,0 +1,22 @@ +package com.github.hramogin.movieapp.presentation.screens.details + +import com.github.hramogin.movieapp.domain.model.Review + +abstract class BaseDetailItem( + open val viewType: Int +) + +class TitleItem( + override val viewType: Int = 0, + val title: String, +) : BaseDetailItem(viewType) + +class DescriptionItem( + override val viewType: Int = 1, + val description: String +) : BaseDetailItem(viewType) + +class ReviewItem( + override val viewType: Int = 2, + val review: Review, +) : BaseDetailItem(viewType) \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/DetailsAdapter.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/DetailsAdapter.kt new file mode 100644 index 00000000..d3f42478 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/DetailsAdapter.kt @@ -0,0 +1,106 @@ +package com.github.hramogin.movieapp.presentation.screens.details + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.github.hramogin.movieapp.databinding.DescriptionItemBinding +import com.github.hramogin.movieapp.databinding.ReviewItemBinding +import com.github.hramogin.movieapp.databinding.TitleItemBinding + +class DetailsAdapter() : RecyclerView.Adapter() { + + private val items: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseDetailViewHolder { + return when (viewType) { + TitleViewHolder.TYPE -> { + val binding = + TitleItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + TitleViewHolder(binding) + } + DescriptionViewHolder.TYPE -> { + val binding = DescriptionItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + DescriptionViewHolder(binding) + } + ReviewViewHolder.TYPE -> { + val binding = + ReviewItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ReviewViewHolder(binding) + } + else -> throw IllegalStateException() + } + } + + override fun onBindViewHolder(holder: BaseDetailViewHolder, position: Int) { + holder.onBind(items[position]) + } + + override fun getItemCount(): Int { + return items.size + } + + override fun getItemViewType(position: Int): Int { + return items[position].viewType + } + + fun setItems(data: List) { + items.clear() + items.addAll(data) + notifyDataSetChanged() + } +} + +class TitleViewHolder(private val binding: TitleItemBinding) : BaseDetailViewHolder(binding.root) { + + override fun onBind(data: BaseDetailItem) { + if (data is TitleItem) { + binding.tvTitle.text = data.title + } + } + + companion object { + const val TYPE = 0 + } +} + +class DescriptionViewHolder(private val binding: DescriptionItemBinding) : + BaseDetailViewHolder(binding.root) { + + + override fun onBind(data: BaseDetailItem) { + if (data is DescriptionItem) { + binding.tvDescription.text = data.description + } + } + + companion object { + const val TYPE = 1 + } +} + +class ReviewViewHolder(private val binding: ReviewItemBinding) : + BaseDetailViewHolder(binding.root) { + + override fun onBind(data: BaseDetailItem) { + if (data is ReviewItem) { + binding.tvReviewContent.text = data.review.content + binding.tvReviewerName.text = data.review.name + binding.tvReviewDate.text = data.review.date + } + } + + companion object { + const val TYPE = 2 + } +} + +abstract class BaseDetailViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + + + abstract fun onBind(data: BaseDetailItem) +} diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/DetailsFragment.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/DetailsFragment.kt new file mode 100644 index 00000000..d089d3d7 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/DetailsFragment.kt @@ -0,0 +1,56 @@ +package com.github.hramogin.movieapp.presentation.screens.details + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.navigation.fragment.navArgs +import com.github.hramogin.movieapp.BuildConfig +import com.github.hramogin.movieapp.R +import com.github.hramogin.movieapp.data.network.GlideApp +import com.github.hramogin.movieapp.databinding.FragmentDetailsBinding +import com.github.hramogin.movieapp.navigation.AppNavigator.onHandleBack +import com.github.hramogin.movieapp.presentation.base.BaseFragment +import org.koin.android.ext.android.inject +import org.koin.core.parameter.parametersOf + +class DetailsFragment : BaseFragment() { + + override val viewModel: DetailsViewModel by inject { parametersOf(param.filmId) } + + override fun getLayoutId(): Int = R.layout.fragment_details + + private val param: DetailsFragmentArgs by navArgs() + + private lateinit var adapter: DetailsAdapter + + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentDetailsBinding = + { inflater, container, attached -> + FragmentDetailsBinding.inflate(inflater, container, attached) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.ivMovieImage.transitionName = param.filmId + binding.toolbar.setNavigationOnClickListener { + onHandleBack(this) + } + initRecycler() + + viewModel.movieDetails.observe(viewLifecycleOwner) { + binding.collapsingToolbarLayout.title = it.title + GlideApp.with(requireContext()) + .load(BuildConfig.BASE_IMAGE_URL + it.posterPath) + .dontTransform() + .into(binding.ivMovieImage) + } + viewModel.detailsItem.observe(viewLifecycleOwner) { + adapter.setItems(it) + } + } + + private fun initRecycler() { + adapter = DetailsAdapter() + binding.rvMovieDetailsContent.adapter = adapter + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/DetailsViewModel.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/DetailsViewModel.kt new file mode 100644 index 00000000..d18658f6 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/DetailsViewModel.kt @@ -0,0 +1,40 @@ +package com.github.hramogin.movieapp.presentation.screens.details + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.github.hramogin.movieapp.domain.model.MovieDetails +import com.github.hramogin.movieapp.domain.useCases.movie.GetFilmDetailsUseCase +import com.github.hramogin.movieapp.presentation.base.viewModel.BaseViewModel + +class DetailsViewModel( + private val id: String, + private val getFilmDetailsUseCase: GetFilmDetailsUseCase, + private val movieDetailsMapper: MovieDetailsMapper, +) : BaseViewModel() { + + private val _filmId: MutableLiveData = MutableLiveData() + private val _movieDetails: MutableLiveData = MutableLiveData() + private val _detailsItem: MutableLiveData> = MutableLiveData() + + val movieDetails: LiveData = _movieDetails + val detailsItem: LiveData> = _detailsItem + + override fun onCreate() { + super.onCreate() + loadFilmDetails() + } + + private fun loadFilmDetails() { + getFilmDetailsUseCase.invoke(viewModelScope, GetFilmDetailsUseCase.Param(id)) { + it.either(::onHandleError, ::onFilmDetailsLoaded) + } + } + + private fun onFilmDetailsLoaded(movieDetails: MovieDetails) { + _filmId.value = movieDetails.id + _movieDetails.value = movieDetails + _detailsItem.value = movieDetailsMapper.map(movieDetails) + return + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/MovieDetailsMapper.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/MovieDetailsMapper.kt new file mode 100644 index 00000000..e6a826d5 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/details/MovieDetailsMapper.kt @@ -0,0 +1,25 @@ +package com.github.hramogin.movieapp.presentation.screens.details + +import com.github.hramogin.movieapp.R +import com.github.hramogin.movieapp.domain.model.MovieDetails +import com.github.hramogin.movieapp.presentation.base.ResourceProvider +import javax.inject.Inject + +class MovieDetailsMapper @Inject constructor(private val resourceProvider: ResourceProvider) { + + fun map(details: MovieDetails): List { + val items: MutableList = mutableListOf() + + if (details.description.isNotEmpty()) { + items.add(TitleItem(title = resourceProvider.getString(R.string.movie_details_description_title))) + items.add(DescriptionItem(description = details.description)) + } + + if (details.reviews.isNotEmpty()) { + items.add(TitleItem(title = resourceProvider.getString(R.string.movie_details_reviews_title))) + items.addAll(details.reviews.map { ReviewItem(review = it) }) + } + + return items + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/MoviesListFragment.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/MoviesListFragment.kt new file mode 100644 index 00000000..4471bcd8 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/MoviesListFragment.kt @@ -0,0 +1,56 @@ +package com.github.hramogin.movieapp.presentation.screens.moviesList + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.github.hramogin.movieapp.R +import com.github.hramogin.movieapp.databinding.FragmentMoviesListBinding +import com.github.hramogin.movieapp.navigation.AppNavigator +import com.github.hramogin.movieapp.presentation.base.BaseFragment +import com.github.hramogin.movieapp.presentation.base.extensions.observeEvent +import com.github.hramogin.movieapp.presentation.screens.moviesList.adapter.MovieItemDecorator +import com.github.hramogin.movieapp.presentation.screens.moviesList.adapter.MoviesListAdapter +import org.koin.androidx.viewmodel.ext.android.viewModel + +class MoviesListFragment : BaseFragment() { + + override val viewModel: MoviesListViewModel by viewModel() + + override fun getLayoutId(): Int = R.layout.fragment_movies_list + + private lateinit var adapter: MoviesListAdapter + + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentMoviesListBinding = + { inflater, container, attached -> + FragmentMoviesListBinding.inflate(inflater, container, attached) + } + + private fun initViews() { + adapter = MoviesListAdapter { movie, view -> + AppNavigator.toDetailsScreen(this, movie.id, view) + } + binding.rwMoviesList.adapter = adapter + binding.rwMoviesList.addItemDecoration(MovieItemDecorator()) + binding.rwMoviesList.apply { + postponeEnterTransition() + viewTreeObserver + .addOnPreDrawListener { + startPostponedEnterTransition() + true + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initViews() + viewModel.openDetailsScreenAction.observeEvent(viewLifecycleOwner) { + AppNavigator.toDetailsScreen(this, it, binding.rwMoviesList) + } + + viewModel.moviesLiveData.observe(viewLifecycleOwner) { + adapter.setNewData(it) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/MoviesListViewModel.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/MoviesListViewModel.kt new file mode 100644 index 00000000..977922f3 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/MoviesListViewModel.kt @@ -0,0 +1,34 @@ +package com.github.hramogin.movieapp.presentation.screens.moviesList + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.github.hramogin.movieapp.domain.model.Movie +import com.github.hramogin.movieapp.domain.useCases.movie.GetMoviesUseCase +import com.github.hramogin.movieapp.presentation.base.viewModel.ActionLiveData +import com.github.hramogin.movieapp.presentation.base.viewModel.BaseViewModel +import com.github.hramogin.movieapp.presentation.base.viewModel.Event + +class MoviesListViewModel(private val getMoviesUseCase: GetMoviesUseCase) : BaseViewModel() { + + private val _openDetailsAction: ActionLiveData> = ActionLiveData() + private val _moviesLiveData: MutableLiveData> = MutableLiveData() + + val openDetailsScreenAction: LiveData> = _openDetailsAction + val moviesLiveData: LiveData> = _moviesLiveData + + override fun onCreate() { + super.onCreate() + loadMovies() + } + + private fun loadMovies() { + getMoviesUseCase.invoke(viewModelScope, GetMoviesUseCase.Params()) { + it.either(::onHandleError, ::onFilmsLoaded) + } + } + + private fun onFilmsLoaded(movies: List) { + _moviesLiveData.value = movies + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/adapter/MovieItemDecorator.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/adapter/MovieItemDecorator.kt new file mode 100644 index 00000000..a669b753 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/adapter/MovieItemDecorator.kt @@ -0,0 +1,40 @@ +package com.github.hramogin.movieapp.presentation.screens.moviesList.adapter + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.github.hramogin.movieapp.R + +class MovieItemDecorator() : RecyclerView.ItemDecoration() { + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val position = parent.getChildAdapterPosition(view) + + if (position == RecyclerView.NO_POSITION) { + return + } + + val sideMargin = + view.context.resources.getDimension(R.dimen.base_margi).toInt() + val innerMargin = view.context.resources.getDimension(R.dimen.base_margi).toInt() / 2 + + if (position % 2 == 0) { + outRect.set(sideMargin, innerMargin, innerMargin, innerMargin) + } else { + outRect.set(innerMargin, innerMargin, sideMargin, innerMargin) + } + + val adapter = parent.adapter ?: return + + if (position == adapter.itemCount - 1 || + position == adapter.itemCount - 2 + ) { + outRect.bottom = sideMargin * 2 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/adapter/MovieViewHolder.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/adapter/MovieViewHolder.kt new file mode 100644 index 00000000..5478ebd5 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/adapter/MovieViewHolder.kt @@ -0,0 +1,65 @@ +package com.github.hramogin.movieapp.presentation.screens.moviesList.adapter + +import android.graphics.drawable.Drawable +import android.view.View +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.Target +import com.github.hramogin.movieapp.BuildConfig +import com.github.hramogin.movieapp.R +import com.github.hramogin.movieapp.data.network.GlideApp +import com.github.hramogin.movieapp.databinding.LayoutMovieItemBinding +import com.github.hramogin.movieapp.domain.model.Movie +import com.github.hramogin.movieapp.presentation.base.adapter.BaseViewHolder +import com.github.hramogin.movieapp.presentation.base.toPx + +class MovieViewHolder( + val binding: LayoutMovieItemBinding, +) : BaseViewHolder(binding) { + + override fun onBind(item: Movie?) { + super.onBind(item) + item ?: return + + binding.tvMovieTitle.text = item.title + val requestOptions = RequestOptions().transforms( + CenterCrop(), RoundedCorners( + itemView.context.resources.getDimension( + R.dimen.base_round_corner + ).toPx + ) + ) + GlideApp.with(itemView.context) + .load(BuildConfig.BASE_IMAGE_URL + item.posterPath) + .apply(requestOptions) + .listener(object : RequestListener { + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean + ): Boolean { + binding.tvMovieTitle.text = item.title + binding.tvMovieTitle.visibility = View.VISIBLE + binding.vTitleContainer.visibility = View.VISIBLE + + return false + } + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean + ): Boolean { + return false + } + }) + .into(binding.ivMovieImage) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/adapter/MoviesListAdapter.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/adapter/MoviesListAdapter.kt new file mode 100644 index 00000000..7e6a51c5 --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/moviesList/adapter/MoviesListAdapter.kt @@ -0,0 +1,33 @@ +package com.github.hramogin.movieapp.presentation.screens.moviesList.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.github.hramogin.movieapp.databinding.LayoutMovieItemBinding +import com.github.hramogin.movieapp.domain.model.Movie +import com.github.hramogin.movieapp.presentation.base.adapter.BaseAdapter +import com.github.hramogin.movieapp.presentation.base.adapter.BaseViewHolder + +class MoviesListAdapter(private val movieClickListener: (Movie, View) -> Unit) : BaseAdapter() { + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BaseViewHolder { + val binding = + LayoutMovieItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return MovieViewHolder(binding) + } + + override fun onBindViewHolder( + holder: BaseViewHolder, + position: Int + ) { + super.onBindViewHolder(holder, position) + if(holder is MovieViewHolder) { + holder.itemView.setOnClickListener { + movieClickListener.invoke(items[position], holder.binding.ivMovieImage) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/splash/SplashFragment.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/splash/SplashFragment.kt new file mode 100644 index 00000000..ec876b3c --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/splash/SplashFragment.kt @@ -0,0 +1,36 @@ +package com.github.hramogin.movieapp.presentation.screens.splash + +import android.os.Bundle +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.github.hramogin.movieapp.R +import com.github.hramogin.movieapp.databinding.FragmentSplashBinding +import com.github.hramogin.movieapp.navigation.AppNavigator +import com.github.hramogin.movieapp.presentation.base.BaseFragment +import com.github.hramogin.movieapp.presentation.base.extensions.observeEvent +import org.koin.androidx.viewmodel.ext.android.viewModel + +class SplashFragment : BaseFragment() { + + override val viewModel: SplashViewModel by viewModel() + + override fun getLayoutId(): Int = R.layout.fragment_splash + + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentSplashBinding = + { inflater, container, attached -> + val contextThemeWrapper = + ContextThemeWrapper(activity, R.style.AppTheme_MovieApp_Splash) + val localInflater = inflater.cloneInContext(contextThemeWrapper) + FragmentSplashBinding.inflate(localInflater, container, attached) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.openMainScreenAction.observeEvent(viewLifecycleOwner) { + AppNavigator.toMainScreen(this) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/splash/SplashViewModel.kt b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/splash/SplashViewModel.kt new file mode 100644 index 00000000..2a95439a --- /dev/null +++ b/app/src/main/java/com/github/hramogin/movieapp/presentation/screens/splash/SplashViewModel.kt @@ -0,0 +1,32 @@ +package com.github.hramogin.movieapp.presentation.screens.splash + +import androidx.lifecycle.LiveData +import androidx.lifecycle.viewModelScope +import com.github.hramogin.movieapp.presentation.base.viewModel.ActionLiveData +import com.github.hramogin.movieapp.presentation.base.viewModel.BaseViewModel +import com.github.hramogin.movieapp.presentation.base.viewModel.Event +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class SplashViewModel : BaseViewModel() { + + private val _openMainAction: ActionLiveData> = ActionLiveData() + + val openMainScreenAction: LiveData> = _openMainAction + + override fun onViewCreated() { + super.onViewCreated() + openRequiredScreen() + } + + private fun openRequiredScreen() { + viewModelScope.launch { + delay(SPLASH_DURATION) + _openMainAction.value = Event(Any()) + } + } + + private companion object { + const val SPLASH_DURATION = 3000L + } +} \ No newline at end of file diff --git a/app/src/main/res/anim/fade_in.xml b/app/src/main/res/anim/fade_in.xml new file mode 100644 index 00000000..a8b83f8e --- /dev/null +++ b/app/src/main/res/anim/fade_in.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml new file mode 100644 index 00000000..116e700b --- /dev/null +++ b/app/src/main/res/anim/fade_out.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_left.xml b/app/src/main/res/anim/slide_in_left.xml new file mode 100644 index 00000000..148de843 --- /dev/null +++ b/app/src/main/res/anim/slide_in_left.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_right.xml b/app/src/main/res/anim/slide_in_right.xml new file mode 100644 index 00000000..cc5a30a3 --- /dev/null +++ b/app/src/main/res/anim/slide_in_right.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_left.xml b/app/src/main/res/anim/slide_out_left.xml new file mode 100644 index 00000000..575237e5 --- /dev/null +++ b/app/src/main/res/anim/slide_out_left.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_right.xml b/app/src/main/res/anim/slide_out_right.xml new file mode 100644 index 00000000..0e5f9e61 --- /dev/null +++ b/app/src/main/res/anim/slide_out_right.xml @@ -0,0 +1,14 @@ + + + + + + \ 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/bg_gray_rounded.xml b/app/src/main/res/drawable/bg_gray_rounded.xml new file mode 100644 index 00000000..0126e317 --- /dev/null +++ b/app/src/main/res/drawable/bg_gray_rounded.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_gray_rounded_bottom.xml b/app/src/main/res/drawable/bg_gray_rounded_bottom.xml new file mode 100644 index 00000000..d1b865bb --- /dev/null +++ b/app/src/main/res/drawable/bg_gray_rounded_bottom.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file 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_left_arrow.xml b/app/src/main/res/drawable/ic_left_arrow.xml new file mode 100644 index 00000000..cee03e16 --- /dev/null +++ b/app/src/main/res/drawable/ic_left_arrow.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/logo.PNG b/app/src/main/res/drawable/logo.PNG new file mode 100644 index 00000000..969e6213 Binary files /dev/null and b/app/src/main/res/drawable/logo.PNG differ 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..847d4696 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/description_item.xml b/app/src/main/res/layout/description_item.xml new file mode 100644 index 00000000..846b935a --- /dev/null +++ b/app/src/main/res/layout/description_item.xml @@ -0,0 +1,18 @@ + + + + + + \ 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..61335ddb --- /dev/null +++ b/app/src/main/res/layout/fragment_details.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_movies_list.xml b/app/src/main/res/layout/fragment_movies_list.xml new file mode 100644 index 00000000..1ab7bc5c --- /dev/null +++ b/app/src/main/res/layout/fragment_movies_list.xml @@ -0,0 +1,29 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_splash.xml b/app/src/main/res/layout/fragment_splash.xml new file mode 100644 index 00000000..968fdbe2 --- /dev/null +++ b/app/src/main/res/layout/fragment_splash.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/layout_movie_item.xml b/app/src/main/res/layout/layout_movie_item.xml new file mode 100644 index 00000000..8cf55332 --- /dev/null +++ b/app/src/main/res/layout/layout_movie_item.xml @@ -0,0 +1,51 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/review_item.xml b/app/src/main/res/layout/review_item.xml new file mode 100644 index 00000000..a9ce0060 --- /dev/null +++ b/app/src/main/res/layout/review_item.xml @@ -0,0 +1,50 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/title_item.xml b/app/src/main/res/layout/title_item.xml new file mode 100644 index 00000000..5830e3f5 --- /dev/null +++ b/app/src/main/res/layout/title_item.xml @@ -0,0 +1,22 @@ + + + + + + \ 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/general_navigation.xml b/app/src/main/res/navigation/general_navigation.xml new file mode 100644 index 00000000..457fd682 --- /dev/null +++ b/app/src/main/res/navigation/general_navigation.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..6319378c --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ 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..b490282c --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,15 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + + #BFBFBF + #F2BFBFBF + #d59944 + #588693 + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..6f1c49eb --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,16 @@ + + + 8dp + 4dp + 10dp + 16dp + 16sp + 400dp + 336dp + 300dp + 50dp + 12sp + 20dp + 40dp + 36sp + \ 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..bba793c9 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + MovieApp + Try later + + More about movie + Reviews + Best movies + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..89e0df42 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ 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..a7cf6acd --- /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/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..fa0f996d --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..9ee9997b --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/com/github/hramogin/movieapp/ExampleUnitTest.kt b/app/src/test/java/com/github/hramogin/movieapp/ExampleUnitTest.kt new file mode 100644 index 00000000..18f29d83 --- /dev/null +++ b/app/src/test/java/com/github/hramogin/movieapp/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.github.hramogin.movieapp + +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/github/hramogin/movieapp/domain/useCases/movie/GetMoviesUseCaseTest.kt b/app/src/test/java/com/github/hramogin/movieapp/domain/useCases/movie/GetMoviesUseCaseTest.kt new file mode 100644 index 00000000..17e4636c --- /dev/null +++ b/app/src/test/java/com/github/hramogin/movieapp/domain/useCases/movie/GetMoviesUseCaseTest.kt @@ -0,0 +1,66 @@ +package com.github.hramogin.movieapp.domain.useCases.movie + +import com.github.hramogin.movieapp.data.repository.movie.MovieRepository +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verifyBlocking +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@DelicateCoroutinesApi +@ExperimentalCoroutinesApi +internal class GetMoviesUseCaseTest { + + + private val mainThreadSurrogate = newSingleThreadContext("UI thread") + + @Before + fun setUp() { + Dispatchers.setMain(mainThreadSurrogate) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + mainThreadSurrogate.close() + } + + + @Test + fun `check fetch data from server and store in db`() = runBlocking { + with(createEnvironment()) { + getMoviesUseCase.invoke(this@runBlocking, mock(), mock()) + + verifyBlocking(repository) { getFilmsFromServer() } + verifyBlocking(repository) { setFilmsToDb(any()) } + verifyBlocking(repository, never()) { getFilmsFromDb() } + } + } + + private class Environment( + val getMoviesUseCase: GetMoviesUseCase, + val repository: MovieRepository, + ) + + private fun createEnvironment(): Environment { + val repository: MovieRepository = mock { + onBlocking { getFilmsFromServer() } doReturn emptyList() + onBlocking { setFilmsToDb(any()) } doReturn Unit + onBlocking { getFilmsFromDb() } doReturn emptyList() + } + return Environment( + getMoviesUseCase = GetMoviesUseCase(repository), + repository = repository, + ) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/github/hramogin/movieapp/presentation/screens/moviesList/MoviesListViewModelTest.kt b/app/src/test/java/com/github/hramogin/movieapp/presentation/screens/moviesList/MoviesListViewModelTest.kt new file mode 100644 index 00000000..fc7b7607 --- /dev/null +++ b/app/src/test/java/com/github/hramogin/movieapp/presentation/screens/moviesList/MoviesListViewModelTest.kt @@ -0,0 +1,37 @@ +package com.github.hramogin.movieapp.presentation.screens.moviesList + +import com.github.hramogin.movieapp.domain.useCases.movie.GetMoviesUseCase +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verifyBlocking +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.mockito.Mockito + +internal class MoviesListViewModelTest { + + @Test + fun `check view model call getMoviesUseCase during create`() = runBlocking { + with(createEnvironment()) { + viewModel.onCreate() + + verifyBlocking(getMoviesUseCase) { invoke(any(), any(), any()) } + } + } + + private class Environment( + val viewModel: MoviesListViewModel, + val getMoviesUseCase: GetMoviesUseCase + ) + + private fun createEnvironment(): Environment { + val getMoviesUseCase: GetMoviesUseCase = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) { + on { invoke(any(), any(), any()) } doReturn mock() + } + return Environment( + MoviesListViewModel(getMoviesUseCase), + getMoviesUseCase = getMoviesUseCase, + ) + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..d9f8af26 --- /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 { + ext.nav_version = "2.3.0-beta01" + dependencies { + classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version" + } +} +plugins { + id 'com.android.application' version '7.2.2' apply false + id 'com.android.library' version '7.2.2' apply false + id 'org.jetbrains.kotlin.android' version '1.6.10' apply false +} + +apply from: 'dependencies.gradle' + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/dependencies.gradle b/dependencies.gradle new file mode 100644 index 00000000..4445be12 --- /dev/null +++ b/dependencies.gradle @@ -0,0 +1,172 @@ +ext.androidConfig = [ + compileSdkVersion : 30, + buildToolsVersion : '30.0.2', + minSdkVersion : 21, + targetSdkVersion : 30, +] + +ext.versions = [ + kotlinVersion : '1.7.0', + + legacySupport : '1.0.0', + + // Coroutines + coroutines : '1.6.4', + + // Design + appCompat : '1.1.0', + constraintLayout : '1.1.3', + recyclerView : '1.0.0', + cardView : '1.0.0', + material : '1.2.1', + + // ViewModel + lifecycleKtx : '2.2.0-beta01', + + lifecycle : '2.1.0', + + glide : '4.10.0', + glideTransformations : '4.0.0', + + timber : '4.7.1', + + // Common + coreKtx : '1.2.0', + coreX : '1.2.0', + + koin : '3.2.2', + + crashlytics : '2.10.1@aar', + + // Retrofit + retrofit : '2.6.2', + okhttp : '4.2.2', + gson : '2.8.6', + + // Test + junit : '4.12', + junitExtention : '1.1.1', + runner : '1.2.0', + espresso : '3.2.0', + + swipeLayout : '1.2.0@aar', + + socketIO : '1.0.0', + + chromeTabs : '2.0.3', + browser : '1.3.0', + + firebaseAnalytics : '18.0.0', + firebaseCloudMessaging: '20.1.0', + firebaseCrashlytics : '17.3.0', + + stetho : '1.5.1', + leakcanary : '2.2', + + room : '2.4.2', + + navigation : '2.3.0-alpha05', + + imageCrop : '2.8.0', + paging : '2.1.0-alpha01', + + jsoup : '1.11.2', + + apollo : '2.5.2', + + lottie : '3.6.0', + + worldCountryData : '1.5.2', + + mockotlin : '2.2.0', + coreTestX : '2.1.0', +] + +ext.libraries = [ + kotlinStdLib : "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$versions.kotlinVersion", + legacySupport : "androidx.legacy:legacy-support-v4:$versions.legacySupport", + + coroutinesCore : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.coroutines", + coroutinesAndroid : "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.coroutines", + coroutinesTest : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$versions.coroutines", + androidXCoreTesting : "androidx.arch.core:core-testing:$versions.coreTestX", + + appCompat : "androidx.appcompat:appcompat:$versions.appCompat", + constraintLayout : "androidx.constraintlayout:constraintlayout:$versions.constraintLayout", + recyclerView : "androidx.recyclerview:recyclerview:$versions.recyclerView", + cardView : "androidx.cardview:cardview:$versions.cardView", + material : "com.google.android.material:material:$versions.material", + + lifecycleViewModelKtx : "androidx.lifecycle:lifecycle-viewmodel-ktx:$versions.lifecycleKtx", + lifecycleLiveDataKtx : "androidx.lifecycle:lifecycle-livedata-ktx:$versions.lifecycleKtx", + + lifecycleRuntime : "androidx.lifecycle:lifecycle-runtime:$versions.lifecycle", + lifecycleCompiler : "androidx.lifecycle:lifecycle-compiler:$versions.lifecycle", + + coreKtx : "androidx.core:core-ktx:$versions.coreKtx", + coreX : "androidx.core:core:$versions.coreX", + coreTestX : "androidx.test:core:$versions.coreX", + + glide : "com.github.bumptech.glide:glide:$versions.glide", + glideCompiler : "com.github.bumptech.glide:compiler:$versions.glide", + glideTransformations : "jp.wasabeef:glide-transformations:$versions.glideTransformations", + + timber : "com.jakewharton.timber:timber:$versions.timber", + + koinCore : "io.insert-koin:koin-core:$versions.koin", + koinAndroid : "io.insert-koin:koin-android:$versions.koin", + koinViewModel : "io.insert-koin:koin-androidx-viewmodel:$versions.koin", + koinTest : "io.insert-koiio.insert-koin:koin-androidx-navigationn:koin-test:$versions.koin", + koinNavigation : "io.insert-koin:koin-androidx-navigation:$versions.koin", + + retrofit : "com.squareup.retrofit2:retrofit:$versions.retrofit", + retrofitConverterGson : "com.squareup.retrofit2:converter-gson:$versions.retrofit", + okhttp : "com.squareup.okhttp3:okhttp:$versions.okhttp", + okhttpLoggingInterceptor: "com.squareup.okhttp3:logging-interceptor:$versions.okhttp", + + gson : "com.google.code.gson:gson:$versions.gson", + + crashlytics : "com.crashlytics.sdk.android:crashlytics:$versions.crashlytics", + + junit : "junit:junit:$versions.junit", + junitExtention : "androidx.test.ext:junit:$versions.junitExtention", + runner : "androidx.test:runner:$versions.runner", + espresso : "androidx.test.espresso:espresso-core:$versions.espresso", + + swipeLayout : "com.daimajia.swipelayout:library:$versions.swipeLayout", + + socketIO : "io.socket:socket.io-client:$versions.socketIO", + + chromeTabs : "saschpe.android:customtabs:$versions.chromeTabs", + browser : "androidx.browser:browser:$versions.browser", + + firebaseAnalytics : "com.google.firebase:firebase-analytics:$versions.firebaseAnalytics", + firebaseCloudMessaging : "com.google.firebase:firebase-messaging:$versions.firebaseCloudMessaging", + firebaseCrashlytics : "com.google.firebase:firebase-crashlytics:$versions.firebaseCrashlytics", + + stetho : "com.facebook.stetho:stetho-okhttp3:$versions.stetho", + + leakcanary : "com.squareup.leakcanary:leakcanary-android:$versions.leakcanary", + + room : "androidx.room:room-runtime:$versions.room", + annotationProcessorRoom : "androidx.room:room-compiler:$versions.room", + roomKtx : "androidx.room:room-ktx:$versions.room", + roomTesting : "androidx.room:room-testing:$versions.room", + + navigation : "androidx.navigation:navigation-fragment-ktx:$versions.navigation", + navigationKtx : "androidx.navigation:navigation-ui-ktx:$versions.navigation", + + imageCrop : "com.theartofdev.edmodo:android-image-cropper:$versions.imageCrop", + paging : "androidx.paging:paging-runtime:$versions.paging", + + jsoup : "org.jsoup:jsoup:$versions.jsoup", + + apolloRuntime : "com.apollographql.apollo:apollo-runtime:$versions.apollo", + apolloSupport : "com.apollographql.apollo:apollo-android-support:$versions.apollo", + apolloCoroutine : "com.apollographql.apollo:apollo-coroutines-support:$versions.apollo", + + lottie : "com.airbnb.android:lottie:$versions.lottie", + + worldCountryData : "com.github.blongho:worldCountryData:$versions.worldCountryData", + mockotlin : "com.nhaarman.mockitokotlin2:mockito-kotlin:$versions.mockotlin", +] \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..cd0519bb --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# 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 +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e708b1c0 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..6974408a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Oct 14 22:01:09 MSK 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-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..37f18458 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "MovieApp" +include ':app'