commit 5d129c11df3ed4e33e8f6d783f890a12674f2fc7 Author: hmalik144 Date: Tue Oct 13 12:52:11 2020 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..603b140 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.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 diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..38f564c --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +MovieListTest \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..88ea3aa --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,122 @@ + + + + + + + + + +
+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:name + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + name + + ^$ + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .* + + http://schemas.android.com/apk/res/android + + + ANDROID_ATTRIBUTE_ORDER + +
+
+ + + + .* + + .* + + + BY_NAME + +
+
+
+
+ + +
+
\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/dictionaries b/.idea/dictionaries new file mode 100644 index 0000000..ed3680a --- /dev/null +++ b/.idea/dictionaries @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..ac6b0ae --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..a5f05cd --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..8b7f4af --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..703e5d4 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..7f68460 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /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 0000000..2c388c7 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,93 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' + +android { + compileSdkVersion 30 + buildToolsVersion "29.0.3" + + defaultConfig { + applicationId "com.example.h_mal.movielisttest" + minSdkVersion 23 + targetSdkVersion 30 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + buildConfigField "String", "ParamOne", "${paramOne}" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + // To inline the bytecode built with JVM target 1.8 into + // bytecode that is being built with JVM target 1.6. (e.g. navArgs) + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.3.2' + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'androidx.constraintlayout:constraintlayout:2.0.2' + implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0' + implementation 'androidx.fragment:fragment-ktx:1.2.5' + implementation 'com.google.android.material:material:1.2.1' + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + testImplementation 'junit:junit:4.13' + androidTestImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test.ext:junit:1.1.2' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + // android unit testing and espresso + androidTestImplementation 'androidx.test.ext:junit:1.1.2' + implementation 'androidx.test.espresso:espresso-core:3.3.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" + //mock websever for testing retrofit responses + testImplementation "com.squareup.okhttp3:mockwebserver:4.6.0" + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" + + //mockito and livedata testing + testImplementation 'org.mockito:mockito-inline:2.13.0' + implementation 'android.arch.core:core-testing' + androidTestImplementation 'androidx.test:rules:1.3.0' + + //Retrofit and GSON + implementation 'com.squareup.retrofit2:retrofit:2.8.1' + implementation 'com.squareup.retrofit2:converter-gson:2.8.1' + implementation 'com.squareup.okhttp3:logging-interceptor:4.0.0' + + //Kotlin Coroutines + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.4" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.4" + + //Kodein Dependency Injection + implementation "org.kodein.di:kodein-di-generic-jvm:6.2.1" + implementation "org.kodein.di:kodein-di-framework-android-x:6.2.1" + + // Shared prefs + implementation "androidx.preference:preference-ktx:1.1.1" + + // Picasso image display + implementation 'com.squareup.picasso:picasso:2.71828' + + //Android Room + implementation "androidx.room:room-runtime:2.3.0-alpha01" + implementation "androidx.room:room-ktx:2.3.0-alpha01" + kapt "androidx.room:room-compiler:2.3.0-alpha01" + + // Circle Image View + implementation 'com.mikhaellopez:circularimageview:4.2.0' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /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/example/h_mal/movielisttest/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/h_mal/movielisttest/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..cffc845 --- /dev/null +++ b/app/src/androidTest/java/com/example/h_mal/movielisttest/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.example.h_mal.movielisttest + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 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.example.h_mal.movielisttest", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/h_mal/movielisttest/data/room/MoviesRoomDatabaseTest.kt b/app/src/androidTest/java/com/example/h_mal/movielisttest/data/room/MoviesRoomDatabaseTest.kt new file mode 100644 index 0000000..85c23da --- /dev/null +++ b/app/src/androidTest/java/com/example/h_mal/movielisttest/data/room/MoviesRoomDatabaseTest.kt @@ -0,0 +1,63 @@ +package com.example.h_mal.movielisttest.data.room + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.matcher.ViewMatchers.assertThat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.hamcrest.CoreMatchers.equalTo +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class MoviesRoomDatabaseTest{ + private lateinit var simpleDao: SimpleDao + private lateinit var db: MoviesRoomDatabase + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder( + context, MoviesRoomDatabase::class.java).build() + simpleDao = db.getSimpleDao() + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + @Throws(Exception::class) + fun writeEntryAndReadResponse() = runBlocking{ + // Given + val entity = MovieEntity(123) + // When + simpleDao.saveAllItems(listOf(entity)) + // Then + val retrieved = simpleDao.getItem(123) + assertThat(retrieved, equalTo(entity)) + } + + @Test + @Throws(Exception::class) + fun changeFavouriteAndRead() = runBlocking{ + // Given + val entity = MovieEntity(123) + simpleDao.saveAllItems(listOf(entity)) + val retrieved = simpleDao.getItem(123) + + // When + simpleDao.updateFavourite(123) + + // Then + val favourite = retrieved.favourites + val retrieveAgain = simpleDao.getItem(123) + assertThat(retrieveAgain.favourites, equalTo(!favourite)) + } +} \ 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 0000000..e860407 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/MainActivity.kt b/app/src/main/java/com/example/h_mal/movielisttest/MainActivity.kt new file mode 100644 index 0000000..2313fe8 --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/MainActivity.kt @@ -0,0 +1,18 @@ +package com.example.h_mal.movielisttest + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.example.h_mal.movielisttest.ui.main.MainFragment + +class MainActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.main_activity) + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction() + .replace(R.id.container, MainFragment()) + .commitNow() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/application/MovieListApplication.kt b/app/src/main/java/com/example/h_mal/movielisttest/application/MovieListApplication.kt new file mode 100644 index 0000000..ceba3ad --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/application/MovieListApplication.kt @@ -0,0 +1,33 @@ +package com.example.h_mal.movielisttest.application + +import android.app.Application +import com.example.h_mal.movielisttest.data.network.MoviesApi +import com.example.h_mal.movielisttest.data.network.interceptors.NetworkConnectionInterceptor +import com.example.h_mal.movielisttest.data.network.interceptors.QueryParamsInterceptor +import com.example.h_mal.movielisttest.data.prefs.PreferenceProvider +import com.example.h_mal.movielisttest.data.repository.RepositoryImpl +import com.example.h_mal.movielisttest.data.room.MoviesRoomDatabase +import com.example.h_mal.movielisttest.ui.main.MainViewModelFactory +import org.kodein.di.Kodein +import org.kodein.di.KodeinAware +import org.kodein.di.android.x.androidXModule +import org.kodein.di.generic.bind +import org.kodein.di.generic.instance +import org.kodein.di.generic.provider +import org.kodein.di.generic.singleton + +class MovieListApplication : Application(), KodeinAware{ + + // Kodein creation of modules to be retrieve within the app + override val kodein = Kodein.lazy { + import(androidXModule(this@MovieListApplication)) + + bind() from singleton { NetworkConnectionInterceptor(instance()) } + bind() from singleton { QueryParamsInterceptor() } + bind() from singleton { MoviesApi(instance(), instance())} + bind() from singleton { MoviesRoomDatabase(instance()) } + bind() from singleton { PreferenceProvider(instance()) } + bind() from singleton { RepositoryImpl(instance(), instance(), instance()) } + bind() from provider { MainViewModelFactory(instance()) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/models/Movie.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/models/Movie.kt new file mode 100644 index 0000000..3604348 --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/models/Movie.kt @@ -0,0 +1,26 @@ +package com.example.h_mal.movielisttest.data.models + +import com.example.h_mal.movielisttest.data.room.MovieEntity + +data class Movie( + val id: Int? = null, + val overview: String? = null, + var favourites: Boolean? = null, + val title: String? = null, + val posterPath: String? = null, + val releaseDate: String? = null, + val popularity: Double? = null, + val voteAverage: Double? = null +){ + + constructor(movieEntity: MovieEntity): this( + movieEntity.id, + movieEntity.overview, + movieEntity.favourites, + movieEntity.title, + movieEntity.posterPath, + movieEntity.releaseDate, + movieEntity.popularity, + movieEntity.voteAverage + ) +} diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/network/MoviesApi.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/network/MoviesApi.kt new file mode 100644 index 0000000..914cf8c --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/network/MoviesApi.kt @@ -0,0 +1,49 @@ +package com.example.h_mal.movielisttest.data.network + +import com.example.h_mal.movielisttest.data.network.interceptors.NetworkConnectionInterceptor +import com.example.h_mal.movielisttest.data.network.interceptors.QueryParamsInterceptor +import com.example.h_mal.movielisttest.data.network.response.MoviesResponse +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET +import retrofit2.http.Query + + +interface MoviesApi { + + // https://api.themoviedb.org/3/movie/popular?api_key=&language=en-US&page=1 + @GET("movie/popular?") + suspend fun getFromApi( + @Query("page") pageNumber: Int + ): Response + + companion object{ + operator fun invoke( + networkConnectionInterceptor: NetworkConnectionInterceptor, + queryParamsInterceptor: QueryParamsInterceptor + ) : MoviesApi { + + val baseUrl = "https://api.themoviedb.org/3/" + + // okHttpClient + val okkHttpclient = OkHttpClient.Builder() + .addNetworkInterceptor(networkConnectionInterceptor) + .addInterceptor(queryParamsInterceptor) + .addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }) + .build() + + return Retrofit.Builder() + .client(okkHttpclient) + .baseUrl(baseUrl) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(MoviesApi::class.java) + } + } +} + diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/network/interceptors/NetworkConnectionInterceptor.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/network/interceptors/NetworkConnectionInterceptor.kt new file mode 100644 index 0000000..486a90c --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/network/interceptors/NetworkConnectionInterceptor.kt @@ -0,0 +1,42 @@ +package com.example.h_mal.movielisttest.data.network.interceptors + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import okhttp3.Interceptor +import java.io.IOException + +/** + * Intercept API calls and determine if there is a connection. + * If no connection then @throws IOException + */ +class NetworkConnectionInterceptor( + context: Context +) : Interceptor { + + private val applicationContext = context.applicationContext + + override fun intercept(chain: Interceptor.Chain): okhttp3.Response { + if (!isInternetAvailable()){ + throw IOException("Make sure you have an active data connection") + } + return chain.proceed(chain.request()) + } + + private fun isInternetAvailable(): Boolean { + var result = false + val connectivityManager = + applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager? + connectivityManager?.let { + it.getNetworkCapabilities(connectivityManager.activeNetwork)?.apply { + result = when { + hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + else -> false + } + } + } + return result + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/network/interceptors/QueryParamsInterceptor.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/network/interceptors/QueryParamsInterceptor.kt new file mode 100644 index 0000000..808f898 --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/network/interceptors/QueryParamsInterceptor.kt @@ -0,0 +1,30 @@ +package com.example.h_mal.movielisttest.data.network.interceptors + +import com.example.h_mal.movielisttest.BuildConfig +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response + +/** + * Inject query parameters into the API calls. + * For uniform constraints (eg. Language, sort order, page size ect) + * Also for injecting an API key to all api calls + */ +class QueryParamsInterceptor : Interceptor{ + + override fun intercept(chain: Interceptor.Chain): Response { + val original = chain.request() + val originalHttpUrl = original.url + + val url = originalHttpUrl.newBuilder() + .addQueryParameter("language", "en-UK") + .addQueryParameter("api_key", BuildConfig.ParamOne) + .build() + + val requestBuilder: Request.Builder = original.newBuilder() + .url(url) + + val request: Request = requestBuilder.build() + return chain.proceed(request) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/network/networkUtils/OkHttpClient.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/network/networkUtils/OkHttpClient.kt new file mode 100644 index 0000000..f4fba7c --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/network/networkUtils/OkHttpClient.kt @@ -0,0 +1,21 @@ +package com.example.h_mal.movielisttest.data.network.networkUtils + +import com.example.h_mal.movielisttest.data.network.interceptors.NetworkConnectionInterceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import java.util.concurrent.TimeUnit + +fun okHttpClient( + networkConnectionInterceptor: NetworkConnectionInterceptor + +): OkHttpClient { + val logging: HttpLoggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + return OkHttpClient.Builder() + .addNetworkInterceptor(networkConnectionInterceptor) + .addInterceptor(logging) + .readTimeout(5 * 60, TimeUnit.SECONDS) + .build() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/network/networkUtils/ResponseUnwrap.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/network/networkUtils/ResponseUnwrap.kt new file mode 100644 index 0000000..f49b633 --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/network/networkUtils/ResponseUnwrap.kt @@ -0,0 +1,37 @@ +package com.example.h_mal.movielisttest.data.network.networkUtils + +import org.json.JSONException +import org.json.JSONObject +import retrofit2.Response +import java.io.IOException + +/** + * Abstract class for extracting from Retrofit Response + * Or throw IOException if the API call fails + */ +abstract class ResponseUnwrap { + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun responseUnwrap( + call: suspend () -> Response + ): T { + + val response = call.invoke() + if (response.isSuccessful) { + return response.body()!! + } else { + val error = response.errorBody()?.string() + + val errorMessage = error?.let { + try { + JSONObject(it).getString("status_message") + } catch (e: JSONException) { + e.printStackTrace() + null + } + } ?: "Error Code: ${response.code()}" + + throw IOException(errorMessage) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/network/response/MoviesResponse.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/network/response/MoviesResponse.kt new file mode 100644 index 0000000..07741ce --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/network/response/MoviesResponse.kt @@ -0,0 +1,18 @@ +package com.example.h_mal.movielisttest.data.network.response + +import com.google.gson.annotations.SerializedName + +data class MoviesResponse( + + @field:SerializedName("page") + val page: Int? = null, + + @field:SerializedName("total_pages") + val totalPages: Int? = null, + + @field:SerializedName("results") + val results: List? = null, + + @field:SerializedName("total_results") + val totalResults: Int? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/network/response/ResultsItem.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/network/response/ResultsItem.kt new file mode 100644 index 0000000..b71b0f4 --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/network/response/ResultsItem.kt @@ -0,0 +1,48 @@ +package com.example.h_mal.movielisttest.data.network.response + +import com.google.gson.annotations.SerializedName + +data class ResultsItem( + + @field:SerializedName("overview") + val overview: String? = null, + + @field:SerializedName("original_language") + val originalLanguage: String? = null, + + @field:SerializedName("original_title") + val originalTitle: String? = null, + + @field:SerializedName("video") + val video: Boolean? = null, + + @field:SerializedName("title") + val title: String? = null, + + @field:SerializedName("genre_ids") + val genreIds: List? = null, + + @field:SerializedName("poster_path") + val posterPath: String? = null, + + @field:SerializedName("backdrop_path") + val backdropPath: String? = null, + + @field:SerializedName("release_date") + val releaseDate: String? = null, + + @field:SerializedName("popularity") + val popularity: Double? = null, + + @field:SerializedName("vote_average") + val voteAverage: Double? = null, + + @field:SerializedName("id") + val id: Int? = null, + + @field:SerializedName("adult") + val adult: Boolean? = null, + + @field:SerializedName("vote_count") + val voteCount: Int? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/prefs/PreferencesProvider.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/prefs/PreferencesProvider.kt new file mode 100644 index 0000000..aa8f578 --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/prefs/PreferencesProvider.kt @@ -0,0 +1,33 @@ +package com.example.h_mal.movielisttest.data.prefs + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceManager + +/** + * Shared preferences to save & load last timestamp + */ +private const val PAGE_NUMBER = "page_number" +class PreferenceProvider( + context: Context +) { + + private val appContext = context.applicationContext + + private val preference: SharedPreferences + get() = PreferenceManager.getDefaultSharedPreferences(appContext) + + fun savePageNumber() { + var pages = getPageNumber() + pages++ + preference.edit().putInt( + PAGE_NUMBER, + pages + ).apply() + } + + fun getPageNumber(): Int { + return preference.getInt(PAGE_NUMBER, 1) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/repository/Repository.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/repository/Repository.kt new file mode 100644 index 0000000..18cb10d --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/repository/Repository.kt @@ -0,0 +1,15 @@ +package com.example.h_mal.movielisttest.data.repository + +import androidx.lifecycle.LiveData +import com.example.h_mal.movielisttest.data.network.response.MoviesResponse +import com.example.h_mal.movielisttest.data.network.response.ResultsItem +import com.example.h_mal.movielisttest.data.room.MovieEntity + +interface Repository { + suspend fun getMoviesFromApi(pageNumber: Int): MoviesResponse? + fun getMoviesFromDatabase(): LiveData> + suspend fun saveMoviesToDatabase(resultsItems: List) + suspend fun setFavourite(id: Int) + fun getCurrentPage(): Int + fun updateCurrentPage() +} diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/repository/RepositoryImpl.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/repository/RepositoryImpl.kt new file mode 100644 index 0000000..91bf5a0 --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/repository/RepositoryImpl.kt @@ -0,0 +1,36 @@ +package com.example.h_mal.movielisttest.data.repository + +import com.example.h_mal.movielisttest.data.network.MoviesApi +import com.example.h_mal.movielisttest.data.network.networkUtils.ResponseUnwrap +import com.example.h_mal.movielisttest.data.network.response.MoviesResponse +import com.example.h_mal.movielisttest.data.network.response.ResultsItem +import com.example.h_mal.movielisttest.data.prefs.PreferenceProvider +import com.example.h_mal.movielisttest.data.room.MovieEntity +import com.example.h_mal.movielisttest.data.room.MoviesRoomDatabase + +class RepositoryImpl( + private val api: MoviesApi, + private val database: MoviesRoomDatabase, + private val preferences: PreferenceProvider +) : Repository, ResponseUnwrap() { + + + override suspend fun getMoviesFromApi(pageNumber: Int): MoviesResponse? { + return responseUnwrap { api.getFromApi(pageNumber) } + } + + override fun getMoviesFromDatabase() = database.getSimpleDao().getAllItems() + + override suspend fun saveMoviesToDatabase(resultsItems: List){ + val userList = resultsItems.map { MovieEntity(it) } + database.getSimpleDao().saveAllItems(userList) + } + + override suspend fun setFavourite(id: Int) = database.getSimpleDao().updateFavourite(id) + + override fun getCurrentPage(): Int = preferences.getPageNumber() + + override fun updateCurrentPage(){ + preferences.savePageNumber() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/room/MovieEntity.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/room/MovieEntity.kt new file mode 100644 index 0000000..8575383 --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/room/MovieEntity.kt @@ -0,0 +1,29 @@ +package com.example.h_mal.movielisttest.data.room + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.example.h_mal.movielisttest.data.network.response.ResultsItem + +@Entity +data class MovieEntity( + @PrimaryKey(autoGenerate = false) + val id: Int, + val overview: String? = null, + var favourites: Boolean = false, + val title: String? = null, + val posterPath: String? = null, + val releaseDate: String? = null, + val popularity: Double? = null, + val voteAverage: Double? = null +){ + constructor(resultsItem: ResultsItem): this( + resultsItem.id!!, + resultsItem.overview, + false, + resultsItem.title, + "https://image.tmdb.org/t/p/w500${resultsItem.posterPath}", + resultsItem.releaseDate, + resultsItem.popularity, + resultsItem.voteAverage + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/room/MoviesRoomDatabase.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/room/MoviesRoomDatabase.kt new file mode 100644 index 0000000..73d08dc --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/room/MoviesRoomDatabase.kt @@ -0,0 +1,40 @@ +package com.example.h_mal.movielisttest.data.room + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + + +/** + * Room database class for caching movies locally. + */ +@Database( + entities = [MovieEntity::class], + version = 1 +) +abstract class MoviesRoomDatabase : RoomDatabase() { + + abstract fun getSimpleDao(): SimpleDao + + companion object { + + @Volatile + private var instance: MoviesRoomDatabase? = null + private val LOCK = Any() + + + operator fun invoke(context: Context) = instance ?: synchronized(LOCK) { + instance ?: buildDatabase(context).also { + instance = it + } + } + + private fun buildDatabase(context: Context) = + Room.databaseBuilder( + context.applicationContext, + MoviesRoomDatabase::class.java, + "MyDatabase.db" + ).build() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/data/room/SimpleDao.kt b/app/src/main/java/com/example/h_mal/movielisttest/data/room/SimpleDao.kt new file mode 100644 index 0000000..d45a8a1 --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/data/room/SimpleDao.kt @@ -0,0 +1,32 @@ +package com.example.h_mal.movielisttest.data.room + +import androidx.lifecycle.LiveData +import androidx.room.* + +@Dao +interface SimpleDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun saveAllItems(items: List) + + @Query("SELECT * FROM MovieEntity") + fun getAllItems(): LiveData> + + @Query("SELECT * FROM MovieEntity WHERE id = :id") + suspend fun getItem(id: Int): MovieEntity + + @Query("UPDATE MovieEntity SET favourites = :favourite WHERE id = :id") + fun setFavourite(id: Int, favourite: Boolean) + + @Query("DELETE FROM MovieEntity") + suspend fun deleteEntries() + + @Transaction + suspend fun updateFavourite(id: Int){ + val fav = getItem(id).favourites + setFavourite(id, !fav) + } + + @Delete + fun deleteEntry(movie: MovieEntity) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/ui/main/MainFragment.kt b/app/src/main/java/com/example/h_mal/movielisttest/ui/main/MainFragment.kt new file mode 100644 index 0000000..f5f6ff0 --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/ui/main/MainFragment.kt @@ -0,0 +1,80 @@ +package com.example.h_mal.movielisttest.ui.main + +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.lifecycle.Observer +import androidx.recyclerview.widget.SimpleItemAnimator +import com.example.h_mal.movielisttest.R +import com.example.h_mal.movielisttest.utils.* +import kotlinx.android.synthetic.main.empty_view_item.view.* +import kotlinx.android.synthetic.main.main_fragment.* +import org.kodein.di.KodeinAware +import org.kodein.di.android.x.kodein +import org.kodein.di.generic.instance + +class MainFragment : Fragment(), KodeinAware { + override val kodein by kodein() + private val factory by instance() + + private val viewModel by viewModels { factory } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View { + return inflater.inflate(R.layout.main_fragment, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.operationState.observe(viewLifecycleOwner, stateObserver) + viewModel.operationError.observe(viewLifecycleOwner, errorObserver) + + val mAdapter = MoviesRecyclerViewAdapter( + favouriteClickListener = { + viewModel.setFavourite(it) + } + ) + mAdapter.setHasStableIds(true) + + + recycler_view.apply { + setHasFixedSize(true) + adapter = mAdapter + scrollBottomReachedListener{ + viewModel.loadMoreMovies() + } + } + + viewModel.moviesLiveData.observe(viewLifecycleOwner, Observer { + empty_layout.hide() + mAdapter.updateList(it) + }) + + empty_layout.refresh.setOnClickListener { + viewModel.loadMovies() + } + } + + // toggle visibility of progress spinner while async operations are taking place + private val stateObserver = Observer> { + when(it.getContentIfNotHandled()){ + true -> { + progress_circular.show() + } + false -> { + progress_circular.hide() + } + } + } + + + private val errorObserver = Observer> { + it.getContentIfNotHandled()?.let { message -> + requireContext().displayToast(message) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/ui/main/MainViewModel.kt b/app/src/main/java/com/example/h_mal/movielisttest/ui/main/MainViewModel.kt new file mode 100644 index 0000000..a3cf95a --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/ui/main/MainViewModel.kt @@ -0,0 +1,87 @@ +package com.example.h_mal.movielisttest.ui.main + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel +import com.example.h_mal.movielisttest.data.models.Movie +import com.example.h_mal.movielisttest.data.repository.Repository +import com.example.h_mal.movielisttest.data.room.MovieEntity +import com.example.h_mal.movielisttest.utils.Event +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.IOException + +class MainViewModel( + private val repository: Repository +) : ViewModel() { + + val moviesLiveData = MutableLiveData>() + + val operationState = MutableLiveData>() + val operationError = MutableLiveData>() + + init { + val observer = Observer> { + val list = it.map {entity -> Movie(entity) } + moviesLiveData.postValue(list) + } + repository.getMoviesFromDatabase().observeForever (observer) + + loadMovies() + } + + fun loadMovies(){ + CoroutineScope(Dispatchers.IO).launch { + operationState.postValue(Event(true)) + try { + val response = repository.getMoviesFromApi(1) + // null check response exists and contains list of users + response?.results?.let { + // save users to database + repository.saveMoviesToDatabase(it) + } + }catch (e: IOException){ + operationError.postValue(Event(e.message!!)) + }finally { + operationState.postValue(Event(false)) + } + } + } + + fun loadMoreMovies(){ + CoroutineScope(Dispatchers.IO).launch { + operationState.postValue(Event(true)) + try { + val page = repository.getCurrentPage() + + val response = repository.getMoviesFromApi(page) + // null check response exists and contains list of users + response?.results?.let { + // save users to database + repository.saveMoviesToDatabase(it) + // update current page + repository.updateCurrentPage() + } + }catch (e: IOException){ + operationError.postValue(Event(e.message!!)) + }finally { + operationState.postValue(Event(false)) + } + } + } + + fun setFavourite(id: Int){ + CoroutineScope(Dispatchers.IO).launch { + operationState.postValue(Event(true)) + try { + // Set favourite + repository.setFavourite(id) + }catch (e: IOException){ + operationError.postValue(Event(e.message!!)) + }finally { + operationState.postValue(Event(false)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/ui/main/MainViewModelFactory.kt b/app/src/main/java/com/example/h_mal/movielisttest/ui/main/MainViewModelFactory.kt new file mode 100644 index 0000000..aed8827 --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/ui/main/MainViewModelFactory.kt @@ -0,0 +1,22 @@ +package com.example.h_mal.movielisttest.ui.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.example.h_mal.movielisttest.data.repository.Repository + +/** + * Viewmodel factory for [MainViewModel] + * @repository injected into MainViewModel + */ +class MainViewModelFactory( + private val repository: Repository +) : ViewModelProvider.Factory{ + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(MainViewModel::class.java)) { + return (MainViewModel(repository)) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/ui/main/MoviesRecyclerViewAdapter.kt b/app/src/main/java/com/example/h_mal/movielisttest/ui/main/MoviesRecyclerViewAdapter.kt new file mode 100644 index 0000000..548ceda --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/ui/main/MoviesRecyclerViewAdapter.kt @@ -0,0 +1,82 @@ +package com.example.h_mal.movielisttest.ui.main + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.example.h_mal.movielisttest.R +import com.example.h_mal.movielisttest.data.models.Movie +import com.example.h_mal.movielisttest.utils.loadImage +import com.squareup.picasso.Picasso +import kotlinx.android.synthetic.main.item_layout.view.* + +/** + * Recycler view adapter to bind movies to a recycler view with + */ +class MoviesRecyclerViewAdapter( + val favouriteClickListener: (Int) -> Unit +) : RecyclerView.Adapter() { + + var list = mutableListOf() + + fun updateList(movies: List) { + list.clear() + list.addAll(movies) + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val itemTwo = + LayoutInflater.from(parent.context).inflate(R.layout.item_layout, parent, false) + return ItemOne(itemTwo) + } + + override fun getItemCount(): Int { + return list.size + } + + override fun getItemId(position: Int): Long { + list[position].id?.let { + return it.toLong() + } + return super.getItemId(position) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (holder is ItemOne) { + holder.populateMovie(list[position]) + holder.favourite.setOnClickListener { + list[position].id?.let { id -> favouriteClickListener(id) } + } + } + } + + internal inner class ItemOne(itemView: View) : RecyclerView.ViewHolder(itemView) { + val title: TextView = itemView.title_tv + val description: TextView = itemView.desc_tv + val dateTv: TextView = itemView.date_tv + val favourite: ImageView = itemView.fav_btn + val cellImageView: ImageView = itemView.movie_iv + val voteTextView: TextView = itemView.vote_average_tv + + fun populateMovie(movie: Movie) { + title.text = movie.title + description.text = movie.overview + dateTv.text = movie.releaseDate + movie.favourites?.let { setFavourite(it) } + voteTextView.text = movie.voteAverage.toString() + + cellImageView.loadImage(movie.posterPath) + } + + private fun setFavourite(fav: Boolean) { + if (fav) { + favourite.setImageResource(android.R.drawable.btn_star_big_on) + } else { + favourite.setImageResource(android.R.drawable.btn_star_big_off) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/utils/Event.kt b/app/src/main/java/com/example/h_mal/movielisttest/utils/Event.kt new file mode 100644 index 0000000..3ef8890 --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/utils/Event.kt @@ -0,0 +1,24 @@ +package com.example.h_mal.movielisttest.utils + +/** + * Used with livedata to make observation lifecycle aware + * Display livedata response only once + */ +open class Event(private val content: T) { + + var hasBeenHandled = false + private set // Allow external read but not write + + /** + * Returns the content and prevents its use again. + */ + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) { + null + } else { + hasBeenHandled = true + content + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/h_mal/movielisttest/utils/ViewUtils.kt b/app/src/main/java/com/example/h_mal/movielisttest/utils/ViewUtils.kt new file mode 100644 index 0000000..061cd50 --- /dev/null +++ b/app/src/main/java/com/example/h_mal/movielisttest/utils/ViewUtils.kt @@ -0,0 +1,41 @@ +package com.example.h_mal.movielisttest.utils + +import android.content.Context +import android.view.View +import android.widget.ImageView +import android.widget.Toast +import androidx.recyclerview.widget.RecyclerView +import com.example.h_mal.movielisttest.R +import com.squareup.picasso.Picasso + +fun View.show() { + this.visibility = View.VISIBLE +} + +fun View.hide() { + this.visibility = View.GONE +} + +fun Context.displayToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() +} + + +fun ImageView.loadImage(url: String?){ + Picasso.get() + .load(url) + .fit() + .centerCrop() + .into(this) +} + +fun RecyclerView.scrollBottomReachedListener(bottomReached: () -> Unit){ + addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + if (!recyclerView.canScrollVertically(1) && newState == RecyclerView.SCROLL_STATE_IDLE) { + bottomReached() + } + } + }) +} \ 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 0000000..2b068d1 --- /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/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/mad_max_sample.jpg b/app/src/main/res/drawable/mad_max_sample.jpg new file mode 100644 index 0000000..ba9ec39 Binary files /dev/null and b/app/src/main/res/drawable/mad_max_sample.jpg differ diff --git a/app/src/main/res/layout/empty_view_item.xml b/app/src/main/res/layout/empty_view_item.xml new file mode 100644 index 0000000..dc0ca2a --- /dev/null +++ b/app/src/main/res/layout/empty_view_item.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + +