diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..56cc6425 --- /dev/null +++ b/.gitignore @@ -0,0 +1,85 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ diff --git a/Design/Compilation.png b/Design/Compilation.png new file mode 100644 index 00000000..c4f16bf6 Binary files /dev/null and b/Design/Compilation.png differ diff --git a/Design/Favorite page.png b/Design/Favorite page.png new file mode 100644 index 00000000..eb7585a8 Binary files /dev/null and b/Design/Favorite page.png differ diff --git a/Design/Movie details page.png b/Design/Movie details page.png new file mode 100644 index 00000000..9e3ab476 Binary files /dev/null and b/Design/Movie details page.png differ diff --git a/Design/Movies page.png b/Design/Movies page.png new file mode 100644 index 00000000..4255cd0e Binary files /dev/null and b/Design/Movies page.png differ diff --git a/Design/Notes.png b/Design/Notes.png new file mode 100644 index 00000000..751039c2 Binary files /dev/null and b/Design/Notes.png differ diff --git a/Design/Search page.png b/Design/Search page.png new file mode 100644 index 00000000..7342aa8f Binary files /dev/null and b/Design/Search page.png differ diff --git a/Design/UI Design.psd b/Design/UI Design.psd new file mode 100644 index 00000000..b259ece4 Binary files /dev/null and b/Design/UI Design.psd differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..35bb1dd1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Casper BL + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Movies/.gitignore b/Movies/.gitignore new file mode 100644 index 00000000..aa724b77 --- /dev/null +++ b/Movies/.gitignore @@ -0,0 +1,15 @@ +*.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 diff --git a/Movies/.idea/.gitignore b/Movies/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/Movies/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/Movies/.idea/compiler.xml b/Movies/.idea/compiler.xml new file mode 100644 index 00000000..61a9130c --- /dev/null +++ b/Movies/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Movies/.idea/dictionaries/Casper.xml b/Movies/.idea/dictionaries/Casper.xml new file mode 100644 index 00000000..a2d072e5 --- /dev/null +++ b/Movies/.idea/dictionaries/Casper.xml @@ -0,0 +1,9 @@ + + + + gson + themoviedb + tmdb + + + \ No newline at end of file diff --git a/Movies/.idea/gradle.xml b/Movies/.idea/gradle.xml new file mode 100644 index 00000000..23a89bbb --- /dev/null +++ b/Movies/.idea/gradle.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/Movies/.idea/inspectionProfiles/Project_Default.xml b/Movies/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..c83a369c --- /dev/null +++ b/Movies/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/Movies/.idea/jarRepositories.xml b/Movies/.idea/jarRepositories.xml new file mode 100644 index 00000000..a5f05cd8 --- /dev/null +++ b/Movies/.idea/jarRepositories.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/Movies/.idea/markdown-navigator-enh.xml b/Movies/.idea/markdown-navigator-enh.xml new file mode 100644 index 00000000..a8fcc84d --- /dev/null +++ b/Movies/.idea/markdown-navigator-enh.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Movies/.idea/markdown-navigator.xml b/Movies/.idea/markdown-navigator.xml new file mode 100644 index 00000000..a2fc0864 --- /dev/null +++ b/Movies/.idea/markdown-navigator.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Movies/.idea/misc.xml b/Movies/.idea/misc.xml new file mode 100644 index 00000000..d5d35ec4 --- /dev/null +++ b/Movies/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/Movies/.idea/vcs.xml b/Movies/.idea/vcs.xml new file mode 100644 index 00000000..6c0b8635 --- /dev/null +++ b/Movies/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Movies/app/.gitignore b/Movies/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/Movies/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Movies/app/build.gradle b/Movies/app/build.gradle new file mode 100644 index 00000000..4fd4ae22 --- /dev/null +++ b/Movies/app/build.gradle @@ -0,0 +1,111 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' +} + +android { + compileSdkVersion 30 + buildToolsVersion "30.0.3" + + buildFeatures { + viewBinding true + dataBinding true + } + defaultConfig { + applicationId "com.daresay.movies" + minSdkVersion 23 + targetSdkVersion 30 + versionCode 1 + versionName "1.0.0" + + buildConfigField("String", "TMDB_API_KEY", "\"" + getTmdbApiKey() + "\"") + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + // Google + implementation "org.jetbrains.kotlin:kotlin-stdlib:$rootProject.kotlin_version" + implementation "androidx.core:core-ktx:$rootProject.ktx_version" + implementation "androidx.appcompat:appcompat:$rootProject.app_compat_version" + implementation "com.google.android.material:material:$rootProject.material_design_version" + implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraint_layout_version" + implementation "androidx.recyclerview:recyclerview:$rootProject.recycler_view_version" + implementation "androidx.viewpager2:viewpager2:$rootProject.view_pager_version" + implementation "com.google.android:flexbox:$rootProject.flexbox_version" + implementation "androidx.paging:paging-runtime-ktx:$rootProject.paging_version" + + // Navigation + implementation "androidx.navigation:navigation-fragment-ktx:$rootProject.nav_version" + implementation "androidx.navigation:navigation-ui-ktx:$rootProject.nav_version" + + // Facebook + implementation "com.facebook.shimmer:shimmer:$rootProject.facebook_shimmer_version" + + // Room + implementation "androidx.room:room-runtime:$rootProject.room_version" + kapt "androidx.room:room-compiler:$rootProject.room_version" + implementation "androidx.room:room-ktx:$rootProject.room_version" + + // Retrofit + implementation "com.squareup.retrofit2:converter-gson:$rootProject.retrofit_version" + implementation "com.squareup.retrofit2:retrofit:$rootProject.retrofit_version" + implementation "com.squareup.okhttp3:logging-interceptor:$rootProject.okhttp_logging_version" + + // Hilt + implementation "com.google.dagger:hilt-android:$rootProject.hilt_version" + implementation "androidx.hilt:hilt-lifecycle-viewmodel:$rootProject.hilt_compiler_version" + kapt "com.google.dagger:hilt-android-compiler:$rootProject.hilt_version" + kapt "androidx.hilt:hilt-compiler:$rootProject.hilt_compiler_version" + + // Ktx Coroutines + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutines_version" + + // Lifecycle + implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.lifecycle_version" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$rootProject.lifecycle_version_ktx" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.lifecycle_version_ktx" + + // Timber (logging) + implementation "com.jakewharton.timber:timber:$rootProject.timer_version" + + // SpinKit (loading animations) + implementation "com.github.ybq:Android-SpinKit:$rootProject.spinkit_version" + + // Glide + implementation "com.github.bumptech.glide:glide:$rootProject.glide_version" + kapt "com.github.bumptech.glide:compiler:$rootProject.glide_version" + + // Debug database + debugImplementation "com.amitshekhar.android:debug-db:$rootProject.debug_db_version" + + // Testing + testImplementation "junit:junit:$rootProject.junit_version" + androidTestImplementation "androidx.test.ext:junit:$rootProject.junit_test_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$rootProject.espresso_core_version" + testImplementation "androidx.room:room-testing:$rootProject.room_version" +} + +kapt { + generateStubs = true +} + +def getTmdbApiKey() { + return project.findProperty("tmdb_api_key") +} \ No newline at end of file diff --git a/Movies/app/proguard-rules.pro b/Movies/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/Movies/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/Movies/app/src/androidTest/java/com/daresay/movies/ExampleInstrumentedTest.kt b/Movies/app/src/androidTest/java/com/daresay/movies/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..e8d7652d --- /dev/null +++ b/Movies/app/src/androidTest/java/com/daresay/movies/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.daresay.movies + +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.daresay.movies", appContext.packageName) + } +} \ No newline at end of file diff --git a/Movies/app/src/main/AndroidManifest.xml b/Movies/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..cb4d9930 --- /dev/null +++ b/Movies/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/App.kt b/Movies/app/src/main/java/com/daresay/movies/App.kt new file mode 100644 index 00000000..9686883d --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/App.kt @@ -0,0 +1,15 @@ +package com.daresay.movies + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber + +@HiltAndroidApp +class App : Application() { + override fun onCreate() { + super.onCreate() + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/api/BaseDataSource.kt b/Movies/app/src/main/java/com/daresay/movies/data/api/BaseDataSource.kt new file mode 100644 index 00000000..d8f0853f --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/api/BaseDataSource.kt @@ -0,0 +1,25 @@ +package com.daresay.movies.data.api + +import com.daresay.movies.utils.Resource +import retrofit2.Response + +abstract class BaseDataSource { + protected suspend fun getResult(call: suspend () -> Response): Resource { + try { + val response = call() + if (response.isSuccessful) { + val body = response.body() + if (body != null) + return Resource.success(body) + } + return error(" ${response.code()} ${response.message()}") + } + catch (e: Exception) { + return error(e.message ?: e.toString()) + } + } + + private fun error(message: String): Resource { + return Resource.error("Network call has failed for a following reason: $message") + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/api/TmdbRemoteDataSource.kt b/Movies/app/src/main/java/com/daresay/movies/data/api/TmdbRemoteDataSource.kt new file mode 100644 index 00000000..14bf630e --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/api/TmdbRemoteDataSource.kt @@ -0,0 +1,30 @@ +package com.daresay.movies.data.api + +import com.daresay.movies.data.models.authentication.SessionRequestBody +import javax.inject.Inject + +class TmdbRemoteDataSource @Inject constructor(private val tmdbService: TmdbService) : BaseDataSource() { + suspend fun getToken() = getResult { + tmdbService.getToken() + } + + suspend fun createSession(requestBody: SessionRequestBody) = getResult { + tmdbService.createSession(requestBody = requestBody) + } + + suspend fun getTopRatedMovies(page: Int) = getResult { + tmdbService.getTopRatedMovies(page = page) + } + + suspend fun getPopularMovies(page: Int) = getResult { + tmdbService.getPopularMovies(page = page) + } + + suspend fun getMovieDetails(movieId: Int) = getResult { + tmdbService.getMovieDetails(movieId = movieId) + } + + suspend fun getMovieReviews(movieId: Int, page: Int) = getResult { + tmdbService.getMovieReviews(movieId = movieId, page = page) + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/api/TmdbRepository.kt b/Movies/app/src/main/java/com/daresay/movies/data/api/TmdbRepository.kt new file mode 100644 index 00000000..f7a4c39b --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/api/TmdbRepository.kt @@ -0,0 +1,57 @@ +package com.daresay.movies.data.api + +import com.daresay.movies.data.local.MovieDao +import com.daresay.movies.data.models.authentication.SessionRequestBody +import com.daresay.movies.data.models.favorites.Favorite +import com.daresay.movies.utils.performDatabaseGetOperation +import com.daresay.movies.utils.performGetOperation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TmdbRepository @Inject constructor( + private val remoteDataSource: TmdbRemoteDataSource, + private val localDataSource: MovieDao) { + + fun getUserToken() = performGetOperation( + networkCall = { remoteDataSource.getToken() } + ) + + fun createSession(requestBody: SessionRequestBody) = performGetOperation( + networkCall = { remoteDataSource.createSession(requestBody = requestBody) } + ) + + fun getTopRatedMovies(page: Int) = performGetOperation( + networkCall = { remoteDataSource.getTopRatedMovies(page = page) } + ) + + fun getPopularMovies(page: Int) = performGetOperation( + networkCall = { remoteDataSource.getPopularMovies(page = page) } + ) + + fun getMovieDetails(movieId: Int) = performGetOperation( + databaseQuery = { localDataSource.getMovie(movieId) }, + networkCall = { remoteDataSource.getMovieDetails(movieId) }, + saveCallResult = { localDataSource.insert(it) }) + + fun getMovieReviews(movieId: Int, page: Int) = performGetOperation( + networkCall = { remoteDataSource.getMovieReviews(movieId = movieId, page = page) } + ) + + fun setMovieFavorite(movieId: Int, favorite: Boolean) { + CoroutineScope(IO).launch { + localDataSource.insert(Favorite(movieId, favorite)) + } + } + + fun getFavorite(movieId: Int) = performDatabaseGetOperation( + databaseQuery = { localDataSource.getFavorite(movieId) } + ) + + fun getAllFavorites() = performDatabaseGetOperation( + databaseQuery = { localDataSource.getAllFavorites() } + ) +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/api/TmdbService.kt b/Movies/app/src/main/java/com/daresay/movies/data/api/TmdbService.kt new file mode 100644 index 00000000..4f94920b --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/api/TmdbService.kt @@ -0,0 +1,50 @@ +package com.daresay.movies.data.api + +import com.daresay.movies.BuildConfig +import com.daresay.movies.data.models.authentication.RequestToken +import com.daresay.movies.data.models.authentication.SessionRequestBody +import com.daresay.movies.data.models.authentication.SessionToken +import com.daresay.movies.data.models.movie.MovieList +import com.daresay.movies.data.models.moviedetails.MovieDetails +import com.daresay.movies.data.models.moviedetails.Reviews +import retrofit2.Response +import retrofit2.http.* + +interface TmdbService { + @GET("authentication/token/new") + suspend fun getToken( + @Query("api_key") apiKey: String = BuildConfig.TMDB_API_KEY + ): Response + + @POST("authentication/session/new") + suspend fun createSession( + @Body() requestBody: SessionRequestBody, + @Query("api_key") apiKey: String = BuildConfig.TMDB_API_KEY + ): Response + + @GET("movie/top_rated") + suspend fun getTopRatedMovies( + @Query("page") page: Int, + @Query("api_key") apiKey: String = BuildConfig.TMDB_API_KEY + ): Response + + @GET("movie/popular") + suspend fun getPopularMovies( + @Query("page") page: Int, + @Query("api_key") apiKey: String = BuildConfig.TMDB_API_KEY + ): Response + + @GET("movie/{movie_id}") + suspend fun getMovieDetails( + @Path("movie_id") movieId: Int, + @Query("api_key") apiKey: String = BuildConfig.TMDB_API_KEY, + @Query("append_to_response") reviews: String = "reviews" + ): Response + + @GET("movie/{movie_id}") + suspend fun getMovieReviews( + @Path("movie_id") movieId: Int, + @Query("page") page: Int, + @Query("api_key") apiKey: String = BuildConfig.TMDB_API_KEY, + ): Response +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/local/AppDatabase.kt b/Movies/app/src/main/java/com/daresay/movies/data/local/AppDatabase.kt new file mode 100644 index 00000000..e95d9c69 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/local/AppDatabase.kt @@ -0,0 +1,27 @@ +package com.daresay.movies.data.local + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.daresay.movies.data.models.favorites.Favorite +import com.daresay.movies.data.models.moviedetails.MovieDetails + +@Database(entities = [MovieDetails::class, Favorite::class], version = 1, exportSchema = false) +@TypeConverters(MyTypeConverters::class) +abstract class AppDatabase : RoomDatabase() { + abstract fun movieDao() : MovieDao + + companion object { + @Volatile private var instance: AppDatabase? = null + + fun getDatabase(context: Context) : AppDatabase = + instance ?: synchronized(this) { instance ?: buildDatabase(context).also { instance = it } } + + private fun buildDatabase(appContext: Context) = + Room.databaseBuilder(appContext, AppDatabase::class.java, "Movies") + .fallbackToDestructiveMigration() + .build() + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/local/MovieDao.kt b/Movies/app/src/main/java/com/daresay/movies/data/local/MovieDao.kt new file mode 100644 index 00000000..ba7ec0d3 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/local/MovieDao.kt @@ -0,0 +1,25 @@ +package com.daresay.movies.data.local + +import androidx.lifecycle.LiveData +import androidx.room.* +import com.daresay.movies.data.models.favorites.Favorite +import com.daresay.movies.data.models.favorites.FavoriteWithMovieDetails +import com.daresay.movies.data.models.moviedetails.MovieDetails + +@Dao +interface MovieDao { + @Query("SELECT * FROM MovieDetails WHERE id = :movieId") + fun getMovie(movieId: Int) : LiveData + + @Query("SELECT * FROM Favorites WHERE movieId = :movieId") + fun getFavorite(movieId: Int) : LiveData + + @Query("SELECT * FROM Favorites WHERE favorite = 1") + fun getAllFavorites() : LiveData> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(movie: MovieDetails) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(favorite: Favorite) +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/local/MyTypeConverters.kt b/Movies/app/src/main/java/com/daresay/movies/data/local/MyTypeConverters.kt new file mode 100644 index 00000000..5f7d310e --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/local/MyTypeConverters.kt @@ -0,0 +1,33 @@ +package com.daresay.movies.data.local + +import androidx.room.TypeConverter +import com.daresay.movies.data.models.moviedetails.Genre +import com.daresay.movies.data.models.moviedetails.Reviews +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import javax.inject.Inject + +class MyTypeConverters { + @TypeConverter + fun genreToJson(value: List?): String { + return GsonBuilder().create().toJson(value) + } + + @TypeConverter + fun genreFromJson(value: String): List? { + val type = object : TypeToken>(){}.type + return GsonBuilder().create().fromJson(value, type) + } + + @TypeConverter + fun reviewsToJson(value: Reviews?): String { + return GsonBuilder().create().toJson(value) + } + + @TypeConverter + fun reviewsFromJson(value: String): Reviews? { + val type = object : TypeToken(){}.type + return GsonBuilder().create().fromJson(value, type) + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/models/authentication/RequestToken.kt b/Movies/app/src/main/java/com/daresay/movies/data/models/authentication/RequestToken.kt new file mode 100644 index 00000000..52b01bf9 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/models/authentication/RequestToken.kt @@ -0,0 +1,7 @@ +package com.daresay.movies.data.models.authentication + +data class RequestToken( + val expires_at: String, + val request_token: String, + val success: Boolean +) \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/models/authentication/SessionRequestBody.kt b/Movies/app/src/main/java/com/daresay/movies/data/models/authentication/SessionRequestBody.kt new file mode 100644 index 00000000..b26a7b7e --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/models/authentication/SessionRequestBody.kt @@ -0,0 +1,5 @@ +package com.daresay.movies.data.models.authentication + +data class SessionRequestBody( + val request_token: String +) diff --git a/Movies/app/src/main/java/com/daresay/movies/data/models/authentication/SessionToken.kt b/Movies/app/src/main/java/com/daresay/movies/data/models/authentication/SessionToken.kt new file mode 100644 index 00000000..b70b1046 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/models/authentication/SessionToken.kt @@ -0,0 +1,6 @@ +package com.daresay.movies.data.models.authentication + +data class SessionToken( + val session_id: String, + val success: Boolean +) \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/models/favorites/Favorite.kt b/Movies/app/src/main/java/com/daresay/movies/data/models/favorites/Favorite.kt new file mode 100644 index 00000000..595dadf0 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/models/favorites/Favorite.kt @@ -0,0 +1,14 @@ +package com.daresay.movies.data.models.favorites + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.ForeignKey.CASCADE +import androidx.room.PrimaryKey +import com.daresay.movies.data.models.moviedetails.MovieDetails + +@Entity(tableName = "Favorites") +data class Favorite( + @PrimaryKey + val movieId: Int, + val favorite: Boolean +) \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/models/favorites/FavoriteWithMovieDetails.kt b/Movies/app/src/main/java/com/daresay/movies/data/models/favorites/FavoriteWithMovieDetails.kt new file mode 100644 index 00000000..096c6406 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/models/favorites/FavoriteWithMovieDetails.kt @@ -0,0 +1,14 @@ +package com.daresay.movies.data.models.favorites + +import androidx.room.Embedded +import androidx.room.Relation +import com.daresay.movies.data.models.moviedetails.MovieDetails + +data class FavoriteWithMovieDetails ( + @Embedded val favorite: Favorite, + @Relation( + parentColumn = "movieId", + entityColumn = "id" + ) + val movieDetails: MovieDetails +) \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/models/movie/Movie.kt b/Movies/app/src/main/java/com/daresay/movies/data/models/movie/Movie.kt new file mode 100644 index 00000000..a35ef6ea --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/models/movie/Movie.kt @@ -0,0 +1,20 @@ +package com.daresay.movies.data.models.movie + +import java.io.Serializable + +data class Movie( + val adult: Boolean, + val backdrop_path: String, + val genre_ids: List, + val id: Int, + val original_language: String, + val original_title: String, + val overview: String, + val popularity: Double, + val poster_path: String, + val release_date: String, + val title: String, + val video: Boolean, + val vote_average: Float, + val vote_count: Int +) \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/models/movie/MovieList.kt b/Movies/app/src/main/java/com/daresay/movies/data/models/movie/MovieList.kt new file mode 100644 index 00000000..2f6ae0dd --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/models/movie/MovieList.kt @@ -0,0 +1,8 @@ +package com.daresay.movies.data.models.movie + +data class MovieList ( + val page: Int, + val results: List, + val total_pages: Int, + val total_results: Int +) \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/AuthorDetails.kt b/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/AuthorDetails.kt new file mode 100644 index 00000000..0c221f50 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/AuthorDetails.kt @@ -0,0 +1,8 @@ +package com.daresay.movies.data.models.moviedetails + +data class AuthorDetails( + val avatar_path: String?, + val name: String, + val rating: Float?, + val username: String +) \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/Genre.kt b/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/Genre.kt new file mode 100644 index 00000000..ab71921a --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/Genre.kt @@ -0,0 +1,6 @@ +package com.daresay.movies.data.models.moviedetails + +data class Genre( + val id: Int, + val name: String +) \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/MovieDetails.kt b/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/MovieDetails.kt new file mode 100644 index 00000000..f3e342c6 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/MovieDetails.kt @@ -0,0 +1,31 @@ +package com.daresay.movies.data.models.moviedetails + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "MovieDetails") +data class MovieDetails( + val adult: Boolean, + val backdrop_path: String, + val budget: Int, + val genres: List, + val homepage: String, + @PrimaryKey + val id: Int, + val imdb_id: String, + val original_language: String, + val original_title: String, + val overview: String, + val popularity: Double, + val poster_path: String, + val release_date: String, + val revenue: Int, + val runtime: Int, + val status: String, + val tagline: String, + val title: String, + val video: Boolean, + val vote_average: Float, + val vote_count: Int, + val reviews: Reviews +) \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/Result.kt b/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/Result.kt new file mode 100644 index 00000000..84b28a67 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/Result.kt @@ -0,0 +1,11 @@ +package com.daresay.movies.data.models.moviedetails + +data class Result( + val author: String, + val author_details: AuthorDetails, + val content: String, + val created_at: String, + val id: String, + val updated_at: String, + val url: String +) \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/Reviews.kt b/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/Reviews.kt new file mode 100644 index 00000000..fb22be35 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/models/moviedetails/Reviews.kt @@ -0,0 +1,8 @@ +package com.daresay.movies.data.models.moviedetails + +data class Reviews( + val page: Int, + val results: List, + val total_pages: Int, + val total_results: Int +) \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/data/user/UserData.kt b/Movies/app/src/main/java/com/daresay/movies/data/user/UserData.kt new file mode 100644 index 00000000..93da290f --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/data/user/UserData.kt @@ -0,0 +1,23 @@ +package com.daresay.movies.data.user + +import android.content.Context +import android.content.SharedPreferences + +class UserData(private val context: Context) { + private fun privatePreferences(): SharedPreferences + = context.getSharedPreferences(PREFERENCE_KEY, Context.MODE_PRIVATE) + + // Why yes, I do realize that after implementing this, the TMDB api calls that you instructed to use in this demo + // does not actually need a user session token. But I figured once I am employed we can scrap all the active Daresay + // projects in favor of building a movie application. And at that point we already have the functionality for getting a session token. + // + // And who's laughing then? + var sessionToken: String? + get() = privatePreferences().getString(SESSION_TOKEN, null) + set(value) = privatePreferences().edit().putString(SESSION_TOKEN, value).apply() + + companion object { + private const val PREFERENCE_KEY = "ryBZOaZ8Smw8xxo" + private const val SESSION_TOKEN = "session_token" + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/di/AppModule.kt b/Movies/app/src/main/java/com/daresay/movies/di/AppModule.kt new file mode 100644 index 00000000..719f8483 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/di/AppModule.kt @@ -0,0 +1,68 @@ +package com.daresay.movies.di + +import android.content.Context +import com.daresay.movies.data.api.TmdbRemoteDataSource +import com.daresay.movies.data.api.TmdbRepository +import com.daresay.movies.data.api.TmdbService +import com.daresay.movies.data.local.AppDatabase +import com.daresay.movies.data.local.MovieDao +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Singleton + @Provides + fun provideRetrofit(gson: Gson) : Retrofit { + val httpClient = OkHttpClient.Builder() + httpClient.addInterceptor( + HttpLoggingInterceptor() + .setLevel(HttpLoggingInterceptor.Level.BODY) + ) + + return Retrofit.Builder() + .baseUrl("https://api.themoviedb.org/3/") + .addConverterFactory(GsonConverterFactory.create(gson)) + .client(httpClient.build()) + .build() + } + + @Provides + fun provideGson() : Gson { + return GsonBuilder() + .setDateFormat("yyyy-MM-dd HH:mm:ss") + .create() + } + + @Provides + fun provideTmdbService(retrofit: Retrofit): TmdbService = retrofit.create(TmdbService::class.java) + + @Singleton + @Provides + fun provideTmdbRemoteDataSource(tmdbService: TmdbService) = TmdbRemoteDataSource(tmdbService) + + @Singleton + @Provides + fun provideTmdbRepository(remoteDataSource: TmdbRemoteDataSource, localDataSource: MovieDao) = + TmdbRepository(remoteDataSource, localDataSource) + + @Singleton + @Provides + fun provideDatabase(@ApplicationContext appContext: Context) = AppDatabase.getDatabase(appContext) + + @Singleton + @Provides + fun provideMovieDetailsDao(db: AppDatabase) = db.movieDao() +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/extensions/Extensions.kt b/Movies/app/src/main/java/com/daresay/movies/extensions/Extensions.kt new file mode 100644 index 00000000..897538b9 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/extensions/Extensions.kt @@ -0,0 +1,9 @@ +package com.daresay.movies.extensions + +import android.view.View +import androidx.annotation.StringRes +import com.google.android.material.snackbar.Snackbar + +fun View.snack(@StringRes textRes: Int, length: Int = Snackbar.LENGTH_LONG) { + Snackbar.make(this, textRes, length).show() +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/extensions/UIExtensions.kt b/Movies/app/src/main/java/com/daresay/movies/extensions/UIExtensions.kt new file mode 100644 index 00000000..425efc27 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/extensions/UIExtensions.kt @@ -0,0 +1,10 @@ +package com.daresay.movies.extensions + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.LayoutRes + +fun ViewGroup.inflate(@LayoutRes layoutRes: Int, attachToRoot: Boolean = false): View { + return LayoutInflater.from(context).inflate(layoutRes, this, attachToRoot) +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/activities/MainActivity.kt b/Movies/app/src/main/java/com/daresay/movies/ui/activities/MainActivity.kt new file mode 100644 index 00000000..4afba56d --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/activities/MainActivity.kt @@ -0,0 +1,137 @@ +package com.daresay.movies.ui.activities + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.navigation.findNavController +import androidx.navigation.fragment.findNavController +import androidx.navigation.ui.setupWithNavController +import com.daresay.movies.R +import com.daresay.movies.data.models.authentication.SessionRequestBody +import com.daresay.movies.data.user.UserData +import com.daresay.movies.databinding.ActivityMainBinding +import com.daresay.movies.extensions.snack +import com.daresay.movies.ui.viewmodels.AuthenticationViewModel +import com.daresay.movies.utils.Resource +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + private val viewModel: AuthenticationViewModel by viewModels() + private lateinit var binding: ActivityMainBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + val view = binding.root + setContentView(view) + + // If we have a request token set we will go go home screen + if (UserData(this).sessionToken == null) { + // We don't have a session token, so start the process of getting one + setUpRequestTokenObserver() + } + else { + moveToHome() + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + // Check for incoming intent data from authentication + intent?.data?.let { + parseUrlData(it) + } + } + + /** + * Here we will parse the uri from the intent after authenticating a user. + * If the authentication was successful, we will move to home screen. + */ + private fun parseUrlData(uri: Uri) { + // Check if the request was approved + val approved = uri.getBooleanQueryParameter("approved", false) + if (approved) { + // Get the request_token and save it to shared_preferences + val requestToken = uri.getQueryParameter("request_token") + if (requestToken != null) { + setUpSessionTokenObserver(requestToken) + } + } + else { + // TODO: Show error dialog + } + } + + /** + * Here we retrieve the user request token so we can make further API calls. + * After getting request token we will open up with tmdb authentication site and wait for user authentication. + * We will save the token in SharedPreferences for easier and automatic access by other API calls. + */ + private fun setUpRequestTokenObserver() { + viewModel.userToken.observe(this, { it -> + when (it.status) { + Resource.Status.SUCCESS -> { + it.data?.let { token -> + openAuthenticationUrl(token.request_token) + } + } + + Resource.Status.ERROR -> { + binding.root.snack(R.string.main_error_getting_request_token) + } + + Resource.Status.LOADING -> { + + } + } + }) + } + + /** + * Using the request token from above method we will fetch the session token so that we can use the api. + */ + private fun setUpSessionTokenObserver(requestToken: String) { + viewModel.createSession(SessionRequestBody(requestToken)).observe(this, { + when (it.status) { + Resource.Status.SUCCESS -> { + it.data?.let { sessionToken -> + if (sessionToken.success) { + UserData(this).sessionToken = sessionToken.session_id + moveToHome() + } + } + } + + Resource.Status.ERROR -> { + binding.root.snack(R.string.main_error_getting_session_token) + } + + Resource.Status.LOADING -> { + + } + } + }) + } + + /** + * Open browser to authenticate the request token. + * Read deep link data in @onNewIntent + */ + private fun openAuthenticationUrl(requestToken: String) { + val url = "https://www.themoviedb.org/authenticate/${requestToken}?redirect_to=movies://authentication/?status=1" + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(browserIntent) + } + + /** + * Navigate to the home screen. + * This can be called straight away if user has a request token set already, or after authenticating a new request token. + */ + private fun moveToHome() { + findNavController(R.id.nav_host).navigate(R.id.homeViewPagerFragment) + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/activities/MovieDetailsActivity.kt b/Movies/app/src/main/java/com/daresay/movies/ui/activities/MovieDetailsActivity.kt new file mode 100644 index 00000000..3bdfd6d0 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/activities/MovieDetailsActivity.kt @@ -0,0 +1,165 @@ +package com.daresay.movies.ui.activities + +import android.content.res.ColorStateList +import android.os.Bundle +import android.view.View +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.daresay.movies.R +import com.daresay.movies.databinding.ActivityMovieDetailsBinding +import com.daresay.movies.extensions.snack +import com.daresay.movies.ui.adapters.GenreItemAdapter +import com.daresay.movies.ui.adapters.ReviewAdapter +import com.daresay.movies.ui.viewmodels.MovieDetailsViewModel +import com.daresay.movies.utils.Resource +import com.daresay.movies.utils.getMoviePosterBigUrl +import dagger.hilt.android.AndroidEntryPoint +import kotlin.collections.ArrayList + +@AndroidEntryPoint +class MovieDetailsActivity : AppCompatActivity() { + private val viewModel: MovieDetailsViewModel by viewModels() + private lateinit var binding: ActivityMovieDetailsBinding + private val genreItemAdapter = GenreItemAdapter() + private val reviewItemAdapter = ReviewAdapter() + + private var reviewPage = 1 + private var reviewsMaxPage = 1 + private var reviewPageLoading = false + + private var movieId: Int = -1 + private var isFavorite = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMovieDetailsBinding.inflate(layoutInflater) + val view = binding.root + setContentView(view) + + movieId = intent.getIntExtra("movie_id", -1) + if (movieId == -1) { + // TODO: Throw error + } + + setUpGenreRecyclerView() + setUpReviewRecyclerView() + setUpMovieDetailsObserver(movieId = movieId) + setUpFavoriteObserver(movieId) + + binding.loading = true + } + + private fun setUpGenreRecyclerView() { + binding.genreRecyclerView.adapter = genreItemAdapter + } + + private fun setUpReviewRecyclerView() { + val layoutManager = LinearLayoutManager(this) + layoutManager.orientation = LinearLayoutManager.VERTICAL + binding.reviewsRecyclerView.layoutManager = layoutManager + + binding.reviewsRecyclerView.adapter = reviewItemAdapter + binding.reviewsRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (reviewPage == reviewsMaxPage) + return + + if (dx > 0) { + val lastVisibleItem = layoutManager.findLastVisibleItemPosition() + if (!reviewPageLoading && lastVisibleItem > reviewItemAdapter.itemCount - 5) { + reviewPageLoading = true + loadReviews(++reviewPage) + } + } + } + }) + } + + private fun setUpMovieDetailsObserver(movieId: Int) { + viewModel.getMovieDetails(movieId).observe(this, { it -> + when (it.status) { + Resource.Status.SUCCESS -> { + it.data?.let { movieDetails -> + binding.movieDetails = movieDetails + reviewsMaxPage = movieDetails.reviews.total_pages + reviewItemAdapter.setItems(ArrayList(movieDetails.reviews.results)) + genreItemAdapter.setItems(movieDetails.genres.map { genre -> genre.name}) + + Glide.with(binding.root) + .load(getMoviePosterBigUrl(movieDetails.poster_path)) + .transform(CenterCrop()) + .into(binding.movieImage) + + binding.loading = false + } + } + + Resource.Status.ERROR -> { + binding.root.snack(R.string.activity_movie_details_error_failed_to_load_movie) + } + + Resource.Status.LOADING -> { + binding.loading = true + } + } + }) + } + + private fun loadReviews(page: Int) { + viewModel.getMovieReviews(movieId, page).observe(this, { + when (it.status) { + Resource.Status.SUCCESS -> { + it.data?.let { reviews -> + reviewItemAdapter.addItems(ArrayList(reviews.results)) + reviewPageLoading = false + } + } + + Resource.Status.ERROR -> { + + } + + Resource.Status.LOADING -> { + + } + } + }) + } + + private fun setUpFavoriteObserver(movieId: Int) { + viewModel.getFavorite(movieId).observe(this, { + when (it.status) { + Resource.Status.SUCCESS -> { + it.data?.let { obj -> + setFavoriteButtonTint(obj.favorite) + isFavorite = obj.favorite + } + } + + Resource.Status.ERROR -> { + + } + + Resource.Status.LOADING -> { + + } + } + }) + } + + fun onFavoriteClicked(view: View) { + isFavorite = !isFavorite + viewModel.setMovieFavorite(movieId, isFavorite) + } + + private fun setFavoriteButtonTint(isFavorite: Boolean) { + val colorRes = if (isFavorite) R.color.red else R.color.white + binding.favorite.imageTintList = ColorStateList.valueOf(ContextCompat.getColor(this, colorRes)) + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/adapters/FavoriteMoviesAdapter.kt b/Movies/app/src/main/java/com/daresay/movies/ui/adapters/FavoriteMoviesAdapter.kt new file mode 100644 index 00000000..a6fafa11 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/adapters/FavoriteMoviesAdapter.kt @@ -0,0 +1,50 @@ +package com.daresay.movies.ui.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.daresay.movies.data.models.moviedetails.MovieDetails +import com.daresay.movies.databinding.AdapterFavoriteMovieItemBinding +import com.daresay.movies.databinding.AdapterGenreItemBinding +import com.daresay.movies.ui.callbacks.MovieOnClickListener +import com.daresay.movies.utils.getGenreList +import com.daresay.movies.utils.getMoviePosterUrl + +class FavoriteMoviesAdapter(private val onMovieOnClickListener: MovieOnClickListener) : RecyclerView.Adapter() { + private var items: List = emptyList() + + fun setItems(items: List) { + this.items = items + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FavoriteMoviesAdapter.GenreHolder { + val binding = AdapterFavoriteMovieItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return GenreHolder(binding) + } + + override fun getItemCount() = items.size + + override fun onBindViewHolder(holder: GenreHolder, position: Int) = holder.bind(items[position]) + + inner class GenreHolder(private val binding: AdapterFavoriteMovieItemBinding) : RecyclerView.ViewHolder(binding.root) { + private lateinit var movie: MovieDetails + + fun bind(movie: MovieDetails) { + binding.movieDetails = movie + binding.movieTags.text = movie.genres.joinToString { it.name } + + Glide.with(binding.root) + .load(getMoviePosterUrl(movie.poster_path)) + .transform(CenterCrop(), RoundedCorners(25)) + .into(binding.movieImage) + + binding.root.setOnClickListener { + onMovieOnClickListener.onMovieClicked(movie.id) + } + } + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/adapters/GenreItemAdapter.kt b/Movies/app/src/main/java/com/daresay/movies/ui/adapters/GenreItemAdapter.kt new file mode 100644 index 00000000..dc8d5955 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/adapters/GenreItemAdapter.kt @@ -0,0 +1,33 @@ +package com.daresay.movies.ui.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.daresay.movies.databinding.AdapterGenreItemBinding + +class GenreItemAdapter : RecyclerView.Adapter() { + private var items: List = emptyList() + + fun setItems(items: List) { + this.items = items + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GenreItemAdapter.GenreHolder { + val binding = AdapterGenreItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return GenreHolder(binding) + } + + override fun getItemCount() = items.size + + override fun onBindViewHolder(holder: GenreHolder, position: Int) = holder.bind(items[position]) + + inner class GenreHolder(private val binding: AdapterGenreItemBinding) : RecyclerView.ViewHolder(binding.root) { + private lateinit var text: String + + fun bind(text: String) { + this.text = text + binding.name = text + } + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/adapters/HomePagerAdapter.kt b/Movies/app/src/main/java/com/daresay/movies/ui/adapters/HomePagerAdapter.kt new file mode 100644 index 00000000..eec41b5c --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/adapters/HomePagerAdapter.kt @@ -0,0 +1,25 @@ +package com.daresay.movies.ui.adapters + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.daresay.movies.ui.fragments.FavoritesFragment +import com.daresay.movies.ui.fragments.MoviesFragment +import java.lang.IndexOutOfBoundsException + +class HomePagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { + private val tabFragments: Map Fragment> = mapOf( + MOVIES_PAGE_INDEX to { MoviesFragment() }, + FAVORITES_PAGE_INDEX to { FavoritesFragment() } + ) + + override fun getItemCount() = tabFragments.size + + override fun createFragment(position: Int): Fragment { + return tabFragments[position]?.invoke() ?: throw IndexOutOfBoundsException() + } + + companion object { + const val MOVIES_PAGE_INDEX = 0 + const val FAVORITES_PAGE_INDEX = 1 + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/adapters/MoviePopularAdapter.kt b/Movies/app/src/main/java/com/daresay/movies/ui/adapters/MoviePopularAdapter.kt new file mode 100644 index 00000000..e99b1deb --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/adapters/MoviePopularAdapter.kt @@ -0,0 +1,53 @@ +package com.daresay.movies.ui.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.daresay.movies.data.models.movie.Movie +import com.daresay.movies.databinding.AdapterMoviePopularItemBinding +import com.daresay.movies.databinding.AdapterMovieTopRatedItemBinding +import com.daresay.movies.ui.callbacks.MovieOnClickListener +import com.daresay.movies.utils.getGenreList +import com.daresay.movies.utils.getMoviePosterUrl + +class MoviePopularAdapter(private val onMovieOnClickListener: MovieOnClickListener) : RecyclerView.Adapter() { + private var items: ArrayList = arrayListOf() + + fun addItems(movies: ArrayList) { + val insertPosition = items.size + this.items.addAll(movies) + notifyItemRangeInserted(insertPosition, movies.size) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MoviePopularAdapter.MovieHolder { + val binding = AdapterMoviePopularItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return MovieHolder(binding) + } + + override fun getItemCount() = items.size + + override fun onBindViewHolder(holder: MovieHolder, position: Int) = holder.bind(items[position]) + + inner class MovieHolder(private val binding: AdapterMoviePopularItemBinding) : RecyclerView.ViewHolder(binding.root) { + private lateinit var movie: Movie + + fun bind(movie: Movie) { + this.movie = movie + + binding.movie = movie + binding.movieTags.text = getGenreList(movie.genre_ids).joinToString() + + Glide.with(binding.root) + .load(getMoviePosterUrl(movie.poster_path)) + .transform(CenterCrop(), RoundedCorners(25)) + .into(binding.movieImage) + + binding.root.setOnClickListener { + onMovieOnClickListener.onMovieClicked(movie.id) + } + } + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/adapters/MovieTopRatedAdapter.kt b/Movies/app/src/main/java/com/daresay/movies/ui/adapters/MovieTopRatedAdapter.kt new file mode 100644 index 00000000..dadb38f7 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/adapters/MovieTopRatedAdapter.kt @@ -0,0 +1,54 @@ +package com.daresay.movies.ui.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.databinding.BindingAdapter +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.CircleCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.daresay.movies.data.models.movie.Movie +import com.daresay.movies.databinding.AdapterMovieTopRatedItemBinding +import com.daresay.movies.ui.callbacks.MovieOnClickListener +import com.daresay.movies.utils.getGenreList +import com.daresay.movies.utils.getMoviePosterUrl + +class MovieTopRatedAdapter(private val onMovieOnClickListener: MovieOnClickListener) : RecyclerView.Adapter() { + private var items: ArrayList = arrayListOf() + + fun addItems(movies: ArrayList) { + val insertPosition = items.size + this.items.addAll(movies) + notifyItemRangeInserted(insertPosition, movies.size) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MovieTopRatedAdapter.MovieHolder { + val binding = AdapterMovieTopRatedItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return MovieHolder(binding) + } + + override fun getItemCount() = items.size + + override fun onBindViewHolder(holder: MovieHolder, position: Int) = holder.bind(items[position]) + + inner class MovieHolder(private val binding: AdapterMovieTopRatedItemBinding) : RecyclerView.ViewHolder(binding.root) { + private lateinit var movie: Movie + + fun bind(movie: Movie) { + this.movie = movie + + binding.movie = movie + binding.movieTags.text = getGenreList(movie.genre_ids).joinToString() + + Glide.with(binding.root) + .load(getMoviePosterUrl(movie.poster_path)) + .transform(CenterCrop(), RoundedCorners(25)) + .into(binding.movieImage) + + binding.root.setOnClickListener { + onMovieOnClickListener.onMovieClicked(movie.id) + } + } + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/adapters/ReviewAdapter.kt b/Movies/app/src/main/java/com/daresay/movies/ui/adapters/ReviewAdapter.kt new file mode 100644 index 00000000..ead83b3a --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/adapters/ReviewAdapter.kt @@ -0,0 +1,52 @@ +package com.daresay.movies.ui.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.CircleCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.daresay.movies.R +import com.daresay.movies.data.models.movie.Movie +import com.daresay.movies.data.models.moviedetails.Result +import com.daresay.movies.databinding.AdapterReviewItemBinding + +class ReviewAdapter : RecyclerView.Adapter() { + private var items: ArrayList = arrayListOf() + + fun setItems(items: ArrayList) { + this.items = items + notifyDataSetChanged() + } + + fun addItems(reviews: ArrayList) { + val insertPosition = items.size + this.items.addAll(reviews) + notifyItemRangeInserted(insertPosition, reviews.size) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReviewAdapter.ReviewHolder { + val binding = AdapterReviewItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ReviewHolder(binding) + } + + override fun getItemCount() = items.size + + override fun onBindViewHolder(holder: ReviewHolder, position: Int) = holder.bind(items[position]) + + inner class ReviewHolder(private val binding: AdapterReviewItemBinding) : RecyclerView.ViewHolder(binding.root) { + private lateinit var review: Result + + fun bind(review: Result) { + this.review = review + binding.result = review + + Glide.with(binding.root) + .load(review.author_details.avatar_path?.removePrefix("/")) + .transform(CenterCrop()) + .error(R.drawable.avatar_placeholder) + .into(binding.avatar) + } + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/callbacks/MovieOnClickListener.kt b/Movies/app/src/main/java/com/daresay/movies/ui/callbacks/MovieOnClickListener.kt new file mode 100644 index 00000000..502cf087 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/callbacks/MovieOnClickListener.kt @@ -0,0 +1,7 @@ +package com.daresay.movies.ui.callbacks + +import com.daresay.movies.data.models.movie.Movie + +interface MovieOnClickListener { + fun onMovieClicked(movieId: Int) +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/dialogs/ErrorDialog.kt b/Movies/app/src/main/java/com/daresay/movies/ui/dialogs/ErrorDialog.kt new file mode 100644 index 00000000..e233eabf --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/dialogs/ErrorDialog.kt @@ -0,0 +1,24 @@ +package com.daresay.movies.ui.dialogs + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.view.Window +import com.daresay.movies.R +import com.daresay.movies.databinding.DialogErrorBinding +import com.daresay.movies.databinding.FragmentHomeViewPagerBinding + +class ErrorDialog(context: Context, private val errorMessage: String) : Dialog(context) { + private lateinit var binding: DialogErrorBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requestWindowFeature(Window.FEATURE_NO_TITLE) + setContentView(R.layout.dialog_error) + + binding.errorText.text = errorMessage + binding.close.setOnClickListener { + dismiss() + } + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/fragments/FavoritesFragment.kt b/Movies/app/src/main/java/com/daresay/movies/ui/fragments/FavoritesFragment.kt new file mode 100644 index 00000000..77c1eb05 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/fragments/FavoritesFragment.kt @@ -0,0 +1,71 @@ +package com.daresay.movies.ui.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import com.daresay.movies.data.models.movie.Movie +import com.daresay.movies.databinding.FragmentFavoritesBinding +import com.daresay.movies.ui.activities.MovieDetailsActivity +import com.daresay.movies.ui.adapters.FavoriteMoviesAdapter +import com.daresay.movies.ui.callbacks.MovieOnClickListener +import com.daresay.movies.ui.viewmodels.MovieFavoritesViewModel +import com.daresay.movies.utils.Resource +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class FavoritesFragment : Fragment(), MovieOnClickListener { + private val viewModel: MovieFavoritesViewModel by viewModels() + private lateinit var binding: FragmentFavoritesBinding + private val favoriteMoviesAdapter = FavoriteMoviesAdapter(this) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentFavoritesBinding.inflate(inflater, container, false) + + setUpFavoriteMoviesList() + setUpFavoriteMoviesObserver() + + return binding.root + } + + private fun setUpFavoriteMoviesList() { + val layoutManager = LinearLayoutManager(context) + layoutManager.orientation = LinearLayoutManager.VERTICAL + binding.favoriteRecyclerView.layoutManager = layoutManager + binding.favoriteRecyclerView.adapter = favoriteMoviesAdapter + } + + private fun setUpFavoriteMoviesObserver() { + viewModel.getAllFavorites().observe(viewLifecycleOwner, { resource -> + when (resource.status) { + Resource.Status.SUCCESS -> { + resource.data?.let { movies -> + favoriteMoviesAdapter.setItems(movies.map { it.movieDetails }) + } + } + + Resource.Status.ERROR -> { + + } + + Resource.Status.LOADING -> { + + } + } + }) + } + + override fun onMovieClicked(movieId: Int) { + val intent = Intent(requireContext(), MovieDetailsActivity::class.java) + intent.putExtra("movie_id", movieId) + startActivity(intent) + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/fragments/HomeViewPagerFragment.kt b/Movies/app/src/main/java/com/daresay/movies/ui/fragments/HomeViewPagerFragment.kt new file mode 100644 index 00000000..5744eef1 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/fragments/HomeViewPagerFragment.kt @@ -0,0 +1,52 @@ +package com.daresay.movies.ui.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import com.daresay.movies.R +import com.daresay.movies.ui.adapters.HomePagerAdapter +import com.daresay.movies.databinding.FragmentHomeViewPagerBinding +import com.google.android.material.tabs.TabLayoutMediator +import dagger.hilt.android.AndroidEntryPoint +import java.lang.IndexOutOfBoundsException + +@AndroidEntryPoint +class HomeViewPagerFragment : Fragment() { + private lateinit var binding: FragmentHomeViewPagerBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentHomeViewPagerBinding.inflate(inflater, container, false) + binding.viewPager.adapter = HomePagerAdapter(this) + binding.viewPager.isUserInputEnabled = false + + TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> + tab.setIcon(getTabIcon(position)) + tab.text = getTabText(position) + }.attach() + + return binding.root + } + + private fun getTabIcon(position: Int): Int { + return when (position) { + HomePagerAdapter.MOVIES_PAGE_INDEX -> R.drawable.ic_baseline_local_movies_24 + HomePagerAdapter.FAVORITES_PAGE_INDEX -> R.drawable.ic_baseline_favorite_24 + else -> throw IndexOutOfBoundsException() + } + } + + private fun getTabText(position: Int): String { + return when (position) { + HomePagerAdapter.MOVIES_PAGE_INDEX -> getString(R.string.main_bottom_navigation_movies) + HomePagerAdapter.FAVORITES_PAGE_INDEX -> getString(R.string.main_bottom_navigation_favorites) + else -> throw IndexOutOfBoundsException() + } + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/fragments/LoadingFragment.kt b/Movies/app/src/main/java/com/daresay/movies/ui/fragments/LoadingFragment.kt new file mode 100644 index 00000000..231e7e75 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/fragments/LoadingFragment.kt @@ -0,0 +1,20 @@ +package com.daresay.movies.ui.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.daresay.movies.R +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class LoadingFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_loading, container, false) + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/fragments/MoviesFragment.kt b/Movies/app/src/main/java/com/daresay/movies/ui/fragments/MoviesFragment.kt new file mode 100644 index 00000000..24254a24 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/fragments/MoviesFragment.kt @@ -0,0 +1,157 @@ +package com.daresay.movies.ui.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.daresay.movies.R +import com.daresay.movies.data.models.movie.Movie +import com.daresay.movies.databinding.FragmentMoviesBinding +import com.daresay.movies.extensions.snack +import com.daresay.movies.ui.activities.MovieDetailsActivity +import com.daresay.movies.ui.adapters.MoviePopularAdapter +import com.daresay.movies.ui.adapters.MovieTopRatedAdapter +import com.daresay.movies.ui.callbacks.MovieOnClickListener +import com.daresay.movies.ui.viewmodels.MovieListViewModel +import com.daresay.movies.utils.Resource +import com.google.android.flexbox.* +import dagger.hilt.android.AndroidEntryPoint + + +@AndroidEntryPoint +class MoviesFragment : Fragment(), MovieOnClickListener { + private val viewModel: MovieListViewModel by viewModels() + private lateinit var binding: FragmentMoviesBinding + + private var topRatedMoviesPage = 1 + private var topRatedMoviesPageSize = 1 + private var topRatedMoviesPageLoading = false + + private var popularMoviesPage = 1 + private var popularMoviesPageSize = 1 + private var popularMoviesPageLoading = false + + private val topRatedMoviesAdapter = MovieTopRatedAdapter(this) + private val popularMoviesAdapter = MoviePopularAdapter(this) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentMoviesBinding.inflate(inflater, container, false) + + setUpTopRatedMoviesList() + setUpPopularMoviesList() + + loadTopRatedMovies(topRatedMoviesPage) + loadPopularMovies(popularMoviesPage) + + return binding.root + } + + private fun setUpTopRatedMoviesList() { + val layoutManager = LinearLayoutManager(context) + layoutManager.orientation = LinearLayoutManager.HORIZONTAL + + binding.topRatingLoading = true + binding.topRatedRecyclerView.layoutManager = layoutManager + binding.topRatedRecyclerView.adapter = topRatedMoviesAdapter + binding.topRatedRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (topRatedMoviesPage == topRatedMoviesPageSize) + return + + if (dx > 0) { + val lastVisibleItem = layoutManager.findLastVisibleItemPosition() + if (!topRatedMoviesPageLoading && lastVisibleItem > topRatedMoviesAdapter.itemCount - 5) { + topRatedMoviesPageLoading = true + loadTopRatedMovies(++topRatedMoviesPage) + } + } + } + }) + } + + private fun loadTopRatedMovies(page: Int) { + viewModel.getTopRatedMovies(page).observe(viewLifecycleOwner, { + when (it.status) { + Resource.Status.SUCCESS -> { + it.data?.let { movies -> + topRatedMoviesAdapter.addItems(ArrayList(movies.results)) + topRatedMoviesPageSize = movies.total_pages + binding.topRatingLoading = false + topRatedMoviesPageLoading = false + } + } + + Resource.Status.ERROR -> { + view?.snack(R.string.fragment_movie_error_failed_to_load_top_rated_list) + } + + Resource.Status.LOADING -> { + + } + } + }) + } + + private fun setUpPopularMoviesList() { + binding.popularLoading = true + val layoutManager = FlexboxLayoutManager(context) + layoutManager.justifyContent = JustifyContent.SPACE_EVENLY + binding.popularRecyclerView.layoutManager = layoutManager + binding.popularRecyclerView.adapter = popularMoviesAdapter + binding.popularRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (popularMoviesPage == popularMoviesPageSize) + return + + if (dy > 0) { + val lastVisibleItem = layoutManager.findLastVisibleItemPosition() + if (!popularMoviesPageLoading && lastVisibleItem > popularMoviesAdapter.itemCount - 5) { + popularMoviesPageLoading = true + loadPopularMovies(++popularMoviesPage) + } + } + } + }) + } + + private fun loadPopularMovies(page: Int) { + viewModel.getPopularMovies(page).observe(viewLifecycleOwner, { + when (it.status) { + Resource.Status.SUCCESS -> { + it.data?.let { movies -> + popularMoviesAdapter.addItems(ArrayList(movies.results)) + popularMoviesPageSize = movies.total_pages + binding.popularLoading = false + popularMoviesPageLoading = false + } + } + + Resource.Status.ERROR -> { + view?.snack(R.string.fragment_movie_error_failed_to_load_popular_list) + } + + Resource.Status.LOADING -> { + + } + } + }) + } + + override fun onMovieClicked(movieId: Int) { + val intent = Intent(requireContext(), MovieDetailsActivity::class.java) + intent.putExtra("movie_id", movieId) + startActivity(intent) + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/fragments/ReviewFragment.kt b/Movies/app/src/main/java/com/daresay/movies/ui/fragments/ReviewFragment.kt new file mode 100644 index 00000000..84303681 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/fragments/ReviewFragment.kt @@ -0,0 +1,25 @@ +package com.daresay.movies.ui.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.daresay.movies.databinding.FragmentMoviesBinding +import com.daresay.movies.databinding.FragmentReviewBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ReviewFragment : Fragment() { + private lateinit var binding: FragmentReviewBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentReviewBinding.inflate(inflater, container, false) + + return binding.root + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/viewmodels/AuthenticationViewModel.kt b/Movies/app/src/main/java/com/daresay/movies/ui/viewmodels/AuthenticationViewModel.kt new file mode 100644 index 00000000..9a8b873c --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/viewmodels/AuthenticationViewModel.kt @@ -0,0 +1,13 @@ +package com.daresay.movies.ui.viewmodels + +import androidx.lifecycle.ViewModel +import com.daresay.movies.data.api.TmdbRepository +import com.daresay.movies.data.models.authentication.SessionRequestBody +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class AuthenticationViewModel @Inject constructor(private val repository: TmdbRepository) : ViewModel() { + val userToken = repository.getUserToken() + fun createSession(requestBody: SessionRequestBody) = repository.createSession(requestBody = requestBody) +} diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/viewmodels/MovieDetailsViewModel.kt b/Movies/app/src/main/java/com/daresay/movies/ui/viewmodels/MovieDetailsViewModel.kt new file mode 100644 index 00000000..42a83ef3 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/viewmodels/MovieDetailsViewModel.kt @@ -0,0 +1,30 @@ +package com.daresay.movies.ui.viewmodels + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import com.daresay.movies.data.api.TmdbRepository +import com.daresay.movies.data.models.favorites.Favorite +import com.daresay.movies.data.models.moviedetails.MovieDetails +import com.daresay.movies.data.models.moviedetails.Reviews +import com.daresay.movies.utils.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class MovieDetailsViewModel @Inject constructor(private val repository: TmdbRepository) : ViewModel() { + fun getMovieDetails(movieId: Int) : LiveData> { + return repository.getMovieDetails(movieId = movieId) + } + + fun getMovieReviews(movieId: Int, page: Int) : LiveData> { + return repository.getMovieReviews(movieId, page) + } + + fun setMovieFavorite(movieId: Int, favorite: Boolean) { + repository.setMovieFavorite(movieId, favorite) + } + + fun getFavorite(movieId: Int) : LiveData> { + return repository.getFavorite(movieId) + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/viewmodels/MovieFavoritesViewModel.kt b/Movies/app/src/main/java/com/daresay/movies/ui/viewmodels/MovieFavoritesViewModel.kt new file mode 100644 index 00000000..45982992 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/viewmodels/MovieFavoritesViewModel.kt @@ -0,0 +1,17 @@ +package com.daresay.movies.ui.viewmodels + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import com.daresay.movies.data.api.TmdbRepository +import com.daresay.movies.data.models.favorites.FavoriteWithMovieDetails +import com.daresay.movies.data.models.movie.MovieList +import com.daresay.movies.utils.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class MovieFavoritesViewModel @Inject constructor(private val repository: TmdbRepository) : ViewModel() { + fun getAllFavorites() : LiveData>> { + return repository.getAllFavorites() + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/ui/viewmodels/MovieListViewModel.kt b/Movies/app/src/main/java/com/daresay/movies/ui/viewmodels/MovieListViewModel.kt new file mode 100644 index 00000000..7d3a5781 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/ui/viewmodels/MovieListViewModel.kt @@ -0,0 +1,22 @@ +package com.daresay.movies.ui.viewmodels + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.daresay.movies.data.api.TmdbRepository +import com.daresay.movies.data.models.movie.Movie +import com.daresay.movies.data.models.movie.MovieList +import com.daresay.movies.utils.Resource +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class MovieListViewModel @Inject constructor(private val repository: TmdbRepository) : ViewModel() { + fun getTopRatedMovies(page: Int) : LiveData> { + return repository.getTopRatedMovies(page = page) + } + + fun getPopularMovies(page: Int) : LiveData> { + return repository.getPopularMovies(page = page) + } +} \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/utils/DataAccessStrategy.kt b/Movies/app/src/main/java/com/daresay/movies/utils/DataAccessStrategy.kt new file mode 100644 index 00000000..d0074477 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/utils/DataAccessStrategy.kt @@ -0,0 +1,57 @@ +package com.daresay.movies.utils + +import androidx.lifecycle.LiveData +import androidx.lifecycle.liveData +import androidx.lifecycle.map +import com.daresay.movies.data.models.moviedetails.MovieDetails +import kotlinx.coroutines.Dispatchers +import com.daresay.movies.utils.Resource.Status.* + +/** + * Save response to database + */ +fun performGetOperation( + databaseQuery: () -> LiveData, + networkCall: suspend () -> Resource, + saveCallResult: suspend (A) -> Unit): LiveData> = + liveData(Dispatchers.IO) { + emit(Resource.loading()) + val source = databaseQuery.invoke().map { Resource.success(it) } + emitSource(source) + + val responseStatus = networkCall.invoke() + if (responseStatus.status == SUCCESS) { + saveCallResult(responseStatus.data!!) + + } else if (responseStatus.status == ERROR) { + emit(Resource.error(responseStatus.message!!)) + emitSource(source) + } + } + +/** + * Reduce boilerplate code for regular network operations + */ +fun performGetOperation(networkCall: suspend () -> Resource) : LiveData> = + liveData(Dispatchers.IO) { + emit(Resource.loading()) + + val responseStatus = networkCall.invoke() + if (responseStatus.status == SUCCESS) { + emit(responseStatus) + + } else if (responseStatus.status == ERROR) { + emit(Resource.error(responseStatus.message!!)) + } + } + +/** + * Reduce boilerplate code for regular database operations + */ +fun performDatabaseGetOperation(databaseQuery: () -> LiveData) : LiveData> = + liveData(Dispatchers.IO) { + emit(Resource.loading()) + + val source = databaseQuery.invoke().map { Resource.success(it) } + emitSource(source) + } \ No newline at end of file diff --git a/Movies/app/src/main/java/com/daresay/movies/utils/Resource.kt b/Movies/app/src/main/java/com/daresay/movies/utils/Resource.kt new file mode 100644 index 00000000..c951e470 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/utils/Resource.kt @@ -0,0 +1,21 @@ +package com.daresay.movies.utils + +data class Resource(val status: Status, val data: T?, val message: String?) { + + enum class Status { + SUCCESS, + ERROR, + LOADING + } + + companion object { + fun success(data: T): Resource = + Resource(Status.SUCCESS, data, null) + + fun error(message: String, data: T? = null): Resource = + Resource(Status.ERROR, data, message) + + fun loading(data: T? = null): Resource = + Resource(Status.LOADING, data, null) + } +} diff --git a/Movies/app/src/main/java/com/daresay/movies/utils/TmdbUtils.kt b/Movies/app/src/main/java/com/daresay/movies/utils/TmdbUtils.kt new file mode 100644 index 00000000..f08ad6c0 --- /dev/null +++ b/Movies/app/src/main/java/com/daresay/movies/utils/TmdbUtils.kt @@ -0,0 +1,37 @@ +package com.daresay.movies.utils + +fun getMoviePosterUrl(image: String) = "https://image.tmdb.org/t/p/w500/${image}" +fun getMoviePosterBigUrl(image: String) = "https://image.tmdb.org/t/p/w1280/${image}" + +val genreMap = mapOf( + 28 to "Action", + 12 to "Adventure", + 16 to "Animation", + 35 to "Comedy", + 80 to "Crime", + 99 to "Documentary", + 18 to "Drama", + 10751 to "Family", + 14 to "Fantasy", + 36 to "History", + 27 to "Horror", + 10402 to "Music", + 9648 to "Mystery", + 10749 to "Romance", + 878 to "Science Fiction", + 10770 to "TV Movie", + 53 to "Thriller", + 10752 to "War", + 37 to "Western") + +fun getGenreList(tags: List) : List { + var returnList = emptyList() + + for (tag in tags) { + genreMap[tag]?.let { + returnList += it + } + } + + return returnList +} \ No newline at end of file diff --git a/Movies/app/src/main/res/anim/slide_in_bottom.xml b/Movies/app/src/main/res/anim/slide_in_bottom.xml new file mode 100644 index 00000000..69070259 --- /dev/null +++ b/Movies/app/src/main/res/anim/slide_in_bottom.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/anim/slide_out_bottom.xml b/Movies/app/src/main/res/anim/slide_out_bottom.xml new file mode 100644 index 00000000..ecb5817f --- /dev/null +++ b/Movies/app/src/main/res/anim/slide_out_bottom.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/color/button_blue_selector.xml b/Movies/app/src/main/res/color/button_blue_selector.xml new file mode 100644 index 00000000..959a8874 --- /dev/null +++ b/Movies/app/src/main/res/color/button_blue_selector.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/color/tab_layout_item_selector.xml b/Movies/app/src/main/res/color/tab_layout_item_selector.xml new file mode 100644 index 00000000..71909b20 --- /dev/null +++ b/Movies/app/src/main/res/color/tab_layout_item_selector.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/drawable-v24/avatar_placeholder.png b/Movies/app/src/main/res/drawable-v24/avatar_placeholder.png new file mode 100644 index 00000000..09892098 Binary files /dev/null and b/Movies/app/src/main/res/drawable-v24/avatar_placeholder.png differ diff --git a/Movies/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/Movies/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/Movies/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/drawable-v24/placehorder.jpg b/Movies/app/src/main/res/drawable-v24/placehorder.jpg new file mode 100644 index 00000000..f38368ea Binary files /dev/null and b/Movies/app/src/main/res/drawable-v24/placehorder.jpg differ diff --git a/Movies/app/src/main/res/drawable/button_blue.xml b/Movies/app/src/main/res/drawable/button_blue.xml new file mode 100644 index 00000000..e200fe5b --- /dev/null +++ b/Movies/app/src/main/res/drawable/button_blue.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/drawable/content_background_rounded_top.xml b/Movies/app/src/main/res/drawable/content_background_rounded_top.xml new file mode 100644 index 00000000..b8805bb3 --- /dev/null +++ b/Movies/app/src/main/res/drawable/content_background_rounded_top.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/drawable/content_blue.xml b/Movies/app/src/main/res/drawable/content_blue.xml new file mode 100644 index 00000000..3026b453 --- /dev/null +++ b/Movies/app/src/main/res/drawable/content_blue.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/drawable/dialog_window.xml b/Movies/app/src/main/res/drawable/dialog_window.xml new file mode 100644 index 00000000..47189640 --- /dev/null +++ b/Movies/app/src/main/res/drawable/dialog_window.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/drawable/ic_baseline_calendar_today_24.xml b/Movies/app/src/main/res/drawable/ic_baseline_calendar_today_24.xml new file mode 100644 index 00000000..1cf969c7 --- /dev/null +++ b/Movies/app/src/main/res/drawable/ic_baseline_calendar_today_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/Movies/app/src/main/res/drawable/ic_baseline_favorite_24.xml b/Movies/app/src/main/res/drawable/ic_baseline_favorite_24.xml new file mode 100644 index 00000000..52d4d9bb --- /dev/null +++ b/Movies/app/src/main/res/drawable/ic_baseline_favorite_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/Movies/app/src/main/res/drawable/ic_baseline_list_24.xml b/Movies/app/src/main/res/drawable/ic_baseline_list_24.xml new file mode 100644 index 00000000..b0e68e0e --- /dev/null +++ b/Movies/app/src/main/res/drawable/ic_baseline_list_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/Movies/app/src/main/res/drawable/ic_baseline_local_movies_24.xml b/Movies/app/src/main/res/drawable/ic_baseline_local_movies_24.xml new file mode 100644 index 00000000..afcfb9c8 --- /dev/null +++ b/Movies/app/src/main/res/drawable/ic_baseline_local_movies_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/Movies/app/src/main/res/drawable/ic_baseline_star_24.xml b/Movies/app/src/main/res/drawable/ic_baseline_star_24.xml new file mode 100644 index 00000000..33832947 --- /dev/null +++ b/Movies/app/src/main/res/drawable/ic_baseline_star_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/Movies/app/src/main/res/drawable/ic_baseline_timer_24.xml b/Movies/app/src/main/res/drawable/ic_baseline_timer_24.xml new file mode 100644 index 00000000..18975721 --- /dev/null +++ b/Movies/app/src/main/res/drawable/ic_baseline_timer_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/Movies/app/src/main/res/drawable/ic_launcher_background.xml b/Movies/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/Movies/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Movies/app/src/main/res/drawable/shimmer_background.xml b/Movies/app/src/main/res/drawable/shimmer_background.xml new file mode 100644 index 00000000..e3131cd2 --- /dev/null +++ b/Movies/app/src/main/res/drawable/shimmer_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/layout/activity_main.xml b/Movies/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..22eb9f7c --- /dev/null +++ b/Movies/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,27 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/layout/activity_movie_details.xml b/Movies/app/src/main/res/layout/activity_movie_details.xml new file mode 100644 index 00000000..ded51595 --- /dev/null +++ b/Movies/app/src/main/res/layout/activity_movie_details.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/layout/activity_movie_details_placeholder.xml b/Movies/app/src/main/res/layout/activity_movie_details_placeholder.xml new file mode 100644 index 00000000..7996d878 --- /dev/null +++ b/Movies/app/src/main/res/layout/activity_movie_details_placeholder.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/layout/adapter_favorite_movie_item.xml b/Movies/app/src/main/res/layout/adapter_favorite_movie_item.xml new file mode 100644 index 00000000..5e23f135 --- /dev/null +++ b/Movies/app/src/main/res/layout/adapter_favorite_movie_item.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/layout/adapter_genre_item.xml b/Movies/app/src/main/res/layout/adapter_genre_item.xml new file mode 100644 index 00000000..e22020a5 --- /dev/null +++ b/Movies/app/src/main/res/layout/adapter_genre_item.xml @@ -0,0 +1,25 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/layout/adapter_movie_popular_item.xml b/Movies/app/src/main/res/layout/adapter_movie_popular_item.xml new file mode 100644 index 00000000..a9f8014d --- /dev/null +++ b/Movies/app/src/main/res/layout/adapter_movie_popular_item.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/layout/adapter_movie_popular_item_placeholder.xml b/Movies/app/src/main/res/layout/adapter_movie_popular_item_placeholder.xml new file mode 100644 index 00000000..e4c8e44f --- /dev/null +++ b/Movies/app/src/main/res/layout/adapter_movie_popular_item_placeholder.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/layout/adapter_movie_top_rated_item.xml b/Movies/app/src/main/res/layout/adapter_movie_top_rated_item.xml new file mode 100644 index 00000000..ad87e6ec --- /dev/null +++ b/Movies/app/src/main/res/layout/adapter_movie_top_rated_item.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/layout/adapter_movie_top_rated_item_placeholder.xml b/Movies/app/src/main/res/layout/adapter_movie_top_rated_item_placeholder.xml new file mode 100644 index 00000000..91420f8b --- /dev/null +++ b/Movies/app/src/main/res/layout/adapter_movie_top_rated_item_placeholder.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/layout/adapter_review_item.xml b/Movies/app/src/main/res/layout/adapter_review_item.xml new file mode 100644 index 00000000..cd6449c7 --- /dev/null +++ b/Movies/app/src/main/res/layout/adapter_review_item.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Movies/app/src/main/res/layout/dialog_error.xml b/Movies/app/src/main/res/layout/dialog_error.xml new file mode 100644 index 00000000..c023972f --- /dev/null +++ b/Movies/app/src/main/res/layout/dialog_error.xml @@ -0,0 +1,39 @@ + + + + + + + + + +