diff --git a/.editorconfig b/.editorconfig
index 62bd415..7137309 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,5 +1,5 @@
-[*.{kt,kts}]
+[*.{kt,kts,xml}]
indent_size=4
insert_final_newline=true
max_line_length=120
-disabled_rules=no-wildcard-imports,experimental:annotation, import-ordering
+disabled_rules=no-wildcard-imports,experimental:annotation, import-ordering, trailing-comma-on-declaration-site, wrapping, experimental:property-naming, experimental:function-signature
diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
new file mode 100644
index 0000000..0c0c338
--- /dev/null
+++ b/.idea/deploymentTargetDropDown.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..0bb46bb
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e7c5b75..cfe85d4 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,7 +1,11 @@
+import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
+
plugins {
id(Plugins.android_application)
kotlin(Plugins.kotlin_android)
+ id(Plugins.kotlin_parcelize)
id(Plugins.detekt).version(Versions.detekt)
+ id(Plugins.ksp).version(Versions.ksp)
}
buildscript {
@@ -9,7 +13,7 @@ buildscript {
}
android {
- namespace = "com.sun.structure_android"
+ namespace = "com.sun.android"
compileSdk = AppConfigs.compile_sdk_version
defaultConfig {
@@ -18,6 +22,9 @@ android {
targetSdk = AppConfigs.target_sdk_version
versionCode = AppConfigs.version_code
versionName = AppConfigs.version_name
+
+ buildConfigField("String", "API_KEY", gradleLocalProperties(rootDir).getProperty("api_key"))
+ buildConfigField("String", "BASE_URL_IMAGE", gradleLocalProperties(rootDir).getProperty("base_url_image"))
}
@Suppress("UnstableApiUsage")
@@ -27,9 +34,11 @@ android {
create("dev") {
applicationIdSuffix = ".dev"
resValue("string", "app_name", "Structure-Dev")
+ buildConfigField("String", "BASE_URL", "\"https://api.themoviedb.org/3/\"")
}
create("prd") {
resValue("string", "app_name", "Structure")
+ buildConfigField("String", "BASE_URL", "\"https://api.themoviedb.org/3/\"")
versionCode = AppConfigs.version_code_release
versionName = AppConfigs.version_name_release
}
@@ -52,11 +61,14 @@ android {
}
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_11
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
- jvmTarget = "11"
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ viewBinding = true
}
}
@@ -90,6 +102,42 @@ dependencies {
implementation(Deps.material)
implementation(Deps.constraint_layout)
+ //Navigation
+ implementation(Deps.navigation_fragment)
+ implementation(Deps.navigation_ui)
+ implementation(Deps.navigation_fragment_ktx)
+ implementation(Deps.navigation_ui_ktx)
+
+ //Lifecycle
+ implementation(Deps.lifecycle_livedata_ktx)
+ implementation(Deps.lifecycle_viewmodel_ktx)
+ implementation(Deps.lifecycle_runtime)
+
+ //Coroutine
+ implementation(Deps.coroutines_core)
+ implementation(Deps.coroutines_android)
+ testImplementation(Deps.coroutines_test)
+
+ //Retrofit
+ implementation(Deps.okHttp)
+ implementation(Deps.retrofit_runtime)
+ implementation(Deps.retrofit_gson)
+ implementation(Deps.okhttp_logging_interceptor)
+
+ //Koin
+ implementation(Deps.koin)
+
+ //Glide
+ implementation(Deps.glide_runtime)
+ implementation(Deps.glide_compiler)
+
+ // Room
+ implementation(Deps.room_runtime)
+ ksp(Deps.room_ksp)
+ implementation(Deps.room_ktx)
+
+ //Test
testImplementation(Deps.junit)
testImplementation(Deps.mockk)
+
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0602b83..768ea14 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,8 +1,11 @@
+ package="com.sun.android">
+
+
@@ -20,4 +23,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/java/com/sun/android/AndroidApplication.kt b/app/src/main/java/com/sun/android/AndroidApplication.kt
new file mode 100644
index 0000000..a21618b
--- /dev/null
+++ b/app/src/main/java/com/sun/android/AndroidApplication.kt
@@ -0,0 +1,26 @@
+package com.sun.android
+
+import android.app.Application
+import com.sun.android.di.DataSourceModule
+import com.sun.android.di.NetworkModule
+import com.sun.android.di.RepositoryModule
+import com.sun.android.di.AppModule
+import com.sun.android.di.ViewModelModule
+import org.koin.android.ext.koin.androidContext
+import org.koin.android.ext.koin.androidFileProperties
+import org.koin.core.context.startKoin
+
+class AndroidApplication : Application() {
+
+ private val rootModule = listOf(AppModule, NetworkModule, DataSourceModule, RepositoryModule, ViewModelModule)
+
+ override fun onCreate() {
+ super.onCreate()
+
+ startKoin {
+ androidContext(this@AndroidApplication)
+ androidFileProperties()
+ modules(rootModule)
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/MainActivity.kt b/app/src/main/java/com/sun/android/MainActivity.kt
index 49d5b92..81e021a 100644
--- a/app/src/main/java/com/sun/android/MainActivity.kt
+++ b/app/src/main/java/com/sun/android/MainActivity.kt
@@ -2,7 +2,6 @@ package com.sun.android
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
-import com.sun.structure_android.R
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
diff --git a/app/src/main/java/com/sun/android/base/BaseActivity.kt b/app/src/main/java/com/sun/android/base/BaseActivity.kt
new file mode 100644
index 0000000..601c4b0
--- /dev/null
+++ b/app/src/main/java/com/sun/android/base/BaseActivity.kt
@@ -0,0 +1,30 @@
+package com.sun.android.base
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.ViewModel
+import androidx.viewbinding.ViewBinding
+
+typealias ActivityInflate = (LayoutInflater) -> T
+
+abstract class BaseActivity(private val inflate: ActivityInflate) : AppCompatActivity() {
+ private var _binding: VB? = null
+
+ val binding get() = _binding!!
+ abstract val viewModel: ViewModel
+
+ protected abstract fun initialize()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ _binding = inflate.invoke(layoutInflater)
+ setContentView(binding.root)
+ initialize()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/com/sun/android/base/BaseFragment.kt b/app/src/main/java/com/sun/android/base/BaseFragment.kt
new file mode 100644
index 0000000..7a40ed7
--- /dev/null
+++ b/app/src/main/java/com/sun/android/base/BaseFragment.kt
@@ -0,0 +1,44 @@
+package com.sun.android.base
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModel
+import androidx.viewbinding.ViewBinding
+
+typealias FragmentInflate = (LayoutInflater, ViewGroup?, Boolean) -> T
+
+/**
+ * class HomeFragment() : BaseFragment(FragmentHomeBinding::inflate) {
+ */
+abstract class BaseFragment(private val inflate: FragmentInflate) : Fragment() {
+ private var _binding: VB? = null
+
+ val binding get() = _binding!!
+ abstract val viewModel: ViewModel
+
+ protected abstract fun initView()
+
+ protected abstract fun initData()
+
+ protected abstract fun bindData()
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ _binding = inflate.invoke(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ initView()
+ initData()
+ bindData()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/app/src/main/java/com/sun/android/data/MovieRepository.kt b/app/src/main/java/com/sun/android/data/MovieRepository.kt
new file mode 100644
index 0000000..5b117a4
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/MovieRepository.kt
@@ -0,0 +1,10 @@
+package com.sun.android.data
+
+import com.sun.android.data.model.Movie
+import kotlinx.coroutines.flow.Flow
+
+interface MovieRepository {
+ suspend fun getMovies(): Flow>
+
+ suspend fun getDetailMovies(movieId: Int): Flow
+}
diff --git a/app/src/main/java/com/sun/android/data/TokenRepository.kt b/app/src/main/java/com/sun/android/data/TokenRepository.kt
new file mode 100644
index 0000000..b584601
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/TokenRepository.kt
@@ -0,0 +1,9 @@
+package com.sun.android.data
+
+interface TokenRepository {
+ fun getToken(): String?
+
+ fun saveToken(token: String)
+
+ fun clearToken()
+}
diff --git a/app/src/main/java/com/sun/android/data/model/Movie.kt b/app/src/main/java/com/sun/android/data/model/Movie.kt
new file mode 100644
index 0000000..8ff607d
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/model/Movie.kt
@@ -0,0 +1,38 @@
+package com.sun.android.data.model
+
+import android.os.Parcelable
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.google.gson.annotations.Expose
+import com.google.gson.annotations.SerializedName
+import kotlinx.android.parcel.Parcelize
+
+@Parcelize
+@Entity(tableName = "movies")
+data class Movie(
+ @PrimaryKey
+ @SerializedName("id")
+ @Expose
+ var id: Int = -1,
+ @SerializedName("backdrop_path")
+ @Expose
+ var backDropImage: String = "",
+ @SerializedName("overview")
+ @Expose
+ var overView: String = "",
+ @SerializedName("vote_average")
+ @Expose
+ var vote: Double = 0.0,
+ @SerializedName("vote_count")
+ @Expose
+ var voteCount: Int = 0,
+ @SerializedName("title")
+ @Expose
+ var title: String = "",
+ @SerializedName("poster_path")
+ @Expose
+ var urlImage: String = "",
+ @SerializedName("original_title")
+ @Expose
+ var originalTitle: String = ""
+) : Parcelable
diff --git a/app/src/main/java/com/sun/android/data/repository/MovieRepositoryImpl.kt b/app/src/main/java/com/sun/android/data/repository/MovieRepositoryImpl.kt
new file mode 100644
index 0000000..31d9cc6
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/repository/MovieRepositoryImpl.kt
@@ -0,0 +1,37 @@
+package com.sun.android.data.repository
+
+import android.util.Log
+import com.sun.android.data.MovieRepository
+import com.sun.android.data.model.Movie
+import com.sun.android.data.source.MovieDataSource
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import org.koin.core.component.KoinComponent
+import java.io.IOException
+
+class MovieRepositoryImpl(
+ private val remote: MovieDataSource.Remote,
+ private val local: MovieDataSource.Local
+) : KoinComponent, MovieRepository {
+
+ override suspend fun getMovies(): Flow> = flow {
+ try {
+ val movies = remote.getMovies().data
+ local.updateMovies(movies)
+ } catch (e: IOException) {
+ Log.e("MovieRepository", "getMovies failed, using local data \n Detail error:\n $e")
+ }
+ emit(local.getMoviesLocal())
+ }
+
+ override suspend fun getDetailMovies(movieId: Int) = flow {
+ try {
+ emit(local.getMovieDetailLocal(movieId))
+ } catch (e: IOException) {
+ Log.e("MovieRepository", "getDetailMovies failed, retry with network \n Detail error:\n $e")
+ val movie = remote.getMovieDetail(movieId = movieId)
+ local.updateMovies(arrayListOf(movie))
+ emit(local.getMovieDetailLocal(movieId))
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/data/repository/TokenRepositoryImpl.kt b/app/src/main/java/com/sun/android/data/repository/TokenRepositoryImpl.kt
new file mode 100644
index 0000000..68ccf05
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/repository/TokenRepositoryImpl.kt
@@ -0,0 +1,14 @@
+package com.sun.android.data.repository
+
+import com.sun.android.data.TokenRepository
+import com.sun.android.data.source.TokenDataSource
+
+class TokenRepositoryImpl(private val local: TokenDataSource.Local) :
+ TokenRepository {
+
+ override fun getToken() = local.getToken()
+
+ override fun saveToken(token: String) = local.saveToken(token)
+
+ override fun clearToken() = local.clearToken()
+}
diff --git a/app/src/main/java/com/sun/android/data/source/MovieDataSource.kt b/app/src/main/java/com/sun/android/data/source/MovieDataSource.kt
new file mode 100644
index 0000000..d210543
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/MovieDataSource.kt
@@ -0,0 +1,24 @@
+package com.sun.android.data.source
+
+import com.sun.android.data.model.Movie
+import com.sun.android.data.source.remote.api.response.BaseResponse
+
+interface MovieDataSource {
+ /**
+ * Local
+ */
+ interface Local {
+ suspend fun getMoviesLocal(): List
+ suspend fun updateMovies(movies: List)
+ suspend fun getMovieDetailLocal(movieId: Int): Movie
+ }
+
+ /**
+ * Remote
+ */
+ interface Remote {
+ suspend fun getMovies(): BaseResponse>
+
+ suspend fun getMovieDetail(movieId: Int): Movie
+ }
+}
diff --git a/app/src/main/java/com/sun/android/data/source/TokenDataSource.kt b/app/src/main/java/com/sun/android/data/source/TokenDataSource.kt
new file mode 100644
index 0000000..99a5a86
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/TokenDataSource.kt
@@ -0,0 +1,13 @@
+package com.sun.android.data.source
+
+interface TokenDataSource {
+ interface Local {
+ fun getToken(): String?
+
+ fun saveToken(token: String)
+
+ fun clearToken()
+ }
+
+ interface Remote
+}
diff --git a/app/src/main/java/com/sun/android/data/source/local/MovieLocalImpl.kt b/app/src/main/java/com/sun/android/data/source/local/MovieLocalImpl.kt
new file mode 100644
index 0000000..18cebbe
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/local/MovieLocalImpl.kt
@@ -0,0 +1,19 @@
+package com.sun.android.data.source.local
+
+import com.sun.android.data.model.Movie
+import com.sun.android.data.source.MovieDataSource
+import com.sun.android.data.source.local.room.MovieDao
+
+class MovieLocalImpl(private val movieDao: MovieDao) : MovieDataSource.Local {
+ override suspend fun getMoviesLocal(): List {
+ return movieDao.getAllMovies()
+ }
+
+ override suspend fun updateMovies(movies: List) {
+ return movieDao.insert(movies)
+ }
+
+ override suspend fun getMovieDetailLocal(movieId: Int): Movie {
+ return movieDao.getMovie(movieId)
+ }
+}
diff --git a/app/src/main/java/com/sun/android/data/source/local/TokenLocalImpl.kt b/app/src/main/java/com/sun/android/data/source/local/TokenLocalImpl.kt
new file mode 100644
index 0000000..ab56822
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/local/TokenLocalImpl.kt
@@ -0,0 +1,17 @@
+package com.sun.android.data.source.local
+
+import com.sun.android.data.source.TokenDataSource
+import com.sun.android.data.source.local.api.SharedPrefsApi
+import com.sun.android.data.source.local.api.sharedpref.SharedPrefsKey
+
+class TokenLocalImpl(private val sharedPrefApi: SharedPrefsApi) :
+ TokenDataSource.Local {
+
+ override fun getToken() =
+ sharedPrefApi.get(SharedPrefsKey.KEY_TOKEN, String::class.java)
+
+ override fun saveToken(token: String) =
+ sharedPrefApi.put(SharedPrefsKey.KEY_TOKEN, token)
+
+ override fun clearToken() = sharedPrefApi.removeKey(SharedPrefsKey.KEY_TOKEN)
+}
diff --git a/app/src/main/java/com/sun/android/data/source/local/api/SharedPrefsApi.kt b/app/src/main/java/com/sun/android/data/source/local/api/SharedPrefsApi.kt
new file mode 100644
index 0000000..fee46f1
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/local/api/SharedPrefsApi.kt
@@ -0,0 +1,20 @@
+package com.sun.android.data.source.local.api
+
+import android.content.SharedPreferences
+
+interface SharedPrefsApi {
+ fun put(key: String, data: T)
+ fun get(key: String, type: Class, default: T? = null): T?
+ fun putList(key: String, list: List)
+ fun getList(key: String, clazz: Class): List?
+ fun removeKey(key: String)
+ fun clear()
+
+ fun registerOnSharedPreferenceChangeListener(
+ listener: SharedPreferences.OnSharedPreferenceChangeListener
+ )
+
+ fun unregisterOnSharedPreferenceChangeListener(
+ listener: SharedPreferences.OnSharedPreferenceChangeListener
+ )
+}
diff --git a/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefsImpl.kt b/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefsImpl.kt
new file mode 100644
index 0000000..51f0ec2
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefsImpl.kt
@@ -0,0 +1,78 @@
+package com.sun.android.data.source.local.api.sharedpref
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import com.sun.android.data.source.local.api.SharedPrefsApi
+
+class SharedPrefsImpl(context: Context, private val gson: Gson) : SharedPrefsApi {
+
+ private val sharedPreferences by lazy {
+ context.getSharedPreferences(SharedPrefsKey.PREFS_NAME, Context.MODE_PRIVATE)
+ }
+
+ override fun registerOnSharedPreferenceChangeListener(
+ listener: SharedPreferences.OnSharedPreferenceChangeListener
+ ) {
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ }
+
+ override fun unregisterOnSharedPreferenceChangeListener(
+ listener: SharedPreferences.OnSharedPreferenceChangeListener
+ ) {
+ sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
+ }
+
+ override fun put(key: String, data: T) {
+ val editor = sharedPreferences.edit()
+ when (data) {
+ is String -> editor.putString(key, data)
+ is Boolean -> editor.putBoolean(key, data)
+ is Float -> editor.putFloat(key, data)
+ is Int -> editor.putInt(key, data)
+ is Long -> editor.putLong(key, data)
+ else -> editor.putString(key, Gson().toJson(data))
+ }
+ editor.apply()
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ override fun get(key: String, type: Class, default: T?): T? = when (type) {
+ String::class.java -> sharedPreferences.getString(key, default as? String) as? T
+ Boolean::class.java -> java.lang.Boolean.valueOf(
+ sharedPreferences.getBoolean(key, default as? Boolean ?: false)
+ ) as? T
+
+ Float::class.java -> java.lang.Float.valueOf(
+ sharedPreferences.getFloat(key, default as? Float ?: 0f)
+ ) as? T
+
+ Int::class.java -> Integer.valueOf(
+ sharedPreferences.getInt(key, default as? Int ?: 0)
+ ) as? T
+
+ Long::class.java -> java.lang.Long.valueOf(
+ sharedPreferences.getLong(key, default as? Long ?: 0L)
+ ) as? T
+
+ else -> gson.fromJson(sharedPreferences.getString(key, default as? String), type)
+ }
+
+ override fun putList(key: String, list: List) {
+ sharedPreferences.edit().putString(key, gson.toJson(list)).apply()
+ }
+
+ override fun getList(key: String, clazz: Class): List? {
+ val typeOfT = TypeToken.getParameterized(List::class.java, clazz).type
+ return gson.fromJson>(get(key, String::class.java), typeOfT)
+ }
+
+ override fun removeKey(key: String) {
+ sharedPreferences.edit().remove(key).apply()
+ }
+
+ override fun clear() {
+ sharedPreferences.edit().clear().apply()
+ }
+}
diff --git a/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefsKey.kt b/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefsKey.kt
new file mode 100644
index 0000000..bab3262
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefsKey.kt
@@ -0,0 +1,6 @@
+package com.sun.android.data.source.local.api.sharedpref
+
+object SharedPrefsKey {
+ const val PREFS_NAME = "WSMCompanySharedPreference"
+ const val KEY_TOKEN = "KEY_TOKEN"
+}
diff --git a/app/src/main/java/com/sun/android/data/source/local/room/AppDatabase.kt b/app/src/main/java/com/sun/android/data/source/local/room/AppDatabase.kt
new file mode 100644
index 0000000..f0764fe
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/local/room/AppDatabase.kt
@@ -0,0 +1,28 @@
+package com.sun.android.data.source.local.room
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import com.sun.android.data.model.Movie
+import com.sun.android.utils.Constant
+
+@Database(entities = [Movie::class], version = 1, exportSchema = false)
+abstract class AppDatabase : RoomDatabase() {
+
+ abstract fun movieDao(): MovieDao
+
+ companion object {
+ @Volatile
+ private var instance: AppDatabase? = null
+
+ fun getDatabase(context: Context): AppDatabase {
+ // if the Instance is not null, return it, otherwise create a new database instance.
+ return instance ?: synchronized(this) {
+ Room.databaseBuilder(context, AppDatabase::class.java, Constant.DATABASE_NAME).build().also {
+ instance = it
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/data/source/local/room/MovieDao.kt b/app/src/main/java/com/sun/android/data/source/local/room/MovieDao.kt
new file mode 100644
index 0000000..592fba2
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/local/room/MovieDao.kt
@@ -0,0 +1,27 @@
+package com.sun.android.data.source.local.room
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import com.sun.android.data.model.Movie
+
+@Dao
+interface MovieDao {
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(movies: List)
+
+ @Update
+ suspend fun update(movie: Movie)
+
+ @Delete
+ suspend fun delete(movie: Movie)
+
+ @Query("SELECT * from movies WHERE id = :id")
+ suspend fun getMovie(id: Int): Movie
+
+ @Query("SELECT * from movies")
+ suspend fun getAllMovies(): List
+}
diff --git a/app/src/main/java/com/sun/android/data/source/remote/MovieRemoteImpl.kt b/app/src/main/java/com/sun/android/data/source/remote/MovieRemoteImpl.kt
new file mode 100644
index 0000000..03c5555
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/remote/MovieRemoteImpl.kt
@@ -0,0 +1,17 @@
+package com.sun.android.data.source.remote
+
+import com.sun.android.BuildConfig
+import com.sun.android.data.model.Movie
+import com.sun.android.data.source.MovieDataSource
+import com.sun.android.data.source.remote.api.ApiService
+import com.sun.android.data.source.remote.api.response.BaseResponse
+
+class MovieRemoteImpl(private val apiService: ApiService) : MovieDataSource.Remote {
+ override suspend fun getMovies(): BaseResponse> {
+ return apiService.getTopRateMovies(apiKey = BuildConfig.API_KEY)
+ }
+
+ override suspend fun getMovieDetail(movieId: Int): Movie {
+ return apiService.getMovieDetails(movieId = movieId, apiKey = BuildConfig.API_KEY)
+ }
+}
diff --git a/app/src/main/java/com/sun/android/data/source/remote/api/ApiService.kt b/app/src/main/java/com/sun/android/data/source/remote/api/ApiService.kt
new file mode 100644
index 0000000..60d6c99
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/remote/api/ApiService.kt
@@ -0,0 +1,21 @@
+package com.sun.android.data.source.remote.api
+
+import com.sun.android.data.model.Movie
+import com.sun.android.data.source.remote.api.response.BaseResponse
+import retrofit2.http.GET
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface ApiService {
+ @GET("movie/top_rated?")
+ suspend fun getTopRateMovies(
+ @Query("api_key") apiKey: String?,
+ @Query("page") page: Int = 1
+ ): BaseResponse>
+
+ @GET("movie/{movieId}")
+ suspend fun getMovieDetails(
+ @Path("movieId") movieId: Int,
+ @Query("api_key") apiKey: String?
+ ): Movie
+}
diff --git a/app/src/main/java/com/sun/android/data/source/remote/api/error/ErrorResponse.kt b/app/src/main/java/com/sun/android/data/source/remote/api/error/ErrorResponse.kt
new file mode 100644
index 0000000..555dd8d
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/remote/api/error/ErrorResponse.kt
@@ -0,0 +1,69 @@
+package com.sun.android.data.source.remote.api.error
+
+import com.google.gson.Gson
+import com.google.gson.JsonSyntaxException
+import com.google.gson.annotations.Expose
+import com.google.gson.annotations.SerializedName
+import com.sun.android.utils.LogUtils
+import com.sun.android.utils.extension.notNull
+import java.io.IOException
+import java.text.ParseException
+import retrofit2.HttpException
+
+data class ErrorResponse(
+ @SerializedName("status")
+ @Expose
+ val status: Int,
+ @SerializedName("messages")
+ @Expose
+ val messages: String?
+) {
+ companion object {
+ private const val TAG = "ErrorResponse"
+
+ fun convertToRetrofitException(throwable: Throwable): RetrofitException {
+ if (throwable is RetrofitException) {
+ return throwable
+ }
+
+ // A network error happened
+ if (throwable is IOException) {
+ return RetrofitException.toNetworkError(throwable)
+ }
+
+ // We had non-200 http error
+ if (throwable is HttpException) {
+ val response = throwable.response() ?: return RetrofitException.toUnexpectedError(
+ throwable
+ )
+
+ response.errorBody().notNull {
+ return try {
+ val errorResponse =
+ Gson().fromJson(it.string(), ErrorResponse::class.java)
+
+ if (errorResponse != null && !errorResponse.messages.isNullOrBlank()) {
+ RetrofitException.toServerError(errorResponse)
+ } else {
+ RetrofitException.toHttpError(response)
+ }
+ } catch (e: IOException) {
+ LogUtils.e(TAG, e.message.toString())
+ RetrofitException.toUnexpectedError(throwable)
+ } catch (e: ParseException) {
+ LogUtils.e(TAG, e.message.toString())
+ RetrofitException.toUnexpectedError(throwable)
+ } catch (e: JsonSyntaxException) {
+ LogUtils.e(TAG, e.message.toString())
+ RetrofitException.toUnexpectedError(throwable)
+ }
+ }
+
+ return RetrofitException.toHttpError(response)
+ }
+
+ // We don't know what happened. We need to simply convert to an unknown error
+ return RetrofitException.toUnexpectedError(throwable)
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/data/source/remote/api/error/RetrofitException.kt b/app/src/main/java/com/sun/android/data/source/remote/api/error/RetrofitException.kt
new file mode 100644
index 0000000..b5455ca
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/remote/api/error/RetrofitException.kt
@@ -0,0 +1,101 @@
+package com.sun.android.data.source.remote.api.error
+
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+import retrofit2.Response
+
+class RetrofitException : RuntimeException {
+
+ private val errorType: String
+ private lateinit var responses: Response<*>
+ private var errorResponse: ErrorResponse? = null
+
+ private constructor(type: String, cause: Throwable) : super(cause.message, cause) {
+ errorType = type
+ }
+
+ private constructor(type: String, response: Response<*>) {
+ errorType = type
+ responses = response
+ }
+
+ constructor(type: String, response: ErrorResponse?) {
+ errorType = type
+ errorResponse = response
+ }
+
+ fun getErrorResponse() = errorResponse
+
+ fun getMessageError(): String? {
+ return when (errorType) {
+ Type.SERVER -> {
+ errorResponse?.messages
+ }
+
+ Type.NETWORK -> {
+ getNetworkErrorMessage(cause)
+ }
+
+ Type.HTTP -> {
+ responses.code().getHttpErrorMessage()
+ }
+
+ else -> null
+ }
+ }
+
+ private fun getNetworkErrorMessage(throwable: Throwable?): String {
+ if (throwable is SocketTimeoutException) {
+ return throwable.message.toString()
+ }
+
+ if (throwable is UnknownHostException) {
+ return throwable.message.toString()
+ }
+
+ if (throwable is IOException) {
+ return throwable.message.toString()
+ }
+
+ return throwable?.message.toString()
+ }
+
+ private fun Int.getHttpErrorMessage(): String {
+ if (this in HttpURLConnection.HTTP_MULT_CHOICE..HttpURLConnection.HTTP_USE_PROXY) {
+ // Redirection
+ return "It was transferred to a different URL. I'm sorry for causing you trouble"
+ }
+ if (this in HttpURLConnection.HTTP_BAD_REQUEST..HttpURLConnection.HTTP_UNSUPPORTED_TYPE) {
+ // Client error
+ return "An error occurred on the application side. Please try again later!"
+ }
+ if (this in HttpURLConnection.HTTP_INTERNAL_ERROR..HttpURLConnection.HTTP_VERSION) {
+ // Server error
+ return "A server error occurred. Please try again later!"
+ }
+
+ // Unofficial error
+ return "An error occurred. Please try again later!"
+ }
+
+ companion object {
+
+ fun toNetworkError(cause: Throwable): RetrofitException {
+ return RetrofitException(Type.NETWORK, cause)
+ }
+
+ fun toHttpError(response: Response<*>): RetrofitException {
+ return RetrofitException(Type.HTTP, response)
+ }
+
+ fun toUnexpectedError(cause: Throwable): RetrofitException {
+ return RetrofitException(Type.UNEXPECTED, cause)
+ }
+
+ fun toServerError(response: ErrorResponse): RetrofitException {
+ return RetrofitException(Type.SERVER, response)
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/data/source/remote/api/error/Type.kt b/app/src/main/java/com/sun/android/data/source/remote/api/error/Type.kt
new file mode 100644
index 0000000..660f377
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/remote/api/error/Type.kt
@@ -0,0 +1,29 @@
+package com.sun.android.data.source.remote.api.error
+
+import java.io.IOException
+
+/**
+ * Error type
+ */
+object Type {
+ /**
+ * An [IOException] occurred while communicating to the server.
+ */
+ const val NETWORK = "NETWORK"
+
+ /**
+ * A non-2xx HTTP netWorkState code was received from the server.
+ */
+ const val HTTP = "HTTP"
+
+ /**
+ * A error server withScheduler code & message
+ */
+ const val SERVER = "SERVER"
+
+ /**
+ * An internal error occurred while attempting to execute a request. It is best practice to
+ * re-throw this exception so your application crashes.
+ */
+ const val UNEXPECTED = "UNEXPECTED"
+}
diff --git a/app/src/main/java/com/sun/android/data/source/remote/api/middleware/BooleanAdapter.kt b/app/src/main/java/com/sun/android/data/source/remote/api/middleware/BooleanAdapter.kt
new file mode 100644
index 0000000..e916390
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/remote/api/middleware/BooleanAdapter.kt
@@ -0,0 +1,32 @@
+package com.sun.android.data.source.remote.api.middleware
+
+import com.google.gson.TypeAdapter
+import com.google.gson.stream.JsonReader
+import com.google.gson.stream.JsonToken
+import com.google.gson.stream.JsonWriter
+import java.io.IOException
+
+class BooleanAdapter : TypeAdapter() {
+ @Throws(IOException::class)
+ override fun write(out: JsonWriter, value: Boolean?) {
+ value?.let {
+ out.nullValue()
+ return
+ }
+ out.value(value)
+ }
+
+ @Throws(IOException::class)
+ override fun read(`in`: JsonReader): Boolean? {
+ return when (`in`.peek()) {
+ JsonToken.NULL -> {
+ `in`.nextNull()
+ null
+ }
+ JsonToken.BOOLEAN -> `in`.nextBoolean()
+ JsonToken.NUMBER -> `in`.nextInt() != 0
+ JsonToken.STRING -> java.lang.Boolean.valueOf(`in`.nextString())
+ else -> null
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/data/source/remote/api/middleware/DoubleAdapter.kt b/app/src/main/java/com/sun/android/data/source/remote/api/middleware/DoubleAdapter.kt
new file mode 100644
index 0000000..fb2fbf5
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/remote/api/middleware/DoubleAdapter.kt
@@ -0,0 +1,38 @@
+package com.sun.android.data.source.remote.api.middleware
+
+import com.google.gson.TypeAdapter
+import com.google.gson.stream.JsonReader
+import com.google.gson.stream.JsonToken
+import com.google.gson.stream.JsonWriter
+import java.io.IOException
+
+class DoubleAdapter : TypeAdapter() {
+
+ @Throws(IOException::class)
+ override fun write(out: JsonWriter, value: Double?) {
+ value?.let {
+ out.nullValue()
+ return
+ }
+ out.value(value)
+ }
+
+ @Throws(IOException::class)
+ override fun read(`in`: JsonReader): Double? {
+ return when (`in`.peek()) {
+ JsonToken.NULL -> {
+ `in`.nextNull()
+ null
+ }
+ JsonToken.NUMBER -> `in`.nextDouble()
+ JsonToken.STRING -> {
+ try {
+ java.lang.Double.valueOf(`in`.nextString())
+ } catch (e: NumberFormatException) {
+ null
+ }
+ }
+ else -> null
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/data/source/remote/api/middleware/IntegerAdapter.kt b/app/src/main/java/com/sun/android/data/source/remote/api/middleware/IntegerAdapter.kt
new file mode 100644
index 0000000..65e2332
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/remote/api/middleware/IntegerAdapter.kt
@@ -0,0 +1,38 @@
+package com.sun.android.data.source.remote.api.middleware
+
+import com.google.gson.TypeAdapter
+import com.google.gson.stream.JsonReader
+import com.google.gson.stream.JsonToken
+import com.google.gson.stream.JsonWriter
+import java.io.IOException
+
+class IntegerAdapter : TypeAdapter() {
+ @Throws(IOException::class)
+ override fun write(out: JsonWriter, value: Int?) {
+ value?.let {
+ out.nullValue()
+ return
+ }
+ out.value(value)
+ }
+
+ @Throws(IOException::class)
+ override fun read(`in`: JsonReader): Int? {
+ when (`in`.peek()) {
+ JsonToken.NULL -> {
+ `in`.nextNull()
+ return null
+ }
+ JsonToken.NUMBER -> return `in`.nextInt()
+ JsonToken.BOOLEAN -> return if (`in`.nextBoolean()) 1 else 0
+ JsonToken.STRING -> {
+ return try {
+ Integer.valueOf(`in`.nextString())
+ } catch (e: NumberFormatException) {
+ null
+ }
+ }
+ else -> return null
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/data/source/remote/api/middleware/InterceptorImpl.kt b/app/src/main/java/com/sun/android/data/source/remote/api/middleware/InterceptorImpl.kt
new file mode 100644
index 0000000..d287dd4
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/remote/api/middleware/InterceptorImpl.kt
@@ -0,0 +1,62 @@
+package com.sun.android.data.source.remote.api.middleware
+
+import androidx.annotation.NonNull
+import com.sun.android.data.TokenRepository
+import java.io.IOException
+import java.net.HttpURLConnection
+import okhttp3.Interceptor
+import okhttp3.Request
+import okhttp3.Response
+
+class InterceptorImpl(
+ private var tokenRepository: TokenRepository
+) : Interceptor {
+
+ private var isRefreshToken = false
+
+ @Throws(IOException::class)
+ override fun intercept(@NonNull chain: Interceptor.Chain): Response {
+ // TODO check connection
+
+ val builder = initializeHeader(chain)
+ val request = builder.build()
+ var response = chain.proceed(request)
+
+ if (!isRefreshToken && response.code == HttpURLConnection.HTTP_UNAUTHORIZED) {
+ tokenRepository.getToken()?.let { token ->
+ val newRequest = initNewRequest(request, token)
+ response.close()
+ response = chain.proceed(newRequest)
+ }
+ }
+ return response
+ }
+
+ private fun initNewRequest(request: Request, token: String?): Request {
+ val builder = request.newBuilder().removeHeader(HEADER_AUTH_TOKEN)
+ token?.let {
+ builder.header(HEADER_AUTH_TOKEN, it)
+ }
+ return builder.build()
+ }
+
+ private fun initializeHeader(chain: Interceptor.Chain): Request.Builder {
+ val originRequest = chain.request()
+ val builder = originRequest.newBuilder()
+ .header(HEADER_ACCEPT, "application/json")
+ .addHeader(HEADER_CACHE_CONTROL, "no-cache")
+ .addHeader(HEADER_CACHE_CONTROL, "no-store")
+ .method(originRequest.method, originRequest.body)
+
+ tokenRepository.getToken()?.let {
+ builder.addHeader(HEADER_AUTH_TOKEN, it)
+ }
+ return builder
+ }
+
+ companion object {
+ private const val HEADER_AUTH_TOKEN = "AUTH-TOKEN"
+ private const val HEADER_ACCEPT = "Accept"
+ private const val HEADER_CACHE_CONTROL = "Cache-Control"
+ }
+}
diff --git a/app/src/main/java/com/sun/android/data/source/remote/api/response/BaseResponse.kt b/app/src/main/java/com/sun/android/data/source/remote/api/response/BaseResponse.kt
new file mode 100644
index 0000000..3017d14
--- /dev/null
+++ b/app/src/main/java/com/sun/android/data/source/remote/api/response/BaseResponse.kt
@@ -0,0 +1,19 @@
+package com.sun.android.data.source.remote.api.response
+
+import com.google.gson.annotations.Expose
+import com.google.gson.annotations.SerializedName
+
+data class BaseResponse(
+ @SerializedName("status")
+ @Expose
+ val status: Int,
+ @SerializedName("messages")
+ @Expose
+ val message: String,
+ @SerializedName("results")
+ @Expose
+ var data: T,
+ @SerializedName("page")
+ @Expose
+ var page: Int
+)
diff --git a/app/src/main/java/com/sun/android/di/AppModule.kt b/app/src/main/java/com/sun/android/di/AppModule.kt
new file mode 100644
index 0000000..f906de8
--- /dev/null
+++ b/app/src/main/java/com/sun/android/di/AppModule.kt
@@ -0,0 +1,66 @@
+package com.sun.android.di
+
+import android.app.Application
+import android.content.res.Resources
+import com.google.gson.FieldNamingPolicy
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import com.sun.android.data.source.local.api.SharedPrefsApi
+import com.sun.android.data.source.local.api.sharedpref.SharedPrefsImpl
+import com.sun.android.data.source.local.room.AppDatabase
+import com.sun.android.data.source.local.room.MovieDao
+import com.sun.android.data.source.remote.api.middleware.BooleanAdapter
+import com.sun.android.data.source.remote.api.middleware.DoubleAdapter
+import com.sun.android.data.source.remote.api.middleware.IntegerAdapter
+import com.sun.android.utils.DateTimeUtils
+import com.sun.android.utils.dispatchers.BaseDispatcherProvider
+import com.sun.android.utils.dispatchers.DispatcherProvider
+import org.koin.dsl.module
+
+val AppModule = module {
+ single { provideResources(get()) }
+
+ single { provideSharedPrefsApi(get(), get()) }
+
+ single { provideBaseDispatcherProvider() }
+
+ single { provideGson() }
+
+ single { provideDatabase(get()) }
+
+ single { provideMovieDao(get()) }
+}
+
+fun provideResources(app: Application): Resources {
+ return app.resources
+}
+
+fun provideSharedPrefsApi(app: Application, gson: Gson): SharedPrefsApi {
+ return SharedPrefsImpl(app, gson)
+}
+
+fun provideBaseDispatcherProvider(): BaseDispatcherProvider {
+ return DispatcherProvider()
+}
+
+fun provideGson(): Gson {
+ val booleanAdapter = BooleanAdapter()
+ val integerAdapter = IntegerAdapter()
+ val doubleAdapter = DoubleAdapter()
+ return GsonBuilder()
+ .registerTypeAdapter(Boolean::class.java, booleanAdapter)
+ .registerTypeAdapter(Int::class.java, integerAdapter)
+ .registerTypeAdapter(Double::class.java, doubleAdapter)
+ .setDateFormat(DateTimeUtils.DATE_TIME_FORMAT_UTC)
+ .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .excludeFieldsWithoutExposeAnnotation()
+ .create()
+}
+
+fun provideDatabase(app: Application): AppDatabase {
+ return AppDatabase.getDatabase(app.applicationContext)
+}
+
+fun provideMovieDao(database: AppDatabase): MovieDao {
+ return database.movieDao()
+}
diff --git a/app/src/main/java/com/sun/android/di/DataSourceModule.kt b/app/src/main/java/com/sun/android/di/DataSourceModule.kt
new file mode 100644
index 0000000..7111e3c
--- /dev/null
+++ b/app/src/main/java/com/sun/android/di/DataSourceModule.kt
@@ -0,0 +1,16 @@
+package com.sun.android.di
+
+import com.sun.android.data.source.MovieDataSource
+import com.sun.android.data.source.TokenDataSource
+import com.sun.android.data.source.local.MovieLocalImpl
+import com.sun.android.data.source.local.TokenLocalImpl
+import com.sun.android.data.source.remote.MovieRemoteImpl
+import org.koin.dsl.module
+
+val DataSourceModule = module {
+ single { TokenLocalImpl(get()) }
+
+ single { MovieRemoteImpl(get()) }
+
+ single { MovieLocalImpl(get()) }
+}
diff --git a/app/src/main/java/com/sun/android/di/NetWorkModule.kt b/app/src/main/java/com/sun/android/di/NetWorkModule.kt
new file mode 100644
index 0000000..21550c7
--- /dev/null
+++ b/app/src/main/java/com/sun/android/di/NetWorkModule.kt
@@ -0,0 +1,81 @@
+package com.sun.android.di
+
+import android.app.Application
+import com.google.gson.Gson
+import com.sun.android.BuildConfig
+import com.sun.android.data.TokenRepository
+import com.sun.android.data.source.remote.api.ApiService
+import com.sun.android.data.source.remote.api.middleware.InterceptorImpl
+import java.util.concurrent.TimeUnit
+import okhttp3.Cache
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import org.koin.dsl.module
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+
+val NetworkModule = module {
+ single { provideOkHttpCache(get()) }
+
+ single { provideOkHttpClient(get(), get()) }
+
+ single { provideInterceptor(get()) }
+
+ single { provideRetrofit(get(), get()) }
+
+ single { provideApiService(get()) }
+}
+
+fun provideOkHttpCache(app: Application): Cache {
+ val cacheSize: Long = NetWorkInstant.CACHE_SIZE
+ return Cache(app.cacheDir, cacheSize)
+}
+
+fun provideInterceptor(tokenRepository: TokenRepository): Interceptor {
+ return InterceptorImpl(tokenRepository)
+}
+
+fun provideOkHttpClient(cache: Cache, interceptor: Interceptor): OkHttpClient {
+ val httpClientBuilder = OkHttpClient.Builder()
+ httpClientBuilder.cache(cache)
+ httpClientBuilder.addInterceptor(interceptor)
+
+ httpClientBuilder.readTimeout(
+ NetWorkInstant.READ_TIMEOUT,
+ TimeUnit.SECONDS
+ )
+ httpClientBuilder.writeTimeout(
+ NetWorkInstant.WRITE_TIMEOUT,
+ TimeUnit.SECONDS
+ )
+ httpClientBuilder.connectTimeout(
+ NetWorkInstant.CONNECT_TIMEOUT,
+ TimeUnit.SECONDS
+ )
+
+ if (BuildConfig.DEBUG) {
+ val logging = HttpLoggingInterceptor()
+ httpClientBuilder.addInterceptor(logging)
+ logging.level = HttpLoggingInterceptor.Level.BODY
+ }
+
+ return httpClientBuilder.build()
+}
+
+fun provideRetrofit(gson: Gson, okHttpClient: OkHttpClient): Retrofit {
+ return Retrofit.Builder().baseUrl(BuildConfig.BASE_URL).addConverterFactory(GsonConverterFactory.create(gson))
+ .client(okHttpClient).build()
+}
+
+fun provideApiService(retrofit: Retrofit): ApiService {
+ return retrofit.create(ApiService::class.java)
+}
+
+object NetWorkInstant {
+ internal const val READ_TIMEOUT = 60L
+ internal const val WRITE_TIMEOUT = 30L
+ internal const val CONNECT_TIMEOUT = 60L
+
+ internal const val CACHE_SIZE = 10 * 1024 * 1024L // 10MB
+}
diff --git a/app/src/main/java/com/sun/android/di/RepositoryModule.kt b/app/src/main/java/com/sun/android/di/RepositoryModule.kt
new file mode 100644
index 0000000..c3ed551
--- /dev/null
+++ b/app/src/main/java/com/sun/android/di/RepositoryModule.kt
@@ -0,0 +1,23 @@
+package com.sun.android.di
+
+import com.sun.android.data.MovieRepository
+import com.sun.android.data.TokenRepository
+import com.sun.android.data.repository.MovieRepositoryImpl
+import com.sun.android.data.repository.TokenRepositoryImpl
+import com.sun.android.data.source.MovieDataSource
+import com.sun.android.data.source.TokenDataSource
+import org.koin.dsl.module
+
+val RepositoryModule = module {
+ single { provideTokenRepository(get()) }
+
+ single { provideMovieRepository(get(), get()) }
+}
+
+fun provideTokenRepository(local: TokenDataSource.Local): TokenRepository {
+ return TokenRepositoryImpl(local)
+}
+
+fun provideMovieRepository(remote: MovieDataSource.Remote, local: MovieDataSource.Local): MovieRepository {
+ return MovieRepositoryImpl(remote, local)
+}
diff --git a/app/src/main/java/com/sun/android/di/ViewModelModule.kt b/app/src/main/java/com/sun/android/di/ViewModelModule.kt
new file mode 100644
index 0000000..0063f11
--- /dev/null
+++ b/app/src/main/java/com/sun/android/di/ViewModelModule.kt
@@ -0,0 +1,14 @@
+package com.sun.android.di
+
+import com.sun.android.ui.MainViewModel
+import com.sun.android.ui.detail.MovieDetailViewModel
+import com.sun.android.ui.listmovie.MoviesViewModel
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.core.module.Module
+import org.koin.dsl.module
+
+val ViewModelModule: Module = module {
+ viewModel { MainViewModel() }
+ viewModel { MoviesViewModel(get()) }
+ viewModel { MovieDetailViewModel(get()) }
+}
diff --git a/app/src/main/java/com/sun/android/ui/MainActivity.kt b/app/src/main/java/com/sun/android/ui/MainActivity.kt
new file mode 100644
index 0000000..5fa6e23
--- /dev/null
+++ b/app/src/main/java/com/sun/android/ui/MainActivity.kt
@@ -0,0 +1,20 @@
+package com.sun.android.ui
+
+import com.sun.android.R
+import com.sun.android.base.BaseActivity
+import com.sun.android.databinding.ActivityMainBinding
+import com.sun.android.ui.listmovie.MoviesFragment
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+class MainActivity : BaseActivity(ActivityMainBinding::inflate) {
+
+ override val viewModel: MainViewModel by viewModel()
+
+ override fun initialize() {
+ supportFragmentManager
+ .beginTransaction()
+ .addToBackStack(MoviesFragment::javaClass.name)
+ .replace(R.id.layoutContainer, MoviesFragment())
+ .commit()
+ }
+}
diff --git a/app/src/main/java/com/sun/android/ui/MainViewModel.kt b/app/src/main/java/com/sun/android/ui/MainViewModel.kt
new file mode 100644
index 0000000..291c485
--- /dev/null
+++ b/app/src/main/java/com/sun/android/ui/MainViewModel.kt
@@ -0,0 +1,5 @@
+package com.sun.android.ui
+
+import androidx.lifecycle.ViewModel
+
+class MainViewModel : ViewModel()
diff --git a/app/src/main/java/com/sun/android/ui/detail/DetailFragment.kt b/app/src/main/java/com/sun/android/ui/detail/DetailFragment.kt
new file mode 100644
index 0000000..ec43d4e
--- /dev/null
+++ b/app/src/main/java/com/sun/android/ui/detail/DetailFragment.kt
@@ -0,0 +1,45 @@
+package com.sun.android.ui.detail
+
+import androidx.core.os.bundleOf
+import androidx.lifecycle.Observer
+import com.sun.android.base.BaseFragment
+import com.sun.android.databinding.FragmentDetailBinding
+import com.sun.android.utils.extension.goBackFragment
+import com.sun.android.utils.extension.loadImageCircleWithUrl
+import com.sun.android.utils.extension.loadImageWithUrl
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+class DetailFragment : BaseFragment(FragmentDetailBinding::inflate) {
+
+ override val viewModel: MovieDetailViewModel by viewModel()
+
+ override fun initView() {
+ binding.buttonImageBack.setOnClickListener { goBackFragment() }
+ }
+
+ override fun initData() {
+ arguments?.run {
+ val mMovieId = getInt(ARGUMENT_MOVIE_ID, -1)
+ viewModel.requestMovieDetails(mMovieId)
+ }
+ }
+
+ override fun bindData() {
+ viewModel.movie.observe(viewLifecycleOwner, Observer {
+ binding.imageBackDrop.loadImageWithUrl(it.backDropImage)
+ binding.imageMovie.loadImageCircleWithUrl(it.urlImage)
+ binding.textTitle.text = it.title
+ binding.textDescription.text = it.overView
+ binding.textRatting.text = it.vote.toString()
+ binding.textTotalReview.text = it.voteCount.toString()
+ })
+ }
+
+ companion object {
+ private const val ARGUMENT_MOVIE_ID = "ARGUMENT_MOVIE_ID"
+
+ fun newInstance(movieId: Int) = DetailFragment().apply {
+ arguments = bundleOf(ARGUMENT_MOVIE_ID to movieId)
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/ui/detail/MovieDetailViewModel.kt b/app/src/main/java/com/sun/android/ui/detail/MovieDetailViewModel.kt
new file mode 100644
index 0000000..322769f
--- /dev/null
+++ b/app/src/main/java/com/sun/android/ui/detail/MovieDetailViewModel.kt
@@ -0,0 +1,22 @@
+package com.sun.android.ui.detail
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.sun.android.data.MovieRepository
+import com.sun.android.data.model.Movie
+import com.sun.android.utils.LogUtils
+import com.sun.android.utils.livedata.SingleLiveData
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.launch
+
+class MovieDetailViewModel(private val movieRepository: MovieRepository) : ViewModel() {
+ val movie = SingleLiveData()
+
+ fun requestMovieDetails(movieId: Int) {
+ viewModelScope.launch {
+ movieRepository.getDetailMovies(movieId).catch {
+ LogUtils.e("QQQQQ", it.toString())
+ }.collect { movie.value = it }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/ui/listmovie/MoviesFragment.kt b/app/src/main/java/com/sun/android/ui/listmovie/MoviesFragment.kt
new file mode 100644
index 0000000..23cc54f
--- /dev/null
+++ b/app/src/main/java/com/sun/android/ui/listmovie/MoviesFragment.kt
@@ -0,0 +1,47 @@
+package com.sun.android.ui.listmovie
+
+import androidx.lifecycle.Observer
+import com.sun.android.R
+import com.sun.android.base.BaseFragment
+import com.sun.android.data.model.Movie
+import com.sun.android.databinding.MoviesFragmentBinding
+import com.sun.android.ui.detail.DetailFragment
+import com.sun.android.ui.listmovie.adapter.MoviesAdapter
+import com.sun.android.utils.extension.addFragment
+import com.sun.android.utils.extension.notNull
+import com.sun.android.utils.recycler.OnItemRecyclerViewClickListener
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+class MoviesFragment : BaseFragment(MoviesFragmentBinding::inflate),
+ OnItemRecyclerViewClickListener {
+
+ private val mMovieAdapter: MoviesAdapter by lazy { MoviesAdapter() }
+
+ override val viewModel: MoviesViewModel by viewModel()
+
+ override fun initView() {
+ binding.recyclerViewMovie.apply {
+ adapter = mMovieAdapter
+ }
+ mMovieAdapter.registerItemRecyclerViewClickListener(this)
+ }
+
+ override fun initData() {
+ viewModel.requestTopRateMovies()
+ }
+
+ override fun bindData() {
+ viewModel.movies.observe(
+ this,
+ Observer { movies ->
+ mMovieAdapter.updateData(movies)
+ },
+ )
+ }
+
+ override fun onItemClick(item: Movie?) {
+ item.notNull {
+ addFragment(R.id.layoutContainer, DetailFragment.newInstance(it.id), true)
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/ui/listmovie/MoviesViewModel.kt b/app/src/main/java/com/sun/android/ui/listmovie/MoviesViewModel.kt
new file mode 100644
index 0000000..1870527
--- /dev/null
+++ b/app/src/main/java/com/sun/android/ui/listmovie/MoviesViewModel.kt
@@ -0,0 +1,27 @@
+package com.sun.android.ui.listmovie
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.sun.android.data.MovieRepository
+import com.sun.android.data.model.Movie
+import com.sun.android.utils.LogUtils
+import com.sun.android.utils.dispatchers.DispatcherProvider
+import com.sun.android.utils.livedata.SingleLiveData
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.launch
+
+class MoviesViewModel(private val movieRepository: MovieRepository) : ViewModel() {
+ val movies = SingleLiveData>()
+
+ fun requestTopRateMovies() {
+ viewModelScope.launch {
+ movieRepository.getMovies().catch { e ->
+ LogUtils.e("requestTopRateMovies", e.toString())
+ }.flowOn(DispatcherProvider().io())
+ .collect {
+ movies.value = it
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/ui/listmovie/adapter/MoviesAdapter.kt b/app/src/main/java/com/sun/android/ui/listmovie/adapter/MoviesAdapter.kt
new file mode 100644
index 0000000..13c5dfd
--- /dev/null
+++ b/app/src/main/java/com/sun/android/ui/listmovie/adapter/MoviesAdapter.kt
@@ -0,0 +1,72 @@
+package com.sun.android.ui.listmovie.adapter
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.sun.android.data.model.Movie
+import com.sun.android.databinding.ItemLayoutMovieBinding
+import com.sun.android.utils.extension.loadImageCircleWithUrl
+import com.sun.android.utils.extension.notNull
+import com.sun.android.utils.recycler.OnItemRecyclerViewClickListener
+
+class MoviesAdapter : RecyclerView.Adapter() {
+
+ private val movies = mutableListOf()
+ private var onItemClickListener: OnItemRecyclerViewClickListener? = null
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val binding = ItemLayoutMovieBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return ViewHolder(binding, onItemClickListener)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ holder.bindViewData(movies[position])
+ }
+
+ override fun getItemCount(): Int {
+ return movies.size
+ }
+
+ fun registerItemRecyclerViewClickListener(
+ onItemRecyclerViewClickListener: OnItemRecyclerViewClickListener?
+ ) {
+ onItemClickListener = onItemRecyclerViewClickListener
+ }
+
+ fun updateData(movies: List) {
+ movies.notNull {
+ this.movies.clear()
+ this.movies.addAll(it)
+ notifyDataSetChanged()
+ }
+ }
+
+ class ViewHolder(
+ private val binding: ItemLayoutMovieBinding,
+ itemClickListener: OnItemRecyclerViewClickListener?
+ ) : RecyclerView.ViewHolder(binding.root), View.OnClickListener {
+
+ private var movieData: Movie? = null
+ private var listener: OnItemRecyclerViewClickListener? = null
+
+ init {
+ itemView.setOnClickListener(this)
+ listener = itemClickListener
+ }
+
+ fun bindViewData(movie: Movie) {
+ movie.let {
+ binding.textViewTitle.text = it.title
+ binding.textViewRatting.text = it.vote.toString()
+ binding.textViewContent.text = it.originalTitle
+ binding.imageMovie.loadImageCircleWithUrl(it.urlImage)
+ movieData = it
+ }
+ }
+
+ override fun onClick(view: View?) {
+ listener?.onItemClick(movieData)
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/utils/Constant.kt b/app/src/main/java/com/sun/android/utils/Constant.kt
new file mode 100644
index 0000000..0e403f2
--- /dev/null
+++ b/app/src/main/java/com/sun/android/utils/Constant.kt
@@ -0,0 +1,5 @@
+package com.sun.android.utils
+
+object Constant {
+ const val DATABASE_NAME = "movies_database"
+}
diff --git a/app/src/main/java/com/sun/android/utils/DateTimeUtils.kt b/app/src/main/java/com/sun/android/utils/DateTimeUtils.kt
new file mode 100644
index 0000000..4705db4
--- /dev/null
+++ b/app/src/main/java/com/sun/android/utils/DateTimeUtils.kt
@@ -0,0 +1,8 @@
+package com.sun.android.utils
+
+object DateTimeUtils {
+
+ const val TIME_ZONE_UTC = "UTC"
+
+ const val DATE_TIME_FORMAT_UTC = "yyyy-MM-dd'T'HH:mm:ss'Z'"
+}
diff --git a/app/src/main/java/com/sun/android/utils/LogUtils.kt b/app/src/main/java/com/sun/android/utils/LogUtils.kt
new file mode 100644
index 0000000..8d6c169
--- /dev/null
+++ b/app/src/main/java/com/sun/android/utils/LogUtils.kt
@@ -0,0 +1,33 @@
+package com.sun.android.utils
+
+import android.util.Log
+import com.sun.android.BuildConfig
+
+object LogUtils {
+
+ private val DEBUG = BuildConfig.DEBUG
+
+ fun d(tag: String, message: String) {
+ if (DEBUG) {
+ Log.d(tag, message)
+ }
+ }
+
+ fun e(tag: String, msg: String) {
+ if (DEBUG) {
+ Log.e(tag, msg)
+ }
+ }
+
+ fun e(tag: String, msg: String, throwable: Throwable) {
+ if (DEBUG) {
+ Log.e(tag, msg, throwable)
+ }
+ }
+
+ fun thread() {
+ if (DEBUG) {
+ Log.e("THREAD", "${Thread.currentThread().name} has run.")
+ }
+ }
+}
diff --git a/app/src/main/java/com/sun/android/utils/dispatchers/BaseDispatcherProvider.kt b/app/src/main/java/com/sun/android/utils/dispatchers/BaseDispatcherProvider.kt
new file mode 100644
index 0000000..6171799
--- /dev/null
+++ b/app/src/main/java/com/sun/android/utils/dispatchers/BaseDispatcherProvider.kt
@@ -0,0 +1,19 @@
+package com.sun.android.utils.dispatchers
+
+import androidx.annotation.NonNull
+import kotlinx.coroutines.CoroutineDispatcher
+
+interface BaseDispatcherProvider {
+
+ @NonNull
+ fun computation(): CoroutineDispatcher
+
+ @NonNull
+ fun io(): CoroutineDispatcher
+
+ @NonNull
+ fun ui(): CoroutineDispatcher
+
+ @NonNull
+ fun unconfined(): CoroutineDispatcher
+}
diff --git a/app/src/main/java/com/sun/android/utils/dispatchers/DispatcherProvider.kt b/app/src/main/java/com/sun/android/utils/dispatchers/DispatcherProvider.kt
new file mode 100644
index 0000000..77b3861
--- /dev/null
+++ b/app/src/main/java/com/sun/android/utils/dispatchers/DispatcherProvider.kt
@@ -0,0 +1,20 @@
+package com.sun.android.utils.dispatchers
+
+import androidx.annotation.NonNull
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+
+class DispatcherProvider : BaseDispatcherProvider {
+
+ @NonNull
+ override fun computation(): CoroutineDispatcher = Dispatchers.Default
+
+ @NonNull
+ override fun io(): CoroutineDispatcher = Dispatchers.IO
+
+ @NonNull
+ override fun ui(): CoroutineDispatcher = Dispatchers.Main
+
+ @NonNull
+ override fun unconfined(): CoroutineDispatcher = Dispatchers.Unconfined
+}
diff --git a/app/src/main/java/com/sun/android/utils/extension/AnyExtension.kt b/app/src/main/java/com/sun/android/utils/extension/AnyExtension.kt
new file mode 100644
index 0000000..bf620f2
--- /dev/null
+++ b/app/src/main/java/com/sun/android/utils/extension/AnyExtension.kt
@@ -0,0 +1,9 @@
+package com.sun.android.utils.extension
+
+inline fun T?.notNull(f: (it: T) -> Unit) {
+ if (this != null) f(this)
+}
+
+inline fun T?.isNull(f: () -> Unit) {
+ if (this == null) f()
+}
diff --git a/app/src/main/java/com/sun/android/utils/extension/ContextExtension.kt b/app/src/main/java/com/sun/android/utils/extension/ContextExtension.kt
new file mode 100644
index 0000000..f290f71
--- /dev/null
+++ b/app/src/main/java/com/sun/android/utils/extension/ContextExtension.kt
@@ -0,0 +1,8 @@
+package com.sun.android.utils.extension
+
+import android.content.Context
+import android.widget.Toast
+
+fun Context.showToast(message: String) {
+ Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
+}
diff --git a/app/src/main/java/com/sun/android/utils/extension/FragmentExtension.kt b/app/src/main/java/com/sun/android/utils/extension/FragmentExtension.kt
new file mode 100644
index 0000000..41a78e9
--- /dev/null
+++ b/app/src/main/java/com/sun/android/utils/extension/FragmentExtension.kt
@@ -0,0 +1,54 @@
+package com.sun.android.utils.extension
+
+import androidx.annotation.IdRes
+import androidx.fragment.app.Fragment
+import com.sun.android.R
+
+fun Fragment.addFragment(
+ @IdRes containerId: Int,
+ fragment: Fragment,
+ addToBackStack: Boolean = false,
+ tag: String? = fragment::class.java.simpleName
+) {
+ activity?.supportFragmentManager?.apply {
+ beginTransaction().apply {
+ if (addToBackStack) {
+ addToBackStack(tag)
+ }
+ setCustomAnimations(
+ R.anim.slide_in_right,
+ R.anim.slide_in_right,
+ R.anim.slide_out_right,
+ R.anim.slide_out_right
+ )
+ add(containerId, fragment, tag)
+ }.commit()
+ }
+}
+
+fun Fragment.replaceFragment(
+ @IdRes containerId: Int,
+ fragment: Fragment,
+ addToBackStack: Boolean,
+ tag: String? = fragment::class.java.simpleName
+) {
+ activity?.supportFragmentManager?.apply {
+ beginTransaction().apply {
+ if (addToBackStack) {
+ addToBackStack(tag)
+ }
+ replace(containerId, fragment, tag)
+ }.commit()
+ }
+}
+
+fun Fragment.goBackFragment(): Boolean {
+ activity?.supportFragmentManager?.notNull {
+ val isShowPreviousPage = it.backStackEntryCount > 0
+ if (isShowPreviousPage) {
+ it.popBackStackImmediate()
+ }
+ return isShowPreviousPage
+ }
+ return false
+}
diff --git a/app/src/main/java/com/sun/android/utils/extension/ImageViewExtention.kt b/app/src/main/java/com/sun/android/utils/extension/ImageViewExtention.kt
new file mode 100644
index 0000000..697fdcb
--- /dev/null
+++ b/app/src/main/java/com/sun/android/utils/extension/ImageViewExtention.kt
@@ -0,0 +1,18 @@
+package com.sun.android.utils.extension
+
+import android.widget.ImageView
+import com.bumptech.glide.Glide
+import com.sun.android.BuildConfig
+
+fun ImageView.loadImageCircleWithUrl(url: String) {
+ Glide.with(this)
+ .load(BuildConfig.BASE_URL_IMAGE + url)
+ .circleCrop()
+ .into(this)
+}
+
+fun ImageView.loadImageWithUrl(url: String) {
+ Glide.with(this)
+ .load(BuildConfig.BASE_URL_IMAGE + url)
+ .into(this)
+}
diff --git a/app/src/main/java/com/sun/android/utils/livedata/SafeObserver.kt b/app/src/main/java/com/sun/android/utils/livedata/SafeObserver.kt
new file mode 100644
index 0000000..e16d7bd
--- /dev/null
+++ b/app/src/main/java/com/sun/android/utils/livedata/SafeObserver.kt
@@ -0,0 +1,9 @@
+package com.sun.android.utils.livedata
+
+import androidx.lifecycle.Observer
+
+class SafeObserver(private val notifier: (T) -> Unit) : Observer {
+ override fun onChanged(t: T) {
+ notifier(t)
+ }
+}
diff --git a/app/src/main/java/com/sun/android/utils/livedata/SingleLiveData.kt b/app/src/main/java/com/sun/android/utils/livedata/SingleLiveData.kt
new file mode 100644
index 0000000..47b183d
--- /dev/null
+++ b/app/src/main/java/com/sun/android/utils/livedata/SingleLiveData.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2017 Google Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance withScheduler the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.sun.android.utils.livedata
+
+import android.util.Log
+import androidx.annotation.MainThread
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Observer
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * A lifecycle-aware observable that sends only new updates after subscription, used for events like
+ * navigation and Snackbar messages.
+ *
+ *
+ * This avoids a common problem withScheduler events: on configuration change (like rotation) an update
+ * can be emitted if the observer is active. This LiveData only calls the observable if there's an
+ * explicit call to setValue() or call().
+ *
+ *
+ * Note that only one observer is going to be notified of changes.
+ */
+class SingleLiveData : MutableLiveData() {
+
+ private val pending = AtomicBoolean(false)
+
+ @MainThread
+ override fun observe(owner: LifecycleOwner, observer: Observer) {
+ if (hasActiveObservers()) {
+ Log.d(
+ "",
+ "Multiple observers registered but only one will be notified of changes."
+ )
+ }
+
+ // Observe the internal MutableLiveData
+ super.observe(owner, SafeObserver { t ->
+ if (pending.compareAndSet(true, false)) {
+ observer.onChanged(t)
+ }
+ })
+ }
+
+ @MainThread
+ override fun setValue(t: T?) {
+ pending.set(true)
+ super.setValue(t)
+ }
+
+ /**
+ * Used for cases where T is Void, to make calls cleaner.
+ */
+ @MainThread
+ fun call() {
+ value = null
+ }
+}
+
+@MainThread
+inline fun LiveData.observeLiveData(
+ owner: LifecycleOwner,
+ crossinline onChanged: (T) -> Unit
+) {
+ this.observe(owner, Observer { onChanged(it) })
+}
diff --git a/app/src/main/java/com/sun/android/utils/recycler/OnItemRecyclerViewClickListener.kt b/app/src/main/java/com/sun/android/utils/recycler/OnItemRecyclerViewClickListener.kt
new file mode 100644
index 0000000..bc395fa
--- /dev/null
+++ b/app/src/main/java/com/sun/android/utils/recycler/OnItemRecyclerViewClickListener.kt
@@ -0,0 +1,5 @@
+package com.sun.android.utils.recycler
+
+interface OnItemRecyclerViewClickListener {
+ fun onItemClick(item: T?)
+}
diff --git a/app/src/main/res/anim/slide_in_right.xml b/app/src/main/res/anim/slide_in_right.xml
new file mode 100644
index 0000000..294128c
--- /dev/null
+++ b/app/src/main/res/anim/slide_in_right.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/src/main/res/anim/slide_out_right.xml b/app/src/main/res/anim/slide_out_right.xml
new file mode 100644
index 0000000..1ab787f
--- /dev/null
+++ b/app/src/main/res/anim/slide_out_right.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml
new file mode 100644
index 0000000..3ddbf6b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_back.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_corona.xml b/app/src/main/res/drawable/ic_corona.xml
new file mode 100644
index 0000000..40627fe
--- /dev/null
+++ b/app/src/main/res/drawable/ic_corona.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_star.xml b/app/src/main/res/drawable/ic_star.xml
new file mode 100644
index 0000000..d28d8a9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_star.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 4fc2444..5c0d6e8 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,18 +1,7 @@
-
-
-
-
\ No newline at end of file
+ tools:context=".ui.MainActivity"/>
diff --git a/app/src/main/res/layout/fragment_detail.xml b/app/src/main/res/layout/fragment_detail.xml
new file mode 100644
index 0000000..422d951
--- /dev/null
+++ b/app/src/main/res/layout/fragment_detail.xml
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_layout_movie.xml b/app/src/main/res/layout/item_layout_movie.xml
new file mode 100644
index 0000000..aa4fc76
--- /dev/null
+++ b/app/src/main/res/layout/item_layout_movie.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/movies_fragment.xml b/app/src/main/res/layout/movies_fragment.xml
new file mode 100644
index 0000000..0225df9
--- /dev/null
+++ b/app/src/main/res/layout/movies_fragment.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index f8c6127..9f8ea6b 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -7,4 +7,8 @@
#FF018786
#FF000000
#FFFFFFFF
-
\ No newline at end of file
+ #E64A19
+ #D50000
+ #5C6BC0
+ #F5F5F5
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8542005..643e780 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,2 +1,4 @@
+ Ratting:
+ Total reviews
diff --git a/build.gradle.kts b/build.gradle.kts
index 1f8f99d..dddf106 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -9,6 +9,7 @@ buildscript {
dependencies {
classpath (ClassPath.gradle_build_tools)
classpath (ClassPath.kotlin_gradle_plugin)
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10")
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.build.gradle.kts files
diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt
index 8eae361..c59b24e 100644
--- a/buildSrc/src/main/kotlin/Config.kt
+++ b/buildSrc/src/main/kotlin/Config.kt
@@ -12,13 +12,24 @@ object Versions {
const val lifecycle = "2.5"
const val navigation = "2.5.3"
+
+ const val coroutines = "1.4.2"
+
+ const val retrofit = "2.9.0"
+ const val okHttp = "4.7.2"
+
+ const val glide = "4.11.0"
+
const val koin = "3.4.0"
+ const val room = "2.5.2"
+
const val jUnit = "4.13.2"
const val mockk = "1.13.2"
const val ktlint = "0.48.1"
const val detekt = "1.23.3"
+ const val ksp = "1.9.0-1.0.12"
}
object AppConfigs {
@@ -40,7 +51,9 @@ object ClassPath {
object Plugins {
const val android_application = "com.android.application"
const val kotlin_android = "android"
+ const val kotlin_parcelize = "kotlin-parcelize"
const val detekt = "io.gitlab.arturbosch.detekt"
+ const val ksp = "com.google.devtools.ksp"
}
object Deps {
@@ -49,21 +62,41 @@ object Deps {
const val material = "com.google.android.material:material:${Versions.material}"
const val constraint_layout = "androidx.constraintlayout:constraintlayout:${Versions.constraintLayout}"
- // Splash screen
- const val splash_screen = "androidx.core:core-splashscreen:${Versions.coreSplashScreen}"
-
- // lifecycle
- const val lifecycle_livedata_ktx = "androidx.lifecycle:lifecycle-livedata-ktx:${Versions.lifecycle}"
- const val lifecycle_viewmodel_ktx = "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.lifecycle}"
-
// navigation
+ const val navigation_fragment = "androidx.navigation:navigation-fragment:${Versions.navigation}"
+ const val navigation_ui = "androidx.navigation:navigation-ui:${Versions.navigation}"
const val navigation_fragment_ktx = "androidx.navigation:navigation-fragment-ktx:${Versions.navigation}"
const val navigation_ui_ktx = "androidx.navigation:navigation-ui-ktx:${Versions.navigation}"
- // koin
- const val koin_core = "io.insert-koin:koin-core:${Versions.koin}"
- const val koin_android = "io.insert-koin:koin-android:${Versions.koin}"
- const val koin_test = "io.insert-koin:koin-test:${Versions.koin}"
+ // Lifecycle
+ const val lifecycle_livedata_ktx = "androidx.lifecycle:lifecycle-livedata-ktx:${Versions.lifecycle}"
+ const val lifecycle_viewmodel_ktx = "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.lifecycle}"
+ const val lifecycle_runtime = "androidx.lifecycle:lifecycle-runtime:${Versions.lifecycle}"
+
+ // Coroutines
+ const val coroutines_core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutines}"
+ const val coroutines_android =
+ "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutines}"
+ const val coroutines_test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.coroutines}"
+
+ //Retrofit
+ const val retrofit_runtime = "com.squareup.retrofit2:retrofit:${Versions.retrofit}"
+ const val okHttp = "com.squareup.okhttp3:okhttp:${Versions.okHttp}"
+ const val retrofit_gson = "com.squareup.retrofit2:converter-gson:${Versions.retrofit}"
+ const val okhttp_logging_interceptor =
+ "com.squareup.okhttp3:logging-interceptor:${Versions.okHttp}"
+
+ // Glide
+ const val glide_runtime = "com.github.bumptech.glide:glide:${Versions.glide}"
+ const val glide_compiler = "com.github.bumptech.glide:compiler:${Versions.glide}"
+
+ // Koin
+ const val koin = "io.insert-koin:koin-android:${Versions.koin}"
+
+ // Room
+ const val room_runtime = "androidx.room:room-runtime:${Versions.room}"
+ const val room_ksp = "androidx.room:room-compiler:${Versions.room}"
+ const val room_ktx = "androidx.room:room-ktx:${Versions.room}"
// Testing
const val junit = "junit:junit:${Versions.jUnit}"
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index 621fa68..59fb9ac 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -282,7 +282,7 @@ exceptions:
active: true
TooGenericExceptionCaught:
active: true
- excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
+ excludes: ['**/base/BaseRepository.kt','**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
exceptionNames:
- 'ArrayIndexOutOfBoundsException'
- 'Error'
@@ -622,9 +622,8 @@ style:
active: false
ReturnCount:
active: true
- max: 2
- excludedFunctions:
- - 'equals'
+ max: 10
+ excludedFunctions: 'equals'
excludeLabeled: false
excludeReturnFromLambda: true
excludeGuardClauses: false
diff --git a/gradle.properties b/gradle.properties
index a0404d9..0820ba1 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -19,5 +19,6 @@ android.useAndroidX=true
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
+android.defaults.buildfeatures.buildconfig=true
# TODO - config run for MacOS, Other OS should update this line to run
org.gradle.java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home
diff --git a/team-props/git-hooks/mac/pre-commit.sh b/team-props/git-hooks/mac/pre-commit.sh
index 3f7d08a..3d35148 100644
--- a/team-props/git-hooks/mac/pre-commit.sh
+++ b/team-props/git-hooks/mac/pre-commit.sh
@@ -2,7 +2,7 @@
echo "Running static analysis..."
echo "Start running ktlint"
-./gradlew ktlintFormat ktlintCheck --daemon
+./gradlew ktlintCheck --daemon
status1=$?
if [[ "$status1" = 0 ]] ; then
echo "*******************************************************"