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