From 337e13d2447e9539ddbdf735f4d2642370a5b5dc Mon Sep 17 00:00:00 2001 From: daolq3012 Date: Wed, 16 Feb 2022 22:35:39 +0700 Subject: [PATCH 01/10] [Add] Base Activity & Fragment --- .editorconfig | 2 +- .idea/misc.xml | 19 +++++++++ app/build.gradle.kts | 26 ++++++++++++ app/src/main/AndroidManifest.xml | 5 ++- .../com/sun/android/AndroidApplication.kt | 10 +++++ .../java/com/sun/android/base/BaseActivity.kt | 36 ++++++++++++++++ .../java/com/sun/android/base/BaseFragment.kt | 42 +++++++++++++++++++ .../com/sun/android/base/BaseViewModel.kt | 11 +++++ .../java/com/sun/android/ui/MainActivity.kt | 13 ++++++ .../java/com/sun/android/ui/MainViewModel.kt | 5 +++ .../com/sun/android/utils/ContextExtension.kt | 8 ++++ app/src/main/res/layout/activity_main.xml | 5 ++- buildSrc/src/main/kotlin/Config.kt | 31 +++++++++++--- 13 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 .idea/misc.xml create mode 100644 app/src/main/java/com/sun/android/AndroidApplication.kt create mode 100644 app/src/main/java/com/sun/android/base/BaseActivity.kt create mode 100644 app/src/main/java/com/sun/android/base/BaseFragment.kt create mode 100644 app/src/main/java/com/sun/android/base/BaseViewModel.kt create mode 100644 app/src/main/java/com/sun/android/ui/MainActivity.kt create mode 100644 app/src/main/java/com/sun/android/ui/MainViewModel.kt create mode 100644 app/src/main/java/com/sun/android/utils/ContextExtension.kt diff --git a/.editorconfig b/.editorconfig index 62bd415..0cfa3bb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -[*.{kt,kts}] +[*.{kt,kts,xml}] indent_size=4 insert_final_newline=true max_line_length=120 diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..907c090 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e7c5b75..f7bc082 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,6 +58,9 @@ android { kotlinOptions { jvmTarget = "11" } + buildFeatures { + viewBinding = true + } } detekt { @@ -90,6 +93,29 @@ 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_extension) + implementation(Deps.lifecycle_livedata_ktx) + implementation(Deps.lifecycle_viewmodel_ktx) + implementation(Deps.lifecycle_runtime) + + //Retrofit + implementation(Deps.okHttp) + implementation(Deps.retrofit_runtime) + implementation(Deps.retrofit_gson) + implementation(Deps.okhttp_logging_interceptor) + + //Glide + implementation(Deps.glide_runtime) + implementation(Deps.glide_compiler) + + //Test testImplementation(Deps.junit) testImplementation(Deps.mockk) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0602b83..f13eece 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ package="com.sun.structure_android"> @@ -20,4 +21,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..12760e0 --- /dev/null +++ b/app/src/main/java/com/sun/android/AndroidApplication.kt @@ -0,0 +1,10 @@ +package com.sun.android + +import android.app.Application + +class AndroidApplication : Application() { + + override fun onCreate() { + super.onCreate() + } +} 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..3c09589 --- /dev/null +++ b/app/src/main/java/com/sun/android/base/BaseActivity.kt @@ -0,0 +1,36 @@ +package com.sun.android.base + +import android.os.Bundle +import android.view.LayoutInflater +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Observer +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: BaseViewModel + + protected abstract fun initialize() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + _binding = inflate.invoke(layoutInflater) + setContentView(binding.root) + + viewModel.error.observe(this, Observer { + Toast.makeText(this, it, Toast.LENGTH_SHORT).show() + }) + + 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..74fa74a --- /dev/null +++ b/app/src/main/java/com/sun/android/base/BaseFragment.kt @@ -0,0 +1,42 @@ +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.Observer +import androidx.viewbinding.ViewBinding +import com.sun.android.utils.showToast + +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: BaseViewModel + + protected abstract fun initialize() + + 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) + viewModel.error.observe(viewLifecycleOwner, Observer { + view.context.showToast(it) + }) + initialize() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/sun/android/base/BaseViewModel.kt b/app/src/main/java/com/sun/android/base/BaseViewModel.kt new file mode 100644 index 0000000..29b20e8 --- /dev/null +++ b/app/src/main/java/com/sun/android/base/BaseViewModel.kt @@ -0,0 +1,11 @@ +package com.sun.android.base + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +open class BaseViewModel : ViewModel() { + private val _error = MutableLiveData() + val error: LiveData + get() = _error +} 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..772b43b --- /dev/null +++ b/app/src/main/java/com/sun/android/ui/MainActivity.kt @@ -0,0 +1,13 @@ +package com.sun.android.ui + +import com.sun.android.base.BaseActivity +import com.sun.android.databinding.ActivityMainBinding + +class MainActivity : BaseActivity(ActivityMainBinding::inflate) { + + override val viewModel get() = MainViewModel() + + override fun initialize() { + binding.txtTest.text = "ABC" + } +} 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..c1e52c0 --- /dev/null +++ b/app/src/main/java/com/sun/android/ui/MainViewModel.kt @@ -0,0 +1,5 @@ +package com.sun.android.ui + +import com.sun.android.base.BaseViewModel + +class MainViewModel : BaseViewModel() diff --git a/app/src/main/java/com/sun/android/utils/ContextExtension.kt b/app/src/main/java/com/sun/android/utils/ContextExtension.kt new file mode 100644 index 0000000..f62e856 --- /dev/null +++ b/app/src/main/java/com/sun/android/utils/ContextExtension.kt @@ -0,0 +1,8 @@ +package com.sun.android.utils + +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/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 4fc2444..d791af3 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -4,9 +4,10 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".MainActivity"> + tools:context=".ui.MainActivity"> - \ No newline at end of file + diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 8eae361..6cd8b05 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -12,6 +12,12 @@ object Versions { const val lifecycle = "2.5" const val navigation = "2.5.3" + + 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 jUnit = "4.13.2" @@ -49,16 +55,29 @@ 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}" + // 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}" // lifecycle + const val lifecycle_extension = "androidx.lifecycle:lifecycle-extensions:${Versions.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_ktx = "androidx.navigation:navigation-fragment-ktx:${Versions.navigation}" - const val navigation_ui_ktx = "androidx.navigation:navigation-ui-ktx:${Versions.navigation}" + const val lifecycle_runtime = "androidx.lifecycle:lifecycle-runtime:${Versions.lifecycle}" + + //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 retrofit_mock = "com.squareup.retrofit2:retrofit-mock:${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_core = "io.insert-koin:koin-core:${Versions.koin}" From b781d998b748162756b47441515d9838ce1f1aaa Mon Sep 17 00:00:00 2001 From: daolq3012 Date: Thu, 17 Feb 2022 14:11:24 +0700 Subject: [PATCH 02/10] [Add] config network --- .idea/misc.xml | 2 +- app/build.gradle.kts | 7 ++ .../com/sun/android/data/MovieRepository.kt | 5 + .../com/sun/android/data/TokenRepository.kt | 9 ++ .../java/com/sun/android/data/model/Movie.kt | 3 + .../data/repository/MovieRepositoryImpl.kt | 18 ++++ .../data/repository/TokenRepositoryImpl.kt | 14 +++ .../android/data/source/MovieDataSource.kt | 20 ++++ .../android/data/source/TokenDataSource.kt | 13 +++ .../data/source/local/TokenLocalImpl.kt | 17 +++ .../data/source/local/api/SharedPrefApi.kt | 20 ++++ .../local/api/sharedpref/SharedPrefApiImpl.kt | 74 +++++++++++++ .../local/api/sharedpref/SharedPrefKey.kt | 6 ++ .../data/source/remote/MovieRemoteImpl.kt | 12 +++ .../data/source/remote/api/ApiService.kt | 3 + .../source/remote/api/error/ErrorResponse.kt | 65 ++++++++++++ .../remote/api/error/RetrofitException.kt | 100 ++++++++++++++++++ .../data/source/remote/api/error/Type.kt | 29 +++++ .../remote/api/middleware/BooleanAdapter.kt | 32 ++++++ .../remote/api/middleware/DoubleAdapter.kt | 38 +++++++ .../remote/api/middleware/IntegerAdapter.kt | 38 +++++++ .../remote/api/middleware/InterceptorImpl.kt | 63 +++++++++++ .../remote/api/response/BaseResponse.kt | 16 +++ .../java/com/sun/android/utils/LogUtils.kt | 33 ++++++ .../android/utils/extension/AnyExtension.kt | 9 ++ .../utils/{ => extension}/ContextExtension.kt | 0 buildSrc/src/main/kotlin/Config.kt | 11 +- config/detekt/detekt.yml | 5 +- 28 files changed, 656 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/sun/android/data/MovieRepository.kt create mode 100644 app/src/main/java/com/sun/android/data/TokenRepository.kt create mode 100644 app/src/main/java/com/sun/android/data/model/Movie.kt create mode 100644 app/src/main/java/com/sun/android/data/repository/MovieRepositoryImpl.kt create mode 100644 app/src/main/java/com/sun/android/data/repository/TokenRepositoryImpl.kt create mode 100644 app/src/main/java/com/sun/android/data/source/MovieDataSource.kt create mode 100644 app/src/main/java/com/sun/android/data/source/TokenDataSource.kt create mode 100644 app/src/main/java/com/sun/android/data/source/local/TokenLocalImpl.kt create mode 100644 app/src/main/java/com/sun/android/data/source/local/api/SharedPrefApi.kt create mode 100644 app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefApiImpl.kt create mode 100644 app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefKey.kt create mode 100644 app/src/main/java/com/sun/android/data/source/remote/MovieRemoteImpl.kt create mode 100644 app/src/main/java/com/sun/android/data/source/remote/api/ApiService.kt create mode 100644 app/src/main/java/com/sun/android/data/source/remote/api/error/ErrorResponse.kt create mode 100644 app/src/main/java/com/sun/android/data/source/remote/api/error/RetrofitException.kt create mode 100644 app/src/main/java/com/sun/android/data/source/remote/api/error/Type.kt create mode 100644 app/src/main/java/com/sun/android/data/source/remote/api/middleware/BooleanAdapter.kt create mode 100644 app/src/main/java/com/sun/android/data/source/remote/api/middleware/DoubleAdapter.kt create mode 100644 app/src/main/java/com/sun/android/data/source/remote/api/middleware/IntegerAdapter.kt create mode 100644 app/src/main/java/com/sun/android/data/source/remote/api/middleware/InterceptorImpl.kt create mode 100644 app/src/main/java/com/sun/android/data/source/remote/api/response/BaseResponse.kt create mode 100644 app/src/main/java/com/sun/android/utils/LogUtils.kt create mode 100644 app/src/main/java/com/sun/android/utils/extension/AnyExtension.kt rename app/src/main/java/com/sun/android/utils/{ => extension}/ContextExtension.kt (100%) diff --git a/.idea/misc.xml b/.idea/misc.xml index 907c090..e8174b0 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -10,7 +10,7 @@ - + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f7bc082..6c1d1e0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { id(Plugins.android_application) kotlin(Plugins.kotlin_android) @@ -105,6 +107,11 @@ dependencies { 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) 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..44c97cb --- /dev/null +++ b/app/src/main/java/com/sun/android/data/MovieRepository.kt @@ -0,0 +1,5 @@ +package com.sun.android.data + +interface MovieRepository { + fun getMovies() +} 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..a5539aa --- /dev/null +++ b/app/src/main/java/com/sun/android/data/model/Movie.kt @@ -0,0 +1,3 @@ +package com.sun.android.data.model + +class Movie 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..49babda --- /dev/null +++ b/app/src/main/java/com/sun/android/data/repository/MovieRepositoryImpl.kt @@ -0,0 +1,18 @@ +package com.sun.android.data.repository + +import com.sun.android.data.source.MovieDataSource + +class MovieRepositoryImpl private constructor( + private val remote: MovieDataSource.Remote, + private val local: MovieDataSource.Local +) { + + companion object { + private var instance: MovieRepositoryImpl? = null + + fun getInstance(remote: MovieDataSource.Remote, local: MovieDataSource.Local) = + synchronized(this) { + instance ?: MovieRepositoryImpl(remote, local).also { instance = it } + } + } +} 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..9369aa4 --- /dev/null +++ b/app/src/main/java/com/sun/android/data/source/MovieDataSource.kt @@ -0,0 +1,20 @@ +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 { + fun getMoviesLocal(): BaseResponse> + } + + /** + * Remote + */ + interface Remote { + fun getMovies(): BaseResponse> + } +} 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/TokenLocalImpl.kt b/app/src/main/java/com/sun/android/data/source/local/TokenLocalImpl.kt new file mode 100644 index 0000000..d2b5fde --- /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.SharedPrefApi +import com.sun.android.data.source.local.api.sharedpref.SharedPrefKey + +class TokenLocalImpl(private val sharedPrefApi: SharedPrefApi) : + TokenDataSource.Local { + + override fun getToken() = + sharedPrefApi.get(SharedPrefKey.KEY_TOKEN, String::class.java) + + override fun saveToken(token: String) = + sharedPrefApi.put(SharedPrefKey.KEY_TOKEN, token) + + override fun clearToken() = sharedPrefApi.removeKey(SharedPrefKey.KEY_TOKEN) +} diff --git a/app/src/main/java/com/sun/android/data/source/local/api/SharedPrefApi.kt b/app/src/main/java/com/sun/android/data/source/local/api/SharedPrefApi.kt new file mode 100644 index 0000000..4ef4eef --- /dev/null +++ b/app/src/main/java/com/sun/android/data/source/local/api/SharedPrefApi.kt @@ -0,0 +1,20 @@ +package com.sun.android.data.source.local.api + +import android.content.SharedPreferences + +interface SharedPrefApi { + 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/SharedPrefApiImpl.kt b/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefApiImpl.kt new file mode 100644 index 0000000..8112f0a --- /dev/null +++ b/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefApiImpl.kt @@ -0,0 +1,74 @@ +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.SharedPrefApi + +class SharedPrefApiImpl(context: Context, private val gson: Gson) : SharedPrefApi { + + private val sharedPreferences by lazy { + context.getSharedPreferences(SharedPrefKey.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/SharedPrefKey.kt b/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefKey.kt new file mode 100644 index 0000000..fde327f --- /dev/null +++ b/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefKey.kt @@ -0,0 +1,6 @@ +package com.sun.android.data.source.local.api.sharedpref + +object SharedPrefKey { + const val PREFS_NAME = "WSMCompanySharedPreference" + const val KEY_TOKEN = "KEY_TOKEN" +} 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..50a3238 --- /dev/null +++ b/app/src/main/java/com/sun/android/data/source/remote/MovieRemoteImpl.kt @@ -0,0 +1,12 @@ +package com.sun.android.data.source.remote + +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 fun getMovies(): BaseResponse> { + TODO("Not yet implemented") + } +} 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..7da5c70 --- /dev/null +++ b/app/src/main/java/com/sun/android/data/source/remote/api/ApiService.kt @@ -0,0 +1,3 @@ +package com.sun.android.data.source.remote.api + +interface ApiService 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..d027e9d --- /dev/null +++ b/app/src/main/java/com/sun/android/data/source/remote/api/error/ErrorResponse.kt @@ -0,0 +1,65 @@ +package com.sun.android.data.source.remote.api.error + +import com.google.gson.Gson +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) + } + } + + 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..c5f991b --- /dev/null +++ b/app/src/main/java/com/sun/android/data/source/remote/api/error/RetrofitException.kt @@ -0,0 +1,100 @@ +package com.sun.android.data.source.remote.api.error + +import java.io.IOException +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 300..308) { + // Redirection + return "It was transferred to a different URL. I'm sorry for causing you trouble" + } + if (this in 400..451) { + // Client error + return "An error occurred on the application side. Please try again later!" + } + if (this in 500..511) { + // 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..9fc447d --- /dev/null +++ b/app/src/main/java/com/sun/android/data/source/remote/api/middleware/InterceptorImpl.kt @@ -0,0 +1,63 @@ +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..22f250a --- /dev/null +++ b/app/src/main/java/com/sun/android/data/source/remote/api/response/BaseResponse.kt @@ -0,0 +1,16 @@ +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("data") + @Expose + var data: T +) 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/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/ContextExtension.kt b/app/src/main/java/com/sun/android/utils/extension/ContextExtension.kt similarity index 100% rename from app/src/main/java/com/sun/android/utils/ContextExtension.kt rename to app/src/main/java/com/sun/android/utils/extension/ContextExtension.kt diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 6cd8b05..270754f 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -13,6 +13,8 @@ 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" @@ -61,17 +63,22 @@ object Deps { const val navigation_fragment_ktx = "androidx.navigation:navigation-fragment-ktx:${Versions.navigation}" const val navigation_ui_ktx = "androidx.navigation:navigation-ui-ktx:${Versions.navigation}" - // lifecycle + // Lifecycle const val lifecycle_extension = "androidx.lifecycle:lifecycle-extensions:${Versions.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 retrofit_mock = "com.squareup.retrofit2:retrofit-mock:${Versions.retrofit}" const val okhttp_logging_interceptor = "com.squareup.okhttp3:logging-interceptor:${Versions.okHttp}" diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 621fa68..91fe123 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -622,9 +622,8 @@ style: active: false ReturnCount: active: true - max: 2 - excludedFunctions: - - 'equals' + max: 10 + excludedFunctions: 'equals' excludeLabeled: false excludeReturnFromLambda: true excludeGuardClauses: false From c8f25c1fead982ea1087ec5bb404ae09a1fc07a4 Mon Sep 17 00:00:00 2001 From: daolq3012 Date: Thu, 17 Feb 2022 16:45:33 +0700 Subject: [PATCH 03/10] [Add] Koin --- app/build.gradle.kts | 8 +- .../com/sun/android/AndroidApplication.kt | 16 ++++ .../sun/android/data/di/DataSourceModule.kt | 9 +++ .../com/sun/android/data/di/NetWorkModule.kt | 79 +++++++++++++++++++ .../sun/android/data/di/RepositoryModule.kt | 14 ++++ .../data/source/local/TokenLocalImpl.kt | 12 +-- .../{SharedPrefApi.kt => SharedPrefsApi.kt} | 2 +- ...haredPrefApiImpl.kt => SharedPrefsImpl.kt} | 8 +- .../{SharedPrefKey.kt => SharedPrefsKey.kt} | 2 +- .../main/java/com/sun/android/di/AppModule.kt | 52 ++++++++++++ .../com/sun/android/di/ViewModelModule.kt | 10 +++ .../java/com/sun/android/ui/MainActivity.kt | 3 +- .../com/sun/android/utils/DateTimeUtils.kt | 8 ++ .../dispatchers/BaseDispatcherProvider.kt | 19 +++++ .../utils/dispatchers/DispatcherProvider.kt | 20 +++++ buildSrc/src/main/kotlin/Config.kt | 7 +- 16 files changed, 250 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/com/sun/android/data/di/DataSourceModule.kt create mode 100644 app/src/main/java/com/sun/android/data/di/NetWorkModule.kt create mode 100644 app/src/main/java/com/sun/android/data/di/RepositoryModule.kt rename app/src/main/java/com/sun/android/data/source/local/api/{SharedPrefApi.kt => SharedPrefsApi.kt} (95%) rename app/src/main/java/com/sun/android/data/source/local/api/sharedpref/{SharedPrefApiImpl.kt => SharedPrefsImpl.kt} (89%) rename app/src/main/java/com/sun/android/data/source/local/api/sharedpref/{SharedPrefKey.kt => SharedPrefsKey.kt} (86%) create mode 100644 app/src/main/java/com/sun/android/di/AppModule.kt create mode 100644 app/src/main/java/com/sun/android/di/ViewModelModule.kt create mode 100644 app/src/main/java/com/sun/android/utils/DateTimeUtils.kt create mode 100644 app/src/main/java/com/sun/android/utils/dispatchers/BaseDispatcherProvider.kt create mode 100644 app/src/main/java/com/sun/android/utils/dispatchers/DispatcherProvider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6c1d1e0..551f2b1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { id(Plugins.android_application) kotlin(Plugins.kotlin_android) @@ -29,9 +27,11 @@ android { create("dev") { applicationIdSuffix = ".dev" resValue("string", "app_name", "Structure-Dev") + buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"") } create("prd") { resValue("string", "app_name", "Structure") + buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"") versionCode = AppConfigs.version_code_release versionName = AppConfigs.version_name_release } @@ -118,6 +118,10 @@ dependencies { implementation(Deps.retrofit_gson) implementation(Deps.okhttp_logging_interceptor) + //Koin + implementation(Deps.koin_ext) + implementation(Deps.koin_viewmodel) + //Glide implementation(Deps.glide_runtime) implementation(Deps.glide_compiler) diff --git a/app/src/main/java/com/sun/android/AndroidApplication.kt b/app/src/main/java/com/sun/android/AndroidApplication.kt index 12760e0..d6e0c98 100644 --- a/app/src/main/java/com/sun/android/AndroidApplication.kt +++ b/app/src/main/java/com/sun/android/AndroidApplication.kt @@ -1,10 +1,26 @@ package com.sun.android import android.app.Application +import com.sun.android.data.di.DataSourceModule +import com.sun.android.data.di.NetworkModule +import com.sun.android.data.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/data/di/DataSourceModule.kt b/app/src/main/java/com/sun/android/data/di/DataSourceModule.kt new file mode 100644 index 0000000..2447ac2 --- /dev/null +++ b/app/src/main/java/com/sun/android/data/di/DataSourceModule.kt @@ -0,0 +1,9 @@ +package com.sun.android.data.di + +import com.sun.android.data.source.TokenDataSource +import com.sun.android.data.source.local.TokenLocalImpl +import org.koin.dsl.module + +val DataSourceModule = module { + single { TokenLocalImpl(get()) } +} diff --git a/app/src/main/java/com/sun/android/data/di/NetWorkModule.kt b/app/src/main/java/com/sun/android/data/di/NetWorkModule.kt new file mode 100644 index 0000000..130be0c --- /dev/null +++ b/app/src/main/java/com/sun/android/data/di/NetWorkModule.kt @@ -0,0 +1,79 @@ +package com.sun.android.data.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 = 10 * 1024 * 1024 // 10MB + 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 +} diff --git a/app/src/main/java/com/sun/android/data/di/RepositoryModule.kt b/app/src/main/java/com/sun/android/data/di/RepositoryModule.kt new file mode 100644 index 0000000..5b2f43e --- /dev/null +++ b/app/src/main/java/com/sun/android/data/di/RepositoryModule.kt @@ -0,0 +1,14 @@ +package com.sun.android.data.di + +import com.sun.android.data.TokenRepository +import com.sun.android.data.repository.TokenRepositoryImpl +import com.sun.android.data.source.TokenDataSource +import org.koin.dsl.module + +val RepositoryModule = module { + single { provideTokenRepository(get()) } +} + +fun provideTokenRepository(local: TokenDataSource.Local): TokenRepository { + return TokenRepositoryImpl(local) +} 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 index d2b5fde..ab56822 100644 --- 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 @@ -1,17 +1,17 @@ package com.sun.android.data.source.local import com.sun.android.data.source.TokenDataSource -import com.sun.android.data.source.local.api.SharedPrefApi -import com.sun.android.data.source.local.api.sharedpref.SharedPrefKey +import com.sun.android.data.source.local.api.SharedPrefsApi +import com.sun.android.data.source.local.api.sharedpref.SharedPrefsKey -class TokenLocalImpl(private val sharedPrefApi: SharedPrefApi) : +class TokenLocalImpl(private val sharedPrefApi: SharedPrefsApi) : TokenDataSource.Local { override fun getToken() = - sharedPrefApi.get(SharedPrefKey.KEY_TOKEN, String::class.java) + sharedPrefApi.get(SharedPrefsKey.KEY_TOKEN, String::class.java) override fun saveToken(token: String) = - sharedPrefApi.put(SharedPrefKey.KEY_TOKEN, token) + sharedPrefApi.put(SharedPrefsKey.KEY_TOKEN, token) - override fun clearToken() = sharedPrefApi.removeKey(SharedPrefKey.KEY_TOKEN) + override fun clearToken() = sharedPrefApi.removeKey(SharedPrefsKey.KEY_TOKEN) } diff --git a/app/src/main/java/com/sun/android/data/source/local/api/SharedPrefApi.kt b/app/src/main/java/com/sun/android/data/source/local/api/SharedPrefsApi.kt similarity index 95% rename from app/src/main/java/com/sun/android/data/source/local/api/SharedPrefApi.kt rename to app/src/main/java/com/sun/android/data/source/local/api/SharedPrefsApi.kt index 4ef4eef..fee46f1 100644 --- a/app/src/main/java/com/sun/android/data/source/local/api/SharedPrefApi.kt +++ b/app/src/main/java/com/sun/android/data/source/local/api/SharedPrefsApi.kt @@ -2,7 +2,7 @@ package com.sun.android.data.source.local.api import android.content.SharedPreferences -interface SharedPrefApi { +interface SharedPrefsApi { fun put(key: String, data: T) fun get(key: String, type: Class, default: T? = null): T? fun putList(key: String, list: List) diff --git a/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefApiImpl.kt b/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefsImpl.kt similarity index 89% rename from app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefApiImpl.kt rename to app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefsImpl.kt index 8112f0a..5e5c83a 100644 --- a/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefApiImpl.kt +++ b/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefsImpl.kt @@ -4,12 +4,12 @@ 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.SharedPrefApi +import com.sun.android.data.source.local.api.SharedPrefsApi -class SharedPrefApiImpl(context: Context, private val gson: Gson) : SharedPrefApi { +class SharedPrefsImpl(context: Context, private val gson: Gson) : SharedPrefsApi { private val sharedPreferences by lazy { - context.getSharedPreferences(SharedPrefKey.PREFS_NAME, Context.MODE_PRIVATE) + context.getSharedPreferences(SharedPrefsKey.PREFS_NAME, Context.MODE_PRIVATE) } override fun registerOnSharedPreferenceChangeListener( @@ -32,7 +32,7 @@ class SharedPrefApiImpl(context: Context, private val gson: Gson) : SharedPrefAp 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)) + else -> editor.putString(key, Gson().toJson(data)) } editor.apply() } diff --git a/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefKey.kt b/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefsKey.kt similarity index 86% rename from app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefKey.kt rename to app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefsKey.kt index fde327f..bab3262 100644 --- a/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefKey.kt +++ b/app/src/main/java/com/sun/android/data/source/local/api/sharedpref/SharedPrefsKey.kt @@ -1,6 +1,6 @@ package com.sun.android.data.source.local.api.sharedpref -object SharedPrefKey { +object SharedPrefsKey { const val PREFS_NAME = "WSMCompanySharedPreference" const val KEY_TOKEN = "KEY_TOKEN" } 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..5cd8329 --- /dev/null +++ b/app/src/main/java/com/sun/android/di/AppModule.kt @@ -0,0 +1,52 @@ +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.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() } +} + +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() +} 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..bffa7be --- /dev/null +++ b/app/src/main/java/com/sun/android/di/ViewModelModule.kt @@ -0,0 +1,10 @@ +package com.sun.android.di + +import com.sun.android.ui.MainViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.module.Module +import org.koin.dsl.module + +val ViewModelModule: Module = module { + viewModel { MainViewModel() } +} diff --git a/app/src/main/java/com/sun/android/ui/MainActivity.kt b/app/src/main/java/com/sun/android/ui/MainActivity.kt index 772b43b..466b04d 100644 --- a/app/src/main/java/com/sun/android/ui/MainActivity.kt +++ b/app/src/main/java/com/sun/android/ui/MainActivity.kt @@ -2,10 +2,11 @@ package com.sun.android.ui import com.sun.android.base.BaseActivity import com.sun.android.databinding.ActivityMainBinding +import org.koin.androidx.viewmodel.ext.android.viewModel class MainActivity : BaseActivity(ActivityMainBinding::inflate) { - override val viewModel get() = MainViewModel() + override val viewModel: MainViewModel by viewModel() override fun initialize() { binding.txtTest.text = "ABC" 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/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/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 270754f..e113f99 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -86,10 +86,9 @@ object Deps { 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_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}" + // Koin + const val koin_ext = "org.koin:koin-androidx-ext:${Versions.koin}" + const val koin_viewmodel = "org.koin:koin-androidx-viewmodel:${Versions.koin}" // Testing const val junit = "junit:junit:${Versions.jUnit}" From a5f6639d1cf9d8ade39560ee5f3677b9f427177e Mon Sep 17 00:00:00 2001 From: daolq3012 Date: Fri, 18 Feb 2022 15:02:54 +0700 Subject: [PATCH 04/10] [Add] Implement feature List Movie --- .idea/deploymentTargetDropDown.xml | 17 ++++ .idea/misc.xml | 4 + app/build.gradle.kts | 5 +- app/src/main/AndroidManifest.xml | 2 + .../java/com/sun/android/base/BaseActivity.kt | 7 -- .../java/com/sun/android/base/BaseFragment.kt | 5 -- .../com/sun/android/base/BaseRepository.kt | 36 ++++++++ .../com/sun/android/base/BaseViewModel.kt | 50 +++++++++-- .../com/sun/android/data/MovieRepository.kt | 5 +- .../sun/android/data/di/DataSourceModule.kt | 4 + .../com/sun/android/data/di/NetWorkModule.kt | 4 +- .../sun/android/data/di/RepositoryModule.kt | 10 +++ .../java/com/sun/android/data/model/Movie.kt | 30 ++++++- .../data/repository/MovieRepositoryImpl.kt | 18 ++-- .../android/data/source/MovieDataSource.kt | 2 +- .../data/source/remote/MovieRemoteImpl.kt | 5 +- .../data/source/remote/api/ApiService.kt | 13 ++- .../source/remote/api/error/ErrorResponse.kt | 4 + .../remote/api/error/RetrofitException.kt | 7 +- .../remote/api/response/BaseResponse.kt | 7 +- .../com/sun/android/di/ViewModelModule.kt | 4 +- .../java/com/sun/android/ui/MainActivity.kt | 12 ++- .../java/com/sun/android/ui/MainViewModel.kt | 20 ++++- .../android/ui/listmovie/MoviesFragment.kt | 13 +++ .../android/ui/listmovie/MoviesViewModel.kt | 5 ++ .../ui/listmovie/adapter/MoviesAdapter.kt | 83 +++++++++++++++++++ .../java/com/sun/android/utils/Constant.kt | 6 ++ .../java/com/sun/android/utils/DataResult.kt | 28 +++++++ .../utils/extension/ImageViewExtention.kt | 18 ++++ .../android/utils/livedata/SafeObserver.kt | 9 ++ .../android/utils/livedata/SingleLiveData.kt | 81 ++++++++++++++++++ .../OnItemRecyclerViewClickListener.kt | 5 ++ app/src/main/res/drawable/ic_corona.xml | 5 ++ app/src/main/res/drawable/ic_star.xml | 5 ++ app/src/main/res/layout/activity_main.xml | 16 +--- app/src/main/res/layout/item_layout_movie.xml | 69 +++++++++++++++ app/src/main/res/layout/movies_fragment.xml | 16 ++++ app/src/main/res/values/colors.xml | 6 +- build.gradle.kts | 1 + buildSrc/src/main/kotlin/Config.kt | 1 + 40 files changed, 578 insertions(+), 60 deletions(-) create mode 100644 .idea/deploymentTargetDropDown.xml create mode 100644 app/src/main/java/com/sun/android/base/BaseRepository.kt create mode 100644 app/src/main/java/com/sun/android/ui/listmovie/MoviesFragment.kt create mode 100644 app/src/main/java/com/sun/android/ui/listmovie/MoviesViewModel.kt create mode 100644 app/src/main/java/com/sun/android/ui/listmovie/adapter/MoviesAdapter.kt create mode 100644 app/src/main/java/com/sun/android/utils/Constant.kt create mode 100644 app/src/main/java/com/sun/android/utils/DataResult.kt create mode 100644 app/src/main/java/com/sun/android/utils/extension/ImageViewExtention.kt create mode 100644 app/src/main/java/com/sun/android/utils/livedata/SafeObserver.kt create mode 100644 app/src/main/java/com/sun/android/utils/livedata/SingleLiveData.kt create mode 100644 app/src/main/java/com/sun/android/utils/recycler/OnItemRecyclerViewClickListener.kt create mode 100644 app/src/main/res/drawable/ic_corona.xml create mode 100644 app/src/main/res/drawable/ic_star.xml create mode 100644 app/src/main/res/layout/item_layout_movie.xml create mode 100644 app/src/main/res/layout/movies_fragment.xml diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..c2412a2 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index e8174b0..6ba44e3 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,9 +3,13 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 551f2b1..1c24fcb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id(Plugins.android_application) kotlin(Plugins.kotlin_android) + id(Plugins.kotlin_parcelize) id(Plugins.detekt).version(Versions.detekt) } @@ -27,11 +28,11 @@ android { create("dev") { applicationIdSuffix = ".dev" resValue("string", "app_name", "Structure-Dev") - buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"") + buildConfigField("String", "BASE_URL", "\"https://api.themoviedb.org/3/\"") } create("prd") { resValue("string", "app_name", "Structure") - buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"") + buildConfigField("String", "BASE_URL", "\"https://api.themoviedb.org/3/\"") versionCode = AppConfigs.version_code_release versionName = AppConfigs.version_name_release } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f13eece..9b9b8a2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + = (LayoutInflater) -> T @@ -21,11 +19,6 @@ abstract class BaseActivity(private val inflate: ActivityInfla super.onCreate(savedInstanceState) _binding = inflate.invoke(layoutInflater) setContentView(binding.root) - - viewModel.error.observe(this, Observer { - Toast.makeText(this, it, Toast.LENGTH_SHORT).show() - }) - initialize() } diff --git a/app/src/main/java/com/sun/android/base/BaseFragment.kt b/app/src/main/java/com/sun/android/base/BaseFragment.kt index 74fa74a..7167ff4 100644 --- a/app/src/main/java/com/sun/android/base/BaseFragment.kt +++ b/app/src/main/java/com/sun/android/base/BaseFragment.kt @@ -5,9 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import androidx.lifecycle.Observer import androidx.viewbinding.ViewBinding -import com.sun.android.utils.showToast typealias FragmentInflate = (LayoutInflater, ViewGroup?, Boolean) -> T @@ -29,9 +27,6 @@ abstract class BaseFragment(private val inflate: FragmentInfla override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.error.observe(viewLifecycleOwner, Observer { - view.context.showToast(it) - }) initialize() } diff --git a/app/src/main/java/com/sun/android/base/BaseRepository.kt b/app/src/main/java/com/sun/android/base/BaseRepository.kt new file mode 100644 index 0000000..1e986ef --- /dev/null +++ b/app/src/main/java/com/sun/android/base/BaseRepository.kt @@ -0,0 +1,36 @@ +package com.sun.android.base + +import com.sun.android.utils.DataResult +import com.sun.android.utils.dispatchers.BaseDispatcherProvider +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withContext +import org.koin.core.KoinComponent +import org.koin.core.get + +abstract class BaseRepository : KoinComponent { + + private val dispatchersProvider = get() + + /** + * Make template code to get DataResult return to ViewModel + * Support for call api, get data from database + * Handle exceptions: Convert exception to Result.Error + * Avoid duplicate code + * + * Default CoroutineContext is IO for repository + */ + + protected suspend fun withResultContext( + context: CoroutineContext = dispatchersProvider.io(), + requestBlock: suspend CoroutineScope.() -> R + ): DataResult = withContext(context) { + return@withContext try { + val response = requestBlock() + DataResult.Success(response) + } catch (e: Exception) { + e.printStackTrace() + DataResult.Error(e) + } + } +} diff --git a/app/src/main/java/com/sun/android/base/BaseViewModel.kt b/app/src/main/java/com/sun/android/base/BaseViewModel.kt index 29b20e8..fa81712 100644 --- a/app/src/main/java/com/sun/android/base/BaseViewModel.kt +++ b/app/src/main/java/com/sun/android/base/BaseViewModel.kt @@ -1,11 +1,51 @@ package com.sun.android.base -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sun.android.data.source.remote.api.error.ErrorResponse +import com.sun.android.utils.DataResult +import com.sun.android.utils.livedata.SingleLiveData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch open class BaseViewModel : ViewModel() { - private val _error = MutableLiveData() - val error: LiveData - get() = _error + val isLoading = SingleLiveData() + val errorResponse = SingleLiveData() + private var loadingCount = 0 + + protected fun launchTaskSync( + onRequest: suspend CoroutineScope.() -> DataResult, + onSuccess: (T) -> Unit = {}, + onError: (Exception) -> Unit = {}, + isShowLoading: Boolean = true + ) = viewModelScope.launch { + + showLoading(isShowLoading) + when (val asynchronousTasks = onRequest(this)) { + is DataResult.Success -> onSuccess(asynchronousTasks.data) + is DataResult.Error -> { + onError(asynchronousTasks.exception) + ErrorResponse.convertToRetrofitException(asynchronousTasks.exception).run { + getErrorResponse()?.let { errorResponse.value = it } + } + } + } + hideLoading(isShowLoading) + } + + private fun showLoading(isShowLoading: Boolean) { + if (!isShowLoading) return + loadingCount++ + if (isLoading.value != true) isLoading.value = true + } + + private fun hideLoading(isShowLoading: Boolean) { + if (!isShowLoading) return + loadingCount-- + if (loadingCount <= 0) { + // reset loadingCount + loadingCount = 0 + isLoading.value = false + } + } } diff --git a/app/src/main/java/com/sun/android/data/MovieRepository.kt b/app/src/main/java/com/sun/android/data/MovieRepository.kt index 44c97cb..a79b4ef 100644 --- a/app/src/main/java/com/sun/android/data/MovieRepository.kt +++ b/app/src/main/java/com/sun/android/data/MovieRepository.kt @@ -1,5 +1,8 @@ package com.sun.android.data +import com.sun.android.data.model.Movie +import com.sun.android.utils.DataResult + interface MovieRepository { - fun getMovies() + suspend fun getMovies(): DataResult> } diff --git a/app/src/main/java/com/sun/android/data/di/DataSourceModule.kt b/app/src/main/java/com/sun/android/data/di/DataSourceModule.kt index 2447ac2..72ec159 100644 --- a/app/src/main/java/com/sun/android/data/di/DataSourceModule.kt +++ b/app/src/main/java/com/sun/android/data/di/DataSourceModule.kt @@ -1,9 +1,13 @@ package com.sun.android.data.di +import com.sun.android.data.source.MovieDataSource import com.sun.android.data.source.TokenDataSource 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()) } } diff --git a/app/src/main/java/com/sun/android/data/di/NetWorkModule.kt b/app/src/main/java/com/sun/android/data/di/NetWorkModule.kt index 130be0c..8d88e70 100644 --- a/app/src/main/java/com/sun/android/data/di/NetWorkModule.kt +++ b/app/src/main/java/com/sun/android/data/di/NetWorkModule.kt @@ -28,7 +28,7 @@ val NetworkModule = module { } fun provideOkHttpCache(app: Application): Cache { - val cacheSize: Long = 10 * 1024 * 1024 // 10MB + val cacheSize: Long = NetWorkInstant.CACHE_SIZE return Cache(app.cacheDir, cacheSize) } @@ -76,4 +76,6 @@ 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/data/di/RepositoryModule.kt b/app/src/main/java/com/sun/android/data/di/RepositoryModule.kt index 5b2f43e..730438b 100644 --- a/app/src/main/java/com/sun/android/data/di/RepositoryModule.kt +++ b/app/src/main/java/com/sun/android/data/di/RepositoryModule.kt @@ -1,14 +1,24 @@ package com.sun.android.data.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 com.sun.android.data.source.remote.MovieRemoteImpl import org.koin.dsl.module val RepositoryModule = module { single { provideTokenRepository(get()) } + + single { provideMovieRepository(MovieRemoteImpl(get())) } } fun provideTokenRepository(local: TokenDataSource.Local): TokenRepository { return TokenRepositoryImpl(local) } + +fun provideMovieRepository(remote: MovieDataSource.Remote): MovieRepository { + return MovieRepositoryImpl(remote) +} 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 index a5539aa..80419c4 100644 --- a/app/src/main/java/com/sun/android/data/model/Movie.kt +++ b/app/src/main/java/com/sun/android/data/model/Movie.kt @@ -1,3 +1,31 @@ package com.sun.android.data.model -class Movie +import android.os.Parcelable +import com.google.gson.annotations.Expose +import com.google.gson.annotations.SerializedName +import kotlinx.android.parcel.Parcelize + +@Parcelize +data class Movie( + @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 index 49babda..8e576b9 100644 --- a/app/src/main/java/com/sun/android/data/repository/MovieRepositoryImpl.kt +++ b/app/src/main/java/com/sun/android/data/repository/MovieRepositoryImpl.kt @@ -1,18 +1,14 @@ package com.sun.android.data.repository +import com.sun.android.base.BaseRepository +import com.sun.android.data.MovieRepository import com.sun.android.data.source.MovieDataSource -class MovieRepositoryImpl private constructor( - private val remote: MovieDataSource.Remote, - private val local: MovieDataSource.Local -) { +class MovieRepositoryImpl constructor( + private val remote: MovieDataSource.Remote +) : BaseRepository(), MovieRepository { - companion object { - private var instance: MovieRepositoryImpl? = null - - fun getInstance(remote: MovieDataSource.Remote, local: MovieDataSource.Local) = - synchronized(this) { - instance ?: MovieRepositoryImpl(remote, local).also { instance = it } - } + override suspend fun getMovies() = withResultContext { + remote.getMovies().data } } 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 index 9369aa4..db058ae 100644 --- a/app/src/main/java/com/sun/android/data/source/MovieDataSource.kt +++ b/app/src/main/java/com/sun/android/data/source/MovieDataSource.kt @@ -15,6 +15,6 @@ interface MovieDataSource { * Remote */ interface Remote { - fun getMovies(): BaseResponse> + suspend fun getMovies(): BaseResponse> } } 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 index 50a3238..c2fd75d 100644 --- 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 @@ -4,9 +4,10 @@ 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 +import com.sun.android.utils.Constant class MovieRemoteImpl(private val apiService: ApiService) : MovieDataSource.Remote { - override fun getMovies(): BaseResponse> { - TODO("Not yet implemented") + override suspend fun getMovies(): BaseResponse> { + return apiService.getTopRateMovies(Constant.BASE_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 index 7da5c70..942dfca 100644 --- 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 @@ -1,3 +1,14 @@ package com.sun.android.data.source.remote.api -interface ApiService +import com.sun.android.data.model.Movie +import com.sun.android.data.source.remote.api.response.BaseResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface ApiService { + @GET("movie/top_rated?") + suspend fun getTopRateMovies( + @Query("api_key") apiKey: String?, + @Query("page") page: Int = 1 + ): BaseResponse> +} 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 index d027e9d..555dd8d 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -52,6 +53,9 @@ data class ErrorResponse( } catch (e: ParseException) { LogUtils.e(TAG, e.message.toString()) RetrofitException.toUnexpectedError(throwable) + } catch (e: JsonSyntaxException) { + LogUtils.e(TAG, e.message.toString()) + 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 index c5f991b..b5455ca 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -62,15 +63,15 @@ class RetrofitException : RuntimeException { } private fun Int.getHttpErrorMessage(): String { - if (this in 300..308) { + 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 400..451) { + 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 500..511) { + if (this in HttpURLConnection.HTTP_INTERNAL_ERROR..HttpURLConnection.HTTP_VERSION) { // Server error return "A server error occurred. Please try again later!" } 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 index 22f250a..3017d14 100644 --- 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 @@ -10,7 +10,10 @@ data class BaseResponse( @SerializedName("messages") @Expose val message: String, - @SerializedName("data") + @SerializedName("results") @Expose - var data: T + var data: T, + @SerializedName("page") + @Expose + var page: Int ) diff --git a/app/src/main/java/com/sun/android/di/ViewModelModule.kt b/app/src/main/java/com/sun/android/di/ViewModelModule.kt index bffa7be..ee73643 100644 --- a/app/src/main/java/com/sun/android/di/ViewModelModule.kt +++ b/app/src/main/java/com/sun/android/di/ViewModelModule.kt @@ -1,10 +1,12 @@ package com.sun.android.di import com.sun.android.ui.MainViewModel +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 { MainViewModel(get()) } + viewModel { MoviesViewModel() } } diff --git a/app/src/main/java/com/sun/android/ui/MainActivity.kt b/app/src/main/java/com/sun/android/ui/MainActivity.kt index 466b04d..5532c2b 100644 --- a/app/src/main/java/com/sun/android/ui/MainActivity.kt +++ b/app/src/main/java/com/sun/android/ui/MainActivity.kt @@ -1,5 +1,7 @@ package com.sun.android.ui +import android.util.Log +import androidx.lifecycle.Observer import com.sun.android.base.BaseActivity import com.sun.android.databinding.ActivityMainBinding import org.koin.androidx.viewmodel.ext.android.viewModel @@ -9,6 +11,14 @@ class MainActivity : BaseActivity(ActivityMainBinding::infl override val viewModel: MainViewModel by viewModel() override fun initialize() { - binding.txtTest.text = "ABC" + viewModel.requestTopRateMovies() + viewModel.movies.observe(this, Observer { + Log.d("1111", "" + it.size) + }) +// supportFragmentManager +// .beginTransaction() +// .addToBackStack(MoviesFragment::javaClass.name) +// .replace(R.id.layoutContainer, MoviesFragment.newInstance()) +// .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 index c1e52c0..dc87ed0 100644 --- a/app/src/main/java/com/sun/android/ui/MainViewModel.kt +++ b/app/src/main/java/com/sun/android/ui/MainViewModel.kt @@ -1,5 +1,23 @@ package com.sun.android.ui import com.sun.android.base.BaseViewModel +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 -class MainViewModel : BaseViewModel() +class MainViewModel(private val movieRepository: MovieRepository) : BaseViewModel() { + val movies = SingleLiveData>() + + fun requestTopRateMovies() { + launchTaskSync(onRequest = { + movieRepository.getMovies() + }, onSuccess = { + movies.value = it + LogUtils.e("QQQQQ", movies.toString()) + }, onError = { + LogUtils.e("QQQQQ", it.toString()) + // No-op + }) + } +} 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..68f8ea1 --- /dev/null +++ b/app/src/main/java/com/sun/android/ui/listmovie/MoviesFragment.kt @@ -0,0 +1,13 @@ +package com.sun.android.ui.listmovie + +import com.sun.android.base.BaseFragment +import com.sun.android.databinding.MoviesFragmentBinding +import org.koin.androidx.viewmodel.ext.android.viewModel + +class MoviesFragment : BaseFragment(MoviesFragmentBinding::inflate) { + + override val viewModel: MoviesViewModel by viewModel() + + override fun initialize() { + } +} 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..7309e78 --- /dev/null +++ b/app/src/main/java/com/sun/android/ui/listmovie/MoviesViewModel.kt @@ -0,0 +1,5 @@ +package com.sun.android.ui.listmovie + +import com.sun.android.base.BaseViewModel + +class MoviesViewModel : BaseViewModel() 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..2d57a92 --- /dev/null +++ b/app/src/main/java/com/sun/android/ui/listmovie/adapter/MoviesAdapter.kt @@ -0,0 +1,83 @@ +package com.sun.android.ui.listmovie.adapter + +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.sun.android.R +import com.sun.android.data.model.Movie +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 view = + LayoutInflater.from(parent.context).inflate(R.layout.item_layout_movie, parent, false) + return ViewHolder(view, 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: MutableList?) { + movies.notNull { + this.movies.clear() + this.movies.addAll(it) + notifyDataSetChanged() + } + } + + class ViewHolder( + itemView: View, + private val itemClickListener: OnItemRecyclerViewClickListener? + ) : RecyclerView.ViewHolder(itemView), View.OnClickListener { + private var mTextViewTitle: TextView? = null + private var mTextViewRatting: TextView? = null + private var mTextViewContent: TextView? = null + private var mImageViewIconMovie: ImageView? = null + + private var movieData: Movie? = null + private var listener: OnItemRecyclerViewClickListener? = null + + init { + mTextViewTitle = itemView.findViewById(R.id.textViewTitle) + mTextViewRatting = itemView.findViewById(R.id.textViewRatting) + mTextViewContent = itemView.findViewById(R.id.textViewContent) + mImageViewIconMovie = itemView.findViewById(R.id.imageMovie) + itemView.setOnClickListener(this) + listener = itemClickListener + } + + fun bindViewData(movie: Movie) { + movie.let { + mTextViewTitle?.text = it.title + mTextViewRatting?.text = it.vote.toString() + mTextViewContent?.text = it.originalTitle + mImageViewIconMovie?.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..73ecd20 --- /dev/null +++ b/app/src/main/java/com/sun/android/utils/Constant.kt @@ -0,0 +1,6 @@ +package com.sun.android.utils + +object Constant { + const val BASE_URL_IMAGE = "https://image.tmdb.org/t/p/w500" + const val BASE_API_KEY = "608dc3a0e4c39fe0b691a89554ec2b1f" +} diff --git a/app/src/main/java/com/sun/android/utils/DataResult.kt b/app/src/main/java/com/sun/android/utils/DataResult.kt new file mode 100644 index 0000000..1bd7473 --- /dev/null +++ b/app/src/main/java/com/sun/android/utils/DataResult.kt @@ -0,0 +1,28 @@ +package com.sun.android.utils + +/** + * A generic class that holds a value with its loading status. + * @param + */ +sealed class DataResult { + + data class Success(val data: T) : DataResult() + data class Error(val exception: Exception) : DataResult() + object Loading : DataResult() + + inline fun map(block: (R) -> M): DataResult { + return when (this) { + is Success -> Success(block(data)) + is Error -> Error(exception) + is Loading -> Loading + } + } + + override fun toString(): String { + return when (this) { + is Success<*> -> "Success[data=$data]" + is Error -> "Error[exception=$exception]" + Loading -> "Loading" + } + } +} 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..6e9721e --- /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.utils.Constant + +fun ImageView.loadImageCircleWithUrl(url: String) { + Glide.with(this) + .load(Constant.BASE_URL_IMAGE + url) + .circleCrop() + .into(this) +} + +fun ImageView.loadImageWithUrl(url: String) { + Glide.with(this) + .load(Constant.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..bc9f93b --- /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?) { + t?.let { 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..d66906f --- /dev/null +++ b/app/src/main/java/com/sun/android/utils/livedata/SingleLiveData.kt @@ -0,0 +1,81 @@ +/* + * 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/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 d791af3..5c0d6e8 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,7 @@ - - - - + tools:context=".ui.MainActivity"/> 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/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 e113f99..f5ce165 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -48,6 +48,7 @@ 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" } From a5c2e753f546f725ceb9e5f9b0a6c0ee4d1fefd3 Mon Sep 17 00:00:00 2001 From: daolq3012 Date: Sun, 20 Feb 2022 11:44:04 +0700 Subject: [PATCH 05/10] [Add] Implement list movie screen --- .../java/com/sun/android/base/BaseFragment.kt | 7 ++++-- .../com/sun/android/di/ViewModelModule.kt | 4 +-- .../java/com/sun/android/ui/MainActivity.kt | 18 ++++++------- .../java/com/sun/android/ui/MainViewModel.kt | 20 +-------------- .../android/ui/listmovie/MoviesFragment.kt | 25 +++++++++++++++++-- .../android/ui/listmovie/MoviesViewModel.kt | 20 ++++++++++++++- .../ui/listmovie/adapter/MoviesAdapter.kt | 2 +- 7 files changed, 58 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/sun/android/base/BaseFragment.kt b/app/src/main/java/com/sun/android/base/BaseFragment.kt index 7167ff4..03fc7b3 100644 --- a/app/src/main/java/com/sun/android/base/BaseFragment.kt +++ b/app/src/main/java/com/sun/android/base/BaseFragment.kt @@ -18,7 +18,9 @@ abstract class BaseFragment(private val inflate: FragmentInfla val binding get() = _binding!! abstract val viewModel: BaseViewModel - protected abstract fun initialize() + protected abstract fun initView() + + protected abstract fun initData() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { _binding = inflate.invoke(inflater, container, false) @@ -27,7 +29,8 @@ abstract class BaseFragment(private val inflate: FragmentInfla override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - initialize() + initView() + initData() } override fun onDestroyView() { diff --git a/app/src/main/java/com/sun/android/di/ViewModelModule.kt b/app/src/main/java/com/sun/android/di/ViewModelModule.kt index ee73643..a03329c 100644 --- a/app/src/main/java/com/sun/android/di/ViewModelModule.kt +++ b/app/src/main/java/com/sun/android/di/ViewModelModule.kt @@ -7,6 +7,6 @@ import org.koin.core.module.Module import org.koin.dsl.module val ViewModelModule: Module = module { - viewModel { MainViewModel(get()) } - viewModel { MoviesViewModel() } + viewModel { MainViewModel() } + viewModel { MoviesViewModel(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 index 5532c2b..5fa6e23 100644 --- a/app/src/main/java/com/sun/android/ui/MainActivity.kt +++ b/app/src/main/java/com/sun/android/ui/MainActivity.kt @@ -1,9 +1,9 @@ package com.sun.android.ui -import android.util.Log -import androidx.lifecycle.Observer +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) { @@ -11,14 +11,10 @@ class MainActivity : BaseActivity(ActivityMainBinding::infl override val viewModel: MainViewModel by viewModel() override fun initialize() { - viewModel.requestTopRateMovies() - viewModel.movies.observe(this, Observer { - Log.d("1111", "" + it.size) - }) -// supportFragmentManager -// .beginTransaction() -// .addToBackStack(MoviesFragment::javaClass.name) -// .replace(R.id.layoutContainer, MoviesFragment.newInstance()) -// .commit() + 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 index dc87ed0..b099896 100644 --- a/app/src/main/java/com/sun/android/ui/MainViewModel.kt +++ b/app/src/main/java/com/sun/android/ui/MainViewModel.kt @@ -1,23 +1,5 @@ package com.sun.android.ui import com.sun.android.base.BaseViewModel -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 -class MainViewModel(private val movieRepository: MovieRepository) : BaseViewModel() { - val movies = SingleLiveData>() - - fun requestTopRateMovies() { - launchTaskSync(onRequest = { - movieRepository.getMovies() - }, onSuccess = { - movies.value = it - LogUtils.e("QQQQQ", movies.toString()) - }, onError = { - LogUtils.e("QQQQQ", it.toString()) - // No-op - }) - } -} +class MainViewModel() : BaseViewModel() 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 index 68f8ea1..90c32b9 100644 --- a/app/src/main/java/com/sun/android/ui/listmovie/MoviesFragment.kt +++ b/app/src/main/java/com/sun/android/ui/listmovie/MoviesFragment.kt @@ -1,13 +1,34 @@ package com.sun.android.ui.listmovie +import androidx.lifecycle.Observer import com.sun.android.base.BaseFragment +import com.sun.android.data.model.Movie import com.sun.android.databinding.MoviesFragmentBinding +import com.sun.android.ui.listmovie.adapter.MoviesAdapter +import com.sun.android.utils.recycler.OnItemRecyclerViewClickListener import org.koin.androidx.viewmodel.ext.android.viewModel -class MoviesFragment : BaseFragment(MoviesFragmentBinding::inflate) { +class MoviesFragment : BaseFragment(MoviesFragmentBinding::inflate), + OnItemRecyclerViewClickListener { + + private val mMovieAdapter: MoviesAdapter by lazy { MoviesAdapter() } override val viewModel: MoviesViewModel by viewModel() - override fun initialize() { + override fun initView() { + binding.recyclerViewMovie.apply { + adapter = mMovieAdapter + } + mMovieAdapter.registerItemRecyclerViewClickListener(this) + } + + override fun initData() { + viewModel.requestTopRateMovies() + viewModel.movies.observe(this, Observer { movies -> + mMovieAdapter.updateData(movies) + }) + } + + override fun onItemClick(item: Movie?) { } } 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 index 7309e78..63eab7a 100644 --- a/app/src/main/java/com/sun/android/ui/listmovie/MoviesViewModel.kt +++ b/app/src/main/java/com/sun/android/ui/listmovie/MoviesViewModel.kt @@ -1,5 +1,23 @@ package com.sun.android.ui.listmovie import com.sun.android.base.BaseViewModel +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 -class MoviesViewModel : BaseViewModel() +class MoviesViewModel(private val movieRepository: MovieRepository) : BaseViewModel() { + val movies = SingleLiveData>() + + fun requestTopRateMovies() { + launchTaskSync(onRequest = { + movieRepository.getMovies() + }, onSuccess = { + movies.value = it + LogUtils.e("QQQQQ", movies.toString()) + }, onError = { + LogUtils.e("QQQQQ", it.toString()) + // No-op + }) + } +} 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 index 2d57a92..7a347bb 100644 --- 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 @@ -37,7 +37,7 @@ class MoviesAdapter : RecyclerView.Adapter() { onItemClickListener = onItemRecyclerViewClickListener } - fun updateData(movies: MutableList?) { + fun updateData(movies: List) { movies.notNull { this.movies.clear() this.movies.addAll(it) From b1db788306581de8238b586efeabddc98bb5423f Mon Sep 17 00:00:00 2001 From: daolq3012 Date: Sun, 20 Feb 2022 14:21:53 +0700 Subject: [PATCH 06/10] [Add] Implement movie details screen --- .idea/misc.xml | 2 + .../java/com/sun/android/base/BaseFragment.kt | 3 + .../com/sun/android/data/MovieRepository.kt | 2 + .../java/com/sun/android/data/model/Movie.kt | 3 + .../data/repository/MovieRepositoryImpl.kt | 4 + .../android/data/source/MovieDataSource.kt | 2 + .../data/source/remote/MovieRemoteImpl.kt | 6 +- .../data/source/remote/api/ApiService.kt | 7 + .../com/sun/android/di/ViewModelModule.kt | 2 + .../java/com/sun/android/ui/MainViewModel.kt | 2 +- .../sun/android/ui/detail/DetailFragment.kt | 45 ++++++ .../android/ui/detail/MovieDetailViewModel.kt | 19 +++ .../android/ui/listmovie/MoviesFragment.kt | 12 +- .../utils/extension/FragmentExtension.kt | 54 +++++++ app/src/main/res/anim/slide_in_right.xml | 10 ++ app/src/main/res/anim/slide_out_right.xml | 10 ++ app/src/main/res/drawable/ic_back.xml | 11 ++ app/src/main/res/layout/fragment_detail.xml | 151 ++++++++++++++++++ app/src/main/res/values/strings.xml | 2 + 19 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/sun/android/ui/detail/DetailFragment.kt create mode 100644 app/src/main/java/com/sun/android/ui/detail/MovieDetailViewModel.kt create mode 100644 app/src/main/java/com/sun/android/utils/extension/FragmentExtension.kt create mode 100644 app/src/main/res/anim/slide_in_right.xml create mode 100644 app/src/main/res/anim/slide_out_right.xml create mode 100644 app/src/main/res/drawable/ic_back.xml create mode 100644 app/src/main/res/layout/fragment_detail.xml diff --git a/.idea/misc.xml b/.idea/misc.xml index 6ba44e3..994ef5c 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,10 +3,12 @@