From 00a2fc7bd867658c6ee3d019a07232d73e391413 Mon Sep 17 00:00:00 2001 From: Arga Hutama Date: Thu, 11 Jun 2026 19:29:42 +0700 Subject: [PATCH 1/2] setup ktlint --- .editorconfig | 17 + .github/workflows/build.yml | 29 + .../com/argahutama/submission/made/App.kt | 71 +- .../submission/made/NavigationList.kt | 9 +- .../submission/made/detail/DetailActivity.kt | 107 +-- .../submission/made/detail/DetailViewModel.kt | 10 +- .../submission/made/di/AppModule.kt | 21 +- .../submission/made/main/MainActivity.kt | 82 ++- .../submission/made/main/MainViewModel.kt | 44 +- .../submission/made/movie/MovieFragment.kt | 20 +- .../submission/made/movie/MovieViewModel.kt | 7 +- .../submission/made/tvshow/TvShowFragment.kt | 20 +- .../submission/made/tvshow/TvShowViewModel.kt | 7 +- .../made/detail/DetailViewModelTest.kt | 1 - .../submission/made/main/MainViewModelTest.kt | 130 ++-- .../made/movie/MovieViewModelTest.kt | 59 +- .../made/tvshow/TvShowViewModelTest.kt | 64 +- build.gradle | 5 + .../submission/core/base/BaseActivity.kt | 19 +- .../submission/core/base/BaseApp.kt | 10 +- .../submission/core/base/BaseFragment.kt | 27 +- .../submission/core/base/BaseViewModel.kt | 2 +- .../submission/core/data/MovieRepository.kt | 27 +- .../core/data/NetworkBoundResource.kt | 42 +- .../submission/core/data/Resource.kt | 4 +- .../core/data/source/local/LocalDataSource.kt | 21 +- .../core/data/source/local/room/MovieDao.kt | 9 +- .../core/data/source/local/room/MovieDb.kt | 2 +- .../data/source/remote/RemoteDataSource.kt | 50 +- .../data/source/remote/network/ApiResponse.kt | 4 +- .../data/source/remote/network/ApiService.kt | 10 +- .../source/remote/response/MovieResponse.kt | 2 +- .../source/remote/response/MoviesResponse.kt | 4 +- .../source/remote/response/TvShowResponse.kt | 2 +- .../source/remote/response/TvShowsResponse.kt | 4 +- .../submission/core/di/CoreModule.kt | 86 +-- .../submission/core/domain/model/Movie.kt | 2 +- .../domain/repository/IMovieRepository.kt | 7 +- .../core/domain/usecase/MovieInteractor.kt | 14 +- .../core/domain/usecase/MovieUseCase.kt | 7 +- .../core/navigation/NavigationDirection.kt | 3 +- .../submission/core/ui/MovieAdapter.kt | 46 +- .../submission/core/util/AppExecutors.kt | 10 +- .../submission/core/util/DataMapper.kt | 114 ++-- .../util/{DiffUtil.kt => DifferenceUtil.kt} | 51 +- .../submission/core/util/SortUtil.kt | 2 +- .../core/data/MovieRepositoryTest.kt | 286 +++++---- .../data/source/local/LocalDataSourceTest.kt | 129 ++-- .../source/remote/RemoteDataSourceTest.kt | 133 ++-- .../domain/usecase/MovieInteractorTest.kt | 12 +- .../submission/core/util/DataMapperTest.kt | 76 ++- custom-ui/.editorconfig | 3 + .../submission/custom_ui/CustomCardView.kt | 154 +++-- .../submission/custom_ui/CustomSnack.kt | 68 +- .../submission/custom_ui/CustomTextField.kt | 607 +++++++++--------- .../submission/custom_ui/CustomTextView.kt | 137 ++-- .../submission/favorite/FavoriteFragment.kt | 12 +- .../submission/favorite/FavoriteViewModel.kt | 15 +- .../favorite/adapter/SectionPagerAdapter.kt | 11 +- .../submission/favorite/di/FavoriteModule.kt | 9 +- .../favorite/movie/FavoriteMovieFragment.kt | 62 +- .../favorite/tvshow/FavoriteTvShowFragment.kt | 62 +- .../favorite/FavoriteViewModelTest.kt | 78 ++- gradle/libs.versions.toml | 2 + 64 files changed, 1760 insertions(+), 1380 deletions(-) create mode 100644 .editorconfig rename core/src/main/java/com/argahutama/submission/core/util/{DiffUtil.kt => DifferenceUtil.kt} (50%) create mode 100644 custom-ui/.editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d70d1ed --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +max_line_length = 120 +ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^ +ktlint_standard_no-wildcard-imports = enabled +ktlint_standard_filename = enabled +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9477a48..e18ff0e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,8 +7,37 @@ on: branches: [ main ] jobs: + ktlint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run ktlint + run: ./gradlew ktlintCheck + build: runs-on: ubuntu-latest + needs: ktlint steps: - uses: actions/checkout@v4 diff --git a/app/src/main/java/com/argahutama/submission/made/App.kt b/app/src/main/java/com/argahutama/submission/made/App.kt index 048518b..b9c3513 100644 --- a/app/src/main/java/com/argahutama/submission/made/App.kt +++ b/app/src/main/java/com/argahutama/submission/made/App.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.Parcelable +import androidx.appcompat.app.AppCompatDelegate import com.argahutama.submission.core.base.BaseApp import com.argahutama.submission.core.di.dbModule import com.argahutama.submission.core.di.networkModule @@ -12,7 +13,6 @@ import com.argahutama.submission.core.di.repositoryModule import com.argahutama.submission.core.navigation.NavigationDirection import com.argahutama.submission.made.di.useCaseModule import com.argahutama.submission.made.di.viewModelModule -import androidx.appcompat.app.AppCompatDelegate import com.pluto.Pluto import com.pluto.plugins.network.PlutoNetworkPlugin import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -46,44 +46,53 @@ class App : BaseApp() { } } - override fun navigateTo(context: Context, direction: NavigationDirection) { + override fun navigateTo( + context: Context, + direction: NavigationDirection + ) { Intent(context, navigationMapper[direction::class.java]) .apply { direction.extras.forEach { putExtra(it) } } .also { context.startActivity(it) } } - override fun navigateTo(activity: Activity, direction: NavigationDirection, requestCode: Int) { + override fun navigateTo( + activity: Activity, + direction: NavigationDirection, + requestCode: Int + ) { Intent(activity, navigationMapper[direction::class.java]) .apply { direction.extras.forEach { putExtra(it) } } .also { activity.startActivityForResult(it, requestCode) } } - private fun Intent.putExtra(it: Map.Entry) = when (val value = it.value) { - is Int -> putExtra(it.key, value) - is Long -> putExtra(it.key, value) - is CharSequence -> putExtra(it.key, value) - is String -> putExtra(it.key, value) - is Float -> putExtra(it.key, value) - is Double -> putExtra(it.key, value) - is Char -> putExtra(it.key, value) - is Short -> putExtra(it.key, value) - is Boolean -> putExtra(it.key, value) - is Serializable -> putExtra(it.key, value) - is Bundle -> putExtra(it.key, value) - is Parcelable -> putExtra(it.key, value) - is Array<*> -> when { - value.isArrayOf() -> putExtra(it.key, value) - value.isArrayOf() -> putExtra(it.key, value) - value.isArrayOf() -> putExtra(it.key, value) - else -> throw Exception("Intent extra ${it.key} has wrong type ${value.javaClass.name}") + private fun Intent.putExtra(it: Map.Entry) = + when (val value = it.value) { + is Int -> putExtra(it.key, value) + is Long -> putExtra(it.key, value) + is String -> putExtra(it.key, value) + is CharSequence -> putExtra(it.key, value) + is Float -> putExtra(it.key, value) + is Double -> putExtra(it.key, value) + is Char -> putExtra(it.key, value) + is Short -> putExtra(it.key, value) + is Boolean -> putExtra(it.key, value) + is Serializable -> putExtra(it.key, value) + is Bundle -> putExtra(it.key, value) + is Parcelable -> putExtra(it.key, value) + is Array<*> -> + when { + value.isArrayOf() -> putExtra(it.key, value) + value.isArrayOf() -> putExtra(it.key, value) + value.isArrayOf() -> putExtra(it.key, value) + else -> throw Exception("Intent extra ${it.key} has wrong type ${value.javaClass.name}") + } + is IntArray -> putExtra(it.key, value) + is LongArray -> putExtra(it.key, value) + is FloatArray -> putExtra(it.key, value) + is DoubleArray -> putExtra(it.key, value) + is CharArray -> putExtra(it.key, value) + is ShortArray -> putExtra(it.key, value) + is BooleanArray -> putExtra(it.key, value) + else -> throw Exception("Intent extra ${it.key} has wrong type ${value?.javaClass?.name}") } - is IntArray -> putExtra(it.key, value) - is LongArray -> putExtra(it.key, value) - is FloatArray -> putExtra(it.key, value) - is DoubleArray -> putExtra(it.key, value) - is CharArray -> putExtra(it.key, value) - is ShortArray -> putExtra(it.key, value) - is BooleanArray -> putExtra(it.key, value) - else -> throw Exception("Intent extra ${it.key} has wrong type ${value?.javaClass?.name}") - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/argahutama/submission/made/NavigationList.kt b/app/src/main/java/com/argahutama/submission/made/NavigationList.kt index fa95401..c4978b9 100644 --- a/app/src/main/java/com/argahutama/submission/made/NavigationList.kt +++ b/app/src/main/java/com/argahutama/submission/made/NavigationList.kt @@ -8,7 +8,8 @@ import kotlinx.coroutines.FlowPreview @ExperimentalCoroutinesApi @FlowPreview -val navigationMapper = mapOf( - NavigationDirection.Main::class.java to MainActivity::class.java, - NavigationDirection.Detail::class.java to DetailActivity::class.java -) \ No newline at end of file +val navigationMapper = + mapOf( + NavigationDirection.Main::class.java to MainActivity::class.java, + NavigationDirection.Detail::class.java to DetailActivity::class.java + ) diff --git a/app/src/main/java/com/argahutama/submission/made/detail/DetailActivity.kt b/app/src/main/java/com/argahutama/submission/made/detail/DetailActivity.kt index 7f14211..105633f 100644 --- a/app/src/main/java/com/argahutama/submission/made/detail/DetailActivity.kt +++ b/app/src/main/java/com/argahutama/submission/made/detail/DetailActivity.kt @@ -2,19 +2,19 @@ package com.argahutama.submission.made.detail import android.view.ViewGroup import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import com.argahutama.submission.core.base.BaseActivity import com.argahutama.submission.core.domain.model.Movie -import androidx.core.content.IntentCompat import com.argahutama.submission.core.navigation.Extra import com.argahutama.submission.core.util.GlideListener -import com.argahutama.submission.core.R as CoreR import com.argahutama.submission.made.R import com.argahutama.submission.made.databinding.ActivityDetailBinding import com.bumptech.glide.Glide import org.koin.androidx.viewmodel.ext.android.viewModel +import com.argahutama.submission.core.R as CoreR class DetailActivity : BaseActivity() { private var movie: Movie? = null @@ -27,57 +27,64 @@ class DetailActivity : BaseActivity() { applyInsets() } - override fun initView() = with(binding) { - ctvTitleDetail.text = movie?.title.orEmpty() - ctvDate.text = movie?.releaseDate - ctvOverview.text = movie?.overview - ctvPopularity.text = getString( - R.string.popularity_detail, - movie?.popularity.toString(), - movie?.voteCount.toString(), - movie?.voteAverage.toString() - ) - ctvRating.text = movie?.voteAverage.toString() + override fun initView() = + with(binding) { + ctvTitleDetail.text = movie?.title.orEmpty() + ctvDate.text = movie?.releaseDate + ctvOverview.text = movie?.overview + ctvPopularity.text = + getString( + R.string.popularity_detail, + movie?.popularity.toString(), + movie?.voteCount.toString(), + movie?.voteAverage.toString() + ) + ctvRating.text = movie?.voteAverage.toString() - Glide.with(this@DetailActivity) - .load(getString(CoreR.string.base_image_url, movie?.posterPath)) - .listener(GlideListener(ivPosterTopBar, shimmerivPosterTopBar)) - .into(ivPosterTopBar) - ivPosterTopBar.tag = movie?.posterPath.orEmpty() + Glide.with(this@DetailActivity) + .load(getString(CoreR.string.base_image_url, movie?.posterPath)) + .listener(GlideListener(ivPosterTopBar, shimmerivPosterTopBar)) + .into(ivPosterTopBar) + ivPosterTopBar.tag = movie?.posterPath.orEmpty() - Glide.with(this@DetailActivity) - .load(getString(CoreR.string.base_image_url, movie?.posterPath)) - .listener(GlideListener(sivSubPoster, shimmerSubPoster)) - .into(sivSubPoster) - sivSubPoster.tag = movie?.posterPath - } + Glide.with(this@DetailActivity) + .load(getString(CoreR.string.base_image_url, movie?.posterPath)) + .listener(GlideListener(sivSubPoster, shimmerSubPoster)) + .into(sivSubPoster) + sivSubPoster.tag = movie?.posterPath + } - override fun initAction() = with(binding) { - setFavorite(movie?.favorite!!, true) - sivFavorite.setOnClickListener { setFavorite(!movie?.favorite!!) } - ivBackButton.setOnClickListener { finish() } - } + override fun initAction() = + with(binding) { + setFavorite(movie?.favorite!!, true) + sivFavorite.setOnClickListener { setFavorite(!movie?.favorite!!) } + ivBackButton.setOnClickListener { finish() } + } private fun loadArgs() { movie = IntentCompat.getParcelableExtra(intent, Extra.MOVIE, Movie::class.java) } - private fun applyInsets() = with(binding) { - ViewCompat.setOnApplyWindowInsetsListener(ivBackButton) { v, insets -> - val statusBarHeight = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top - val margin16dp = (16 * v.resources.displayMetrics.density).toInt() - (v.layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin = statusBarHeight + margin16dp - v.requestLayout() - insets - } - ViewCompat.setOnApplyWindowInsetsListener(nestedScrollView) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.updatePadding(bottom = systemBars.bottom) - insets + private fun applyInsets() = + with(binding) { + ViewCompat.setOnApplyWindowInsetsListener(ivBackButton) { v, insets -> + val statusBarHeight = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top + val margin16dp = (16 * v.resources.displayMetrics.density).toInt() + (v.layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin = statusBarHeight + margin16dp + v.requestLayout() + insets + } + ViewCompat.setOnApplyWindowInsetsListener(nestedScrollView) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding(bottom = systemBars.bottom) + insets + } } - } - private fun ActivityDetailBinding.setFavorite(newState: Boolean, isInitial: Boolean = false) { + private fun ActivityDetailBinding.setFavorite( + newState: Boolean, + isInitial: Boolean = false + ) { if (!isInitial) { movie?.favorite = newState val message = if (newState) R.string.set_favorite else R.string.set_unfavorite @@ -85,10 +92,14 @@ class DetailActivity : BaseActivity() { if (movie != null) viewModel.setFavoriteMovie(movie!!, newState) } - if (newState) sivFavorite.setImageDrawable( - ContextCompat.getDrawable(this@DetailActivity, R.drawable.ic_favorite_selected) - ) else sivFavorite.setImageDrawable( - ContextCompat.getDrawable(this@DetailActivity, R.drawable.ic_favorite_unselected) - ) + if (newState) { + sivFavorite.setImageDrawable( + ContextCompat.getDrawable(this@DetailActivity, R.drawable.ic_favorite_selected) + ) + } else { + sivFavorite.setImageDrawable( + ContextCompat.getDrawable(this@DetailActivity, R.drawable.ic_favorite_unselected) + ) + } } } diff --git a/app/src/main/java/com/argahutama/submission/made/detail/DetailViewModel.kt b/app/src/main/java/com/argahutama/submission/made/detail/DetailViewModel.kt index 029432d..98b89cd 100644 --- a/app/src/main/java/com/argahutama/submission/made/detail/DetailViewModel.kt +++ b/app/src/main/java/com/argahutama/submission/made/detail/DetailViewModel.kt @@ -4,7 +4,9 @@ import com.argahutama.submission.core.base.BaseViewModel import com.argahutama.submission.core.domain.model.Movie import com.argahutama.submission.core.domain.usecase.MovieUseCase -class DetailViewModel(private val movieUseCase: MovieUseCase): BaseViewModel() { - fun setFavoriteMovie(movie: Movie, newStatus: Boolean) = - movieUseCase.setMovieFavorite(movie, newStatus) -} \ No newline at end of file +class DetailViewModel(private val movieUseCase: MovieUseCase) : BaseViewModel() { + fun setFavoriteMovie( + movie: Movie, + newStatus: Boolean + ) = movieUseCase.setMovieFavorite(movie, newStatus) +} diff --git a/app/src/main/java/com/argahutama/submission/made/di/AppModule.kt b/app/src/main/java/com/argahutama/submission/made/di/AppModule.kt index d7fe3f6..bec7423 100644 --- a/app/src/main/java/com/argahutama/submission/made/di/AppModule.kt +++ b/app/src/main/java/com/argahutama/submission/made/di/AppModule.kt @@ -11,16 +11,17 @@ import kotlinx.coroutines.FlowPreview import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module - -val useCaseModule = module { - factory { MovieInteractor(get()) } -} +val useCaseModule = + module { + factory { MovieInteractor(get()) } + } @ExperimentalCoroutinesApi @FlowPreview -val viewModelModule = module { - viewModel { MovieViewModel(get()) } - viewModel { TvShowViewModel(get()) } - viewModel { DetailViewModel(get()) } - viewModel { MainViewModel(get()) } -} \ No newline at end of file +val viewModelModule = + module { + viewModel { MovieViewModel(get()) } + viewModel { TvShowViewModel(get()) } + viewModel { DetailViewModel(get()) } + viewModel { MainViewModel(get()) } + } diff --git a/app/src/main/java/com/argahutama/submission/made/main/MainActivity.kt b/app/src/main/java/com/argahutama/submission/made/main/MainActivity.kt index c1ca8c8..ab0328f 100644 --- a/app/src/main/java/com/argahutama/submission/made/main/MainActivity.kt +++ b/app/src/main/java/com/argahutama/submission/made/main/MainActivity.kt @@ -16,7 +16,11 @@ import com.argahutama.submission.made.R import com.argahutama.submission.made.databinding.ActivityMainBinding import com.argahutama.submission.made.movie.MovieFragment import com.argahutama.submission.made.tvshow.TvShowFragment -import kotlinx.coroutines.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel @FlowPreview @@ -44,34 +48,40 @@ class MainActivity : BaseActivity() { } } - override fun initView() = with(binding) { - bnvMainMenu.run { - selectedItemId = R.id.main_menu_movies - selectMenu(R.id.main_menu_movies) - itemIconTintList = null + override fun initView() = + with(binding) { + bnvMainMenu.run { + selectedItemId = R.id.main_menu_movies + selectMenu(R.id.main_menu_movies) + itemIconTintList = null + } } - } - override fun initAction() = with(binding) { - bnvMainMenu.setOnItemSelectedListener(null) - bnvMainMenu.setOnItemSelectedListener { selectMenu(it.itemId) } - onBackPressedDispatcher.addCallback(this@MainActivity, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (!backPressed) { - backPressed = true - backPressJob?.cancel() - backPressJob = lifecycleScope.launch { - delay(2000) - backPressed = false + override fun initAction() = + with(binding) { + bnvMainMenu.setOnItemSelectedListener(null) + bnvMainMenu.setOnItemSelectedListener { selectMenu(it.itemId) } + onBackPressedDispatcher.addCallback( + this@MainActivity, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (!backPressed) { + backPressed = true + backPressJob?.cancel() + backPressJob = + lifecycleScope.launch { + delay(2000) + backPressed = false + } + showSnackbar(getString(R.string.press_back_to_exit), CustomSnack.WARNING) + } else { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } } - showSnackbar(getString(R.string.press_back_to_exit), CustomSnack.WARNING) - } else { - isEnabled = false - onBackPressedDispatcher.onBackPressed() } - } - }) - } + ) + } private fun changeNavigation(fragment: Fragment) { supportFragmentManager @@ -81,10 +91,11 @@ class MainActivity : BaseActivity() { .commit() } - private fun initInitialMenu() = with(binding) { - val menuId = visibleMenuId - bnvMainMenu.selectedItemId = menuId ?: 0 - } + private fun initInitialMenu() = + with(binding) { + val menuId = visibleMenuId + bnvMainMenu.selectedItemId = menuId ?: 0 + } private val className: String get() = "com.argahutama.submission.favorite.FavoriteFragment" @@ -93,12 +104,13 @@ class MainActivity : BaseActivity() { if (fragment != null) changeNavigation(fragment) } - private fun instantiateFragment(className: String): Fragment? = try { - Class.forName(className).getDeclaredConstructor().newInstance() as Fragment - } catch (e: Exception) { - showSnackbar(e.cause?.message.orEmpty(), CustomSnack.FAILED) - null - } + private fun instantiateFragment(className: String): Fragment? = + try { + Class.forName(className).getDeclaredConstructor().newInstance() as Fragment + } catch (e: Exception) { + showSnackbar(e.cause?.message.orEmpty(), CustomSnack.FAILED) + null + } private fun selectMenu(menuId: Int): Boolean { if (visibleMenuId == menuId) return false diff --git a/app/src/main/java/com/argahutama/submission/made/main/MainViewModel.kt b/app/src/main/java/com/argahutama/submission/made/main/MainViewModel.kt index bd17ff6..4a004c7 100644 --- a/app/src/main/java/com/argahutama/submission/made/main/MainViewModel.kt +++ b/app/src/main/java/com/argahutama/submission/made/main/MainViewModel.kt @@ -22,23 +22,33 @@ class MainViewModel(private val movieUseCase: MovieUseCase) : BaseViewModel() { private val queryFlow = MutableStateFlow("") val searchQuery: StateFlow = queryFlow.asStateFlow() - fun setSearchQuery(search: String) { queryFlow.value = search } + fun setSearchQuery(search: String) { + queryFlow.value = search + } - val movieResult: StateFlow> = queryFlow - .debounce(300) - .distinctUntilChanged() - .flatMapLatest { query -> - if (query.trim().isEmpty()) flowOf(emptyList()) - else movieUseCase.searchMovies(query) - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val movieResult: StateFlow> = + queryFlow + .debounce(300) + .distinctUntilChanged() + .flatMapLatest { query -> + if (query.trim().isEmpty()) { + flowOf(emptyList()) + } else { + movieUseCase.searchMovies(query) + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - val tvShowResult: StateFlow> = queryFlow - .debounce(300) - .distinctUntilChanged() - .flatMapLatest { query -> - if (query.trim().isEmpty()) flowOf(emptyList()) - else movieUseCase.searchTvShows(query) - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val tvShowResult: StateFlow> = + queryFlow + .debounce(300) + .distinctUntilChanged() + .flatMapLatest { query -> + if (query.trim().isEmpty()) { + flowOf(emptyList()) + } else { + movieUseCase.searchTvShows(query) + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) } diff --git a/app/src/main/java/com/argahutama/submission/made/movie/MovieFragment.kt b/app/src/main/java/com/argahutama/submission/made/movie/MovieFragment.kt index 8e5775b..4061399 100644 --- a/app/src/main/java/com/argahutama/submission/made/movie/MovieFragment.kt +++ b/app/src/main/java/com/argahutama/submission/made/movie/MovieFragment.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.argahutama.submission.core.base.BaseFragment import com.argahutama.submission.core.data.Resource -import com.argahutama.submission.core.domain.model.Movie import com.argahutama.submission.core.navigation.NavigationDirection import com.argahutama.submission.core.ui.MovieAdapter import com.argahutama.submission.custom_ui.CustomSnack @@ -24,6 +23,7 @@ class MovieFragment : BaseFragment() { private val adapter by lazy { MovieAdapter() } override val viewModel by viewModel() private val mainViewModel: MainViewModel by viewModel() + override fun createBinding() = FragmentMovieBinding.inflate(layoutInflater) override fun setup() { @@ -65,14 +65,16 @@ class MovieFragment : BaseFragment() { } } - override fun initView() = with(binding as FragmentMovieBinding) { - rvMovies.adapter = adapter - } + override fun initView() = + with(binding as FragmentMovieBinding) { + rvMovies.adapter = adapter + } - override fun initAction() = with(binding as FragmentMovieBinding) { - adapter.onItemClick = { navigateTo(NavigationDirection.Detail(it)) } - ctfSearch.doOnTextChanged { text, _, _, _ -> - mainViewModel.setSearchQuery(text?.toString().orEmpty()) + override fun initAction() = + with(binding as FragmentMovieBinding) { + adapter.onItemClick = { navigateTo(NavigationDirection.Detail(it)) } + ctfSearch.doOnTextChanged { text, _, _, _ -> + mainViewModel.setSearchQuery(text?.toString().orEmpty()) + } } - } } diff --git a/app/src/main/java/com/argahutama/submission/made/movie/MovieViewModel.kt b/app/src/main/java/com/argahutama/submission/made/movie/MovieViewModel.kt index f054dc7..54517d6 100644 --- a/app/src/main/java/com/argahutama/submission/made/movie/MovieViewModel.kt +++ b/app/src/main/java/com/argahutama/submission/made/movie/MovieViewModel.kt @@ -9,7 +9,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn -class MovieViewModel(private val movieUseCase: MovieUseCase) : BaseViewModel() { - val movies: StateFlow>> = movieUseCase.getAllMovies() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Resource.Loading()) +class MovieViewModel(movieUseCase: MovieUseCase) : BaseViewModel() { + val movies: StateFlow>> = + movieUseCase.getAllMovies() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Resource.Loading()) } diff --git a/app/src/main/java/com/argahutama/submission/made/tvshow/TvShowFragment.kt b/app/src/main/java/com/argahutama/submission/made/tvshow/TvShowFragment.kt index 18a553f..462d1af 100644 --- a/app/src/main/java/com/argahutama/submission/made/tvshow/TvShowFragment.kt +++ b/app/src/main/java/com/argahutama/submission/made/tvshow/TvShowFragment.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.argahutama.submission.core.base.BaseFragment import com.argahutama.submission.core.data.Resource -import com.argahutama.submission.core.domain.model.Movie import com.argahutama.submission.core.navigation.NavigationDirection import com.argahutama.submission.core.ui.MovieAdapter import com.argahutama.submission.custom_ui.CustomSnack @@ -24,6 +23,7 @@ class TvShowFragment : BaseFragment() { private val adapter by lazy { MovieAdapter() } override val viewModel by viewModel() private val mainViewModel: MainViewModel by viewModel() + override fun createBinding() = FragmentTvShowBinding.inflate(layoutInflater) override fun setup() { @@ -65,14 +65,16 @@ class TvShowFragment : BaseFragment() { } } - override fun initView() = with(binding as FragmentTvShowBinding) { - rvTvShows.adapter = adapter - } + override fun initView() = + with(binding as FragmentTvShowBinding) { + rvTvShows.adapter = adapter + } - override fun initAction() = with(binding as FragmentTvShowBinding) { - adapter.onItemClick = { navigateTo(NavigationDirection.Detail(it)) } - ctfSearch.doOnTextChanged { text, _, _, _ -> - mainViewModel.setSearchQuery(text?.toString().orEmpty()) + override fun initAction() = + with(binding as FragmentTvShowBinding) { + adapter.onItemClick = { navigateTo(NavigationDirection.Detail(it)) } + ctfSearch.doOnTextChanged { text, _, _, _ -> + mainViewModel.setSearchQuery(text?.toString().orEmpty()) + } } - } } diff --git a/app/src/main/java/com/argahutama/submission/made/tvshow/TvShowViewModel.kt b/app/src/main/java/com/argahutama/submission/made/tvshow/TvShowViewModel.kt index f1cdc18..87e7a73 100644 --- a/app/src/main/java/com/argahutama/submission/made/tvshow/TvShowViewModel.kt +++ b/app/src/main/java/com/argahutama/submission/made/tvshow/TvShowViewModel.kt @@ -9,7 +9,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn -class TvShowViewModel(private val movieUseCase: MovieUseCase) : BaseViewModel() { - val tvShows: StateFlow>> = movieUseCase.getAllTvShows() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Resource.Loading()) +class TvShowViewModel(movieUseCase: MovieUseCase) : BaseViewModel() { + val tvShows: StateFlow>> = + movieUseCase.getAllTvShows() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Resource.Loading()) } diff --git a/app/src/test/java/com/argahutama/submission/made/detail/DetailViewModelTest.kt b/app/src/test/java/com/argahutama/submission/made/detail/DetailViewModelTest.kt index ff5b712..4e057ea 100644 --- a/app/src/test/java/com/argahutama/submission/made/detail/DetailViewModelTest.kt +++ b/app/src/test/java/com/argahutama/submission/made/detail/DetailViewModelTest.kt @@ -16,7 +16,6 @@ import org.junit.Test @ExperimentalCoroutinesApi class DetailViewModelTest { - private val testDispatcher = UnconfinedTestDispatcher() private lateinit var useCase: MovieUseCase private lateinit var viewModel: DetailViewModel diff --git a/app/src/test/java/com/argahutama/submission/made/main/MainViewModelTest.kt b/app/src/test/java/com/argahutama/submission/made/main/MainViewModelTest.kt index b64279d..592e431 100644 --- a/app/src/test/java/com/argahutama/submission/made/main/MainViewModelTest.kt +++ b/app/src/test/java/com/argahutama/submission/made/main/MainViewModelTest.kt @@ -9,20 +9,26 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) class MainViewModelTest { - private val testDispatcher = UnconfinedTestDispatcher() private lateinit var useCase: MovieUseCase private val movie = Movie(1, "Overview", "en", "2024-01-01", 9.5, 7.8, "Batman", 100, "/poster.jpg") - private val tvShow = Movie(2, "TV Overview", "ko", "2023-05-10", 8.0, 8.5, "Breaking Bad", 200, "/tv.jpg", isTvShows = true) + private val tvShow = + Movie(2, "TV Overview", "ko", "2023-05-10", 8.0, 8.5, "Breaking Bad", 200, "/tv.jpg", isTvShows = true) @Before fun setUp() { @@ -55,74 +61,78 @@ class MainViewModelTest { } @Test - fun `movieResult emits empty list when query is blank`() = runTest(testDispatcher) { - every { useCase.searchMovies(any()) } returns flowOf(emptyList()) - every { useCase.searchTvShows(any()) } returns flowOf(emptyList()) - val vm = MainViewModel(useCase) + fun `movieResult emits empty list when query is blank`() = + runTest(testDispatcher) { + every { useCase.searchMovies(any()) } returns flowOf(emptyList()) + every { useCase.searchTvShows(any()) } returns flowOf(emptyList()) + val vm = MainViewModel(useCase) - val collected = mutableListOf>() - val job = launch { vm.movieResult.collect { collected.add(it) } } - advanceTimeBy(301) - advanceUntilIdle() - job.cancel() + val collected = mutableListOf>() + val job = launch { vm.movieResult.collect { collected.add(it) } } + advanceTimeBy(301) + advanceUntilIdle() + job.cancel() - assertTrue(collected.all { it.isEmpty() }) - } + assertTrue(collected.all { it.isEmpty() }) + } @Test - fun `movieResult emits search results after debounce`() = runTest(testDispatcher) { - val results = listOf(movie) - every { useCase.searchMovies("Batman") } returns flowOf(results) - every { useCase.searchTvShows(any()) } returns flowOf(emptyList()) - val vm = MainViewModel(useCase) + fun `movieResult emits search results after debounce`() = + runTest(testDispatcher) { + val results = listOf(movie) + every { useCase.searchMovies("Batman") } returns flowOf(results) + every { useCase.searchTvShows(any()) } returns flowOf(emptyList()) + val vm = MainViewModel(useCase) - val collected = mutableListOf>() - val job = launch { vm.movieResult.collect { collected.add(it) } } + val collected = mutableListOf>() + val job = launch { vm.movieResult.collect { collected.add(it) } } - vm.setSearchQuery("Batman") - advanceTimeBy(301) - advanceUntilIdle() - job.cancel() + vm.setSearchQuery("Batman") + advanceTimeBy(301) + advanceUntilIdle() + job.cancel() - assertTrue(collected.any { it.isNotEmpty() }) - assertEquals(results, collected.last()) - } + assertTrue(collected.any { it.isNotEmpty() }) + assertEquals(results, collected.last()) + } @Test - fun `movieResult emits empty list when query changes back to blank`() = runTest(testDispatcher) { - every { useCase.searchMovies("Batman") } returns flowOf(listOf(movie)) - every { useCase.searchTvShows(any()) } returns flowOf(emptyList()) - val vm = MainViewModel(useCase) + fun `movieResult emits empty list when query changes back to blank`() = + runTest(testDispatcher) { + every { useCase.searchMovies("Batman") } returns flowOf(listOf(movie)) + every { useCase.searchTvShows(any()) } returns flowOf(emptyList()) + val vm = MainViewModel(useCase) - val collected = mutableListOf>() - val job = launch { vm.movieResult.collect { collected.add(it) } } + val collected = mutableListOf>() + val job = launch { vm.movieResult.collect { collected.add(it) } } - vm.setSearchQuery("Batman") - advanceTimeBy(301) - vm.setSearchQuery("") - advanceTimeBy(301) - advanceUntilIdle() - job.cancel() + vm.setSearchQuery("Batman") + advanceTimeBy(301) + vm.setSearchQuery("") + advanceTimeBy(301) + advanceUntilIdle() + job.cancel() - assertEquals(emptyList(), collected.last()) - } + assertEquals(emptyList(), collected.last()) + } @Test - fun `tvShowResult emits search results after debounce`() = runTest(testDispatcher) { - every { useCase.searchMovies(any()) } returns flowOf(emptyList()) - val results = listOf(tvShow) - every { useCase.searchTvShows("Breaking") } returns flowOf(results) - val vm = MainViewModel(useCase) - - val collected = mutableListOf>() - val job = launch { vm.tvShowResult.collect { collected.add(it) } } - - vm.setSearchQuery("Breaking") - advanceTimeBy(301) - advanceUntilIdle() - job.cancel() - - assertTrue(collected.any { it.isNotEmpty() }) - assertEquals(results, collected.last()) - } + fun `tvShowResult emits search results after debounce`() = + runTest(testDispatcher) { + every { useCase.searchMovies(any()) } returns flowOf(emptyList()) + val results = listOf(tvShow) + every { useCase.searchTvShows("Breaking") } returns flowOf(results) + val vm = MainViewModel(useCase) + + val collected = mutableListOf>() + val job = launch { vm.tvShowResult.collect { collected.add(it) } } + + vm.setSearchQuery("Breaking") + advanceTimeBy(301) + advanceUntilIdle() + job.cancel() + + assertTrue(collected.any { it.isNotEmpty() }) + assertEquals(results, collected.last()) + } } diff --git a/app/src/test/java/com/argahutama/submission/made/movie/MovieViewModelTest.kt b/app/src/test/java/com/argahutama/submission/made/movie/MovieViewModelTest.kt index b096271..38b4fe3 100644 --- a/app/src/test/java/com/argahutama/submission/made/movie/MovieViewModelTest.kt +++ b/app/src/test/java/com/argahutama/submission/made/movie/MovieViewModelTest.kt @@ -10,15 +10,20 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @ExperimentalCoroutinesApi class MovieViewModelTest { - private val testDispatcher = UnconfinedTestDispatcher() private lateinit var useCase: MovieUseCase @@ -43,33 +48,35 @@ class MovieViewModelTest { } @Test - fun `movies emits Success when use case provides data`() = runTest(testDispatcher) { - val movieList = listOf(movie) - every { useCase.getAllMovies() } returns flowOf(Resource.Success(movieList)) - val vm = MovieViewModel(useCase) + fun `movies emits Success when use case provides data`() = + runTest(testDispatcher) { + val movieList = listOf(movie) + every { useCase.getAllMovies() } returns flowOf(Resource.Success(movieList)) + val vm = MovieViewModel(useCase) - val collected = mutableListOf>>() - val job = launch { vm.movies.collect { collected.add(it) } } - advanceUntilIdle() - job.cancel() + val collected = mutableListOf>>() + val job = launch { vm.movies.collect { collected.add(it) } } + advanceUntilIdle() + job.cancel() - val success = collected.filterIsInstance>>().firstOrNull() - assertNotNull(success) - assertEquals(movieList, success!!.data) - } + val success = collected.filterIsInstance>>().firstOrNull() + assertNotNull(success) + assertEquals(movieList, success!!.data) + } @Test - fun `movies emits Error when use case returns error`() = runTest(testDispatcher) { - every { useCase.getAllMovies() } returns flowOf(Resource.Error("Failed to load")) - val vm = MovieViewModel(useCase) + fun `movies emits Error when use case returns error`() = + runTest(testDispatcher) { + every { useCase.getAllMovies() } returns flowOf(Resource.Error("Failed to load")) + val vm = MovieViewModel(useCase) - val collected = mutableListOf>>() - val job = launch { vm.movies.collect { collected.add(it) } } - advanceUntilIdle() - job.cancel() + val collected = mutableListOf>>() + val job = launch { vm.movies.collect { collected.add(it) } } + advanceUntilIdle() + job.cancel() - val error = collected.filterIsInstance>>().firstOrNull() - assertNotNull(error) - assertEquals("Failed to load", error!!.message) - } + val error = collected.filterIsInstance>>().firstOrNull() + assertNotNull(error) + assertEquals("Failed to load", error!!.message) + } } diff --git a/app/src/test/java/com/argahutama/submission/made/tvshow/TvShowViewModelTest.kt b/app/src/test/java/com/argahutama/submission/made/tvshow/TvShowViewModelTest.kt index 092a0f3..6de5a38 100644 --- a/app/src/test/java/com/argahutama/submission/made/tvshow/TvShowViewModelTest.kt +++ b/app/src/test/java/com/argahutama/submission/made/tvshow/TvShowViewModelTest.kt @@ -10,19 +10,25 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @ExperimentalCoroutinesApi class TvShowViewModelTest { - private val testDispatcher = UnconfinedTestDispatcher() private lateinit var useCase: MovieUseCase - private val tvShow = Movie(2, "TV Overview", "ko", "2023-05-10", 8.0, 8.5, "TV Show", 200, "/tv.jpg", isTvShows = true) + private val tvShow = + Movie(2, "TV Overview", "ko", "2023-05-10", 8.0, 8.5, "TV Show", 200, "/tv.jpg", isTvShows = true) @Before fun setUp() { @@ -43,34 +49,36 @@ class TvShowViewModelTest { } @Test - fun `tvShows emits Success when use case provides data`() = runTest(testDispatcher) { - val tvShowList = listOf(tvShow) - every { useCase.getAllTvShows() } returns flowOf(Resource.Success(tvShowList)) - val vm = TvShowViewModel(useCase) + fun `tvShows emits Success when use case provides data`() = + runTest(testDispatcher) { + val tvShowList = listOf(tvShow) + every { useCase.getAllTvShows() } returns flowOf(Resource.Success(tvShowList)) + val vm = TvShowViewModel(useCase) - val collected = mutableListOf>>() - val job = launch { vm.tvShows.collect { collected.add(it) } } - advanceUntilIdle() - job.cancel() + val collected = mutableListOf>>() + val job = launch { vm.tvShows.collect { collected.add(it) } } + advanceUntilIdle() + job.cancel() - val success = collected.filterIsInstance>>().firstOrNull() - assertNotNull(success) - assertEquals(tvShowList, success!!.data) - assertTrue(success.data!!.first().isTvShows) - } + val success = collected.filterIsInstance>>().firstOrNull() + assertNotNull(success) + assertEquals(tvShowList, success!!.data) + assertTrue(success.data!!.first().isTvShows) + } @Test - fun `tvShows emits Error when use case returns error`() = runTest(testDispatcher) { - every { useCase.getAllTvShows() } returns flowOf(Resource.Error("Service unavailable")) - val vm = TvShowViewModel(useCase) + fun `tvShows emits Error when use case returns error`() = + runTest(testDispatcher) { + every { useCase.getAllTvShows() } returns flowOf(Resource.Error("Service unavailable")) + val vm = TvShowViewModel(useCase) - val collected = mutableListOf>>() - val job = launch { vm.tvShows.collect { collected.add(it) } } - advanceUntilIdle() - job.cancel() + val collected = mutableListOf>>() + val job = launch { vm.tvShows.collect { collected.add(it) } } + advanceUntilIdle() + job.cancel() - val error = collected.filterIsInstance>>().firstOrNull() - assertNotNull(error) - assertEquals("Service unavailable", error!!.message) - } + val error = collected.filterIsInstance>>().firstOrNull() + assertNotNull(error) + assertEquals("Service unavailable", error!!.message) + } } diff --git a/build.gradle b/build.gradle index eb28110..1e22011 100644 --- a/build.gradle +++ b/build.gradle @@ -5,6 +5,11 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.kotlin.ksp) apply false + alias(libs.plugins.ktlint) apply false +} + +subprojects { + apply plugin: libs.plugins.ktlint.get().pluginId } tasks.register('clean', Delete) { diff --git a/core/src/main/java/com/argahutama/submission/core/base/BaseActivity.kt b/core/src/main/java/com/argahutama/submission/core/base/BaseActivity.kt index 8f1302a..ea3b5de 100644 --- a/core/src/main/java/com/argahutama/submission/core/base/BaseActivity.kt +++ b/core/src/main/java/com/argahutama/submission/core/base/BaseActivity.kt @@ -12,7 +12,9 @@ import com.argahutama.submission.custom_ui.CustomSnack abstract class BaseActivity : AppCompatActivity() { abstract val binding: ViewBinding abstract val viewModel: BaseViewModel + abstract fun initView() + abstract fun initAction() @SuppressLint("SourceLockedOrientationActivity") @@ -31,10 +33,17 @@ abstract class BaseActivity : AppCompatActivity() { fun getBaseApp() = application as? BaseApp - fun showSnackbar(message: String, type: Int = CustomSnack.SUCCESS) = - CustomSnack.show(this, message, type, binding.root) + fun showSnackbar( + message: String, + type: Int = CustomSnack.SUCCESS + ) = CustomSnack.show(this, message, type, binding.root) - fun navigateTo(direction: NavigationDirection, requestCode: Int? = null) = - if (requestCode == null) getBaseApp()?.navigateTo(this, direction) - else getBaseApp()?.navigateTo(this, direction, requestCode) + fun navigateTo( + direction: NavigationDirection, + requestCode: Int? = null + ) = if (requestCode == null) { + getBaseApp()?.navigateTo(this, direction) + } else { + getBaseApp()?.navigateTo(this, direction, requestCode) + } } diff --git a/core/src/main/java/com/argahutama/submission/core/base/BaseApp.kt b/core/src/main/java/com/argahutama/submission/core/base/BaseApp.kt index 4a687b1..7220062 100644 --- a/core/src/main/java/com/argahutama/submission/core/base/BaseApp.kt +++ b/core/src/main/java/com/argahutama/submission/core/base/BaseApp.kt @@ -5,11 +5,15 @@ import android.content.Context import androidx.multidex.MultiDexApplication import com.argahutama.submission.core.navigation.NavigationDirection -abstract class BaseApp: MultiDexApplication() { - abstract fun navigateTo(context: Context, direction: NavigationDirection) +abstract class BaseApp : MultiDexApplication() { + abstract fun navigateTo( + context: Context, + direction: NavigationDirection + ) + abstract fun navigateTo( activity: Activity, direction: NavigationDirection, requestCode: Int ) -} \ No newline at end of file +} diff --git a/core/src/main/java/com/argahutama/submission/core/base/BaseFragment.kt b/core/src/main/java/com/argahutama/submission/core/base/BaseFragment.kt index 9826d77..f9aff83 100644 --- a/core/src/main/java/com/argahutama/submission/core/base/BaseFragment.kt +++ b/core/src/main/java/com/argahutama/submission/core/base/BaseFragment.kt @@ -25,7 +25,9 @@ abstract class BaseFragment : Fragment() { protected val binding get() = _binding abstract fun createBinding(): ViewBinding + abstract fun initView() + abstract fun initAction() override fun onCreateView( @@ -50,19 +52,24 @@ abstract class BaseFragment : Fragment() { private fun getBaseActivity(): BaseActivity? = if (activity is BaseActivity) requireActivity() as BaseActivity else null - fun showSnackbar(message: String, type: Int = CustomSnack.SUCCESS) = - getBaseActivity()?.showSnackbar(message, type) + fun showSnackbar( + message: String, + type: Int = CustomSnack.SUCCESS + ) = getBaseActivity()?.showSnackbar(message, type) private fun getBaseApp() = getBaseActivity()?.getBaseApp() - fun navigateTo(direction: NavigationDirection) = - getBaseApp()?.navigateTo(requireContext(), direction) + fun navigateTo(direction: NavigationDirection) = getBaseApp()?.navigateTo(requireContext(), direction) - protected fun debounce(delayInMs: Long = 200L, action: () -> Unit) { + protected fun debounce( + delayInMs: Long = 200L, + action: () -> Unit + ) { job?.cancel() - job = lifecycleScope.launch { - delay(delayInMs) - action() - } + job = + lifecycleScope.launch { + delay(delayInMs) + action() + } } -} \ No newline at end of file +} diff --git a/core/src/main/java/com/argahutama/submission/core/base/BaseViewModel.kt b/core/src/main/java/com/argahutama/submission/core/base/BaseViewModel.kt index 175dbc6..f9ddf47 100644 --- a/core/src/main/java/com/argahutama/submission/core/base/BaseViewModel.kt +++ b/core/src/main/java/com/argahutama/submission/core/base/BaseViewModel.kt @@ -2,4 +2,4 @@ package com.argahutama.submission.core.base import androidx.lifecycle.ViewModel -abstract class BaseViewModel: ViewModel() \ No newline at end of file +abstract class BaseViewModel : ViewModel() diff --git a/core/src/main/java/com/argahutama/submission/core/data/MovieRepository.kt b/core/src/main/java/com/argahutama/submission/core/data/MovieRepository.kt index 4564418..16912f0 100644 --- a/core/src/main/java/com/argahutama/submission/core/data/MovieRepository.kt +++ b/core/src/main/java/com/argahutama/submission/core/data/MovieRepository.kt @@ -19,14 +19,14 @@ class MovieRepository( ) : IMovieRepository { override fun getAllMovies(): Flow>> = object : NetworkBoundResource, List>() { - override fun loadFromDB(): Flow> = localDataSource.getAllMovies().map { - DataMapper.mapEntitiesToDomain(it) - } + override fun loadFromDB(): Flow> = + localDataSource.getAllMovies().map { + DataMapper.mapEntitiesToDomain(it) + } override fun shouldFetch(data: List?) = data == null || data.isEmpty() - override suspend fun createCall(): Flow>> = - remoteDataSource.getMovies() + override suspend fun createCall(): Flow>> = remoteDataSource.getMovies() override suspend fun saveCallResult(data: List) { val movieList = DataMapper.mapMovieResponsesToEntities(data) @@ -36,14 +36,14 @@ class MovieRepository( override fun getAllTvShows(): Flow>> = object : NetworkBoundResource, List>() { - override fun loadFromDB(): Flow> = localDataSource.getAllTvShows().map { - DataMapper.mapEntitiesToDomain(it) - } + override fun loadFromDB(): Flow> = + localDataSource.getAllTvShows().map { + DataMapper.mapEntitiesToDomain(it) + } override fun shouldFetch(data: List?) = data == null || data.isEmpty() - override suspend fun createCall(): Flow>> = - remoteDataSource.getTvShows() + override suspend fun createCall(): Flow>> = remoteDataSource.getTvShows() override suspend fun saveCallResult(data: List) { val tvShowList = DataMapper.mapTvShowResponsesToEntities(data) @@ -71,8 +71,11 @@ class MovieRepository( DataMapper.mapEntitiesToDomain(it) } - override fun setMovieFavorite(movie: Movie, state: Boolean) { + override fun setMovieFavorite( + movie: Movie, + state: Boolean + ) { val movieEntity = DataMapper.mapDomainToEntity(movie) appExecutors.diskIO().execute { localDataSource.setMovieFavorite(movieEntity, state) } } -} \ No newline at end of file +} diff --git a/core/src/main/java/com/argahutama/submission/core/data/NetworkBoundResource.kt b/core/src/main/java/com/argahutama/submission/core/data/NetworkBoundResource.kt index 8dbea58..9692755 100644 --- a/core/src/main/java/com/argahutama/submission/core/data/NetworkBoundResource.kt +++ b/core/src/main/java/com/argahutama/submission/core/data/NetworkBoundResource.kt @@ -1,28 +1,34 @@ package com.argahutama.submission.core.data import com.argahutama.submission.core.data.source.remote.network.ApiResponse -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map abstract class NetworkBoundResource { - - private var result: Flow> = flow { - emit(Resource.Loading()) - val dbSource = loadFromDB().first() - if (shouldFetch(dbSource)) { + private var result: Flow> = + flow { emit(Resource.Loading()) - when (val apiResponse = createCall().first()) { - is ApiResponse.Success -> { - saveCallResult(apiResponse.data) - emitAll(loadFromDB().map { Resource.Success(it) }) - } - is ApiResponse.Empty -> emitAll(loadFromDB().map { Resource.Success(it) }) - is ApiResponse.Error -> { - onFetchFailed() - emit(Resource.Error(apiResponse.errorMessage)) + val dbSource = loadFromDB().first() + if (shouldFetch(dbSource)) { + emit(Resource.Loading()) + when (val apiResponse = createCall().first()) { + is ApiResponse.Success -> { + saveCallResult(apiResponse.data) + emitAll(loadFromDB().map { Resource.Success(it) }) + } + is ApiResponse.Empty -> emitAll(loadFromDB().map { Resource.Success(it) }) + is ApiResponse.Error -> { + onFetchFailed() + emit(Resource.Error(apiResponse.errorMessage)) + } } + } else { + emitAll(loadFromDB().map { Resource.Success(it) }) } - } else emitAll(loadFromDB().map { Resource.Success(it) }) - } + } protected open fun onFetchFailed() {} @@ -35,4 +41,4 @@ abstract class NetworkBoundResource { protected abstract suspend fun saveCallResult(data: RequestType) fun asFlow(): Flow> = result -} \ No newline at end of file +} diff --git a/core/src/main/java/com/argahutama/submission/core/data/Resource.kt b/core/src/main/java/com/argahutama/submission/core/data/Resource.kt index bdaecb8..24ac707 100644 --- a/core/src/main/java/com/argahutama/submission/core/data/Resource.kt +++ b/core/src/main/java/com/argahutama/submission/core/data/Resource.kt @@ -2,6 +2,8 @@ package com.argahutama.submission.core.data sealed class Resource(val data: T? = null, val message: String? = null) { class Success(data: T) : Resource(data) + class Loading(data: T? = null) : Resource(data) + class Error(message: String, data: T? = null) : Resource(data, message) -} \ No newline at end of file +} diff --git a/core/src/main/java/com/argahutama/submission/core/data/source/local/LocalDataSource.kt b/core/src/main/java/com/argahutama/submission/core/data/source/local/LocalDataSource.kt index 3e9713d..f394007 100644 --- a/core/src/main/java/com/argahutama/submission/core/data/source/local/LocalDataSource.kt +++ b/core/src/main/java/com/argahutama/submission/core/data/source/local/LocalDataSource.kt @@ -29,18 +29,23 @@ class LocalDataSource(private val mMovieDao: MovieDao) { return mMovieDao.getFavoriteTvShows(query) } - fun searchMovie(search: String): Flow> = mMovieDao.searchMovies(search) - .flowOn(Dispatchers.Default) - .conflate() + fun searchMovie(search: String): Flow> = + mMovieDao.searchMovies(search) + .flowOn(Dispatchers.Default) + .conflate() - fun searchTvShow(search: String): Flow> = mMovieDao.searchTvShows(search) - .flowOn(Dispatchers.Default) - .conflate() + fun searchTvShow(search: String): Flow> = + mMovieDao.searchTvShows(search) + .flowOn(Dispatchers.Default) + .conflate() suspend fun insertMovies(movies: List) = mMovieDao.insertMovie(movies) - fun setMovieFavorite(movie: MovieEntity, newState: Boolean) { + fun setMovieFavorite( + movie: MovieEntity, + newState: Boolean + ) { movie.favorite = newState mMovieDao.updateFavoriteMovie(movie) } -} \ No newline at end of file +} diff --git a/core/src/main/java/com/argahutama/submission/core/data/source/local/room/MovieDao.kt b/core/src/main/java/com/argahutama/submission/core/data/source/local/room/MovieDao.kt index 0d76dbb..9256aa0 100644 --- a/core/src/main/java/com/argahutama/submission/core/data/source/local/room/MovieDao.kt +++ b/core/src/main/java/com/argahutama/submission/core/data/source/local/room/MovieDao.kt @@ -1,6 +1,11 @@ package com.argahutama.submission.core.data.source.local.room -import androidx.room.* +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RawQuery +import androidx.room.Update import androidx.sqlite.db.SupportSQLiteQuery import com.argahutama.submission.core.data.source.local.entity.MovieEntity import kotlinx.coroutines.flow.Flow @@ -30,4 +35,4 @@ interface MovieDao { @Update fun updateFavoriteMovie(movie: MovieEntity) -} \ No newline at end of file +} diff --git a/core/src/main/java/com/argahutama/submission/core/data/source/local/room/MovieDb.kt b/core/src/main/java/com/argahutama/submission/core/data/source/local/room/MovieDb.kt index 87ed084..991fea6 100644 --- a/core/src/main/java/com/argahutama/submission/core/data/source/local/room/MovieDb.kt +++ b/core/src/main/java/com/argahutama/submission/core/data/source/local/room/MovieDb.kt @@ -7,4 +7,4 @@ import com.argahutama.submission.core.data.source.local.entity.MovieEntity @Database(entities = [MovieEntity::class], version = 1, exportSchema = false) abstract class MovieDb : RoomDatabase() { abstract fun movieDao(): MovieDao -} \ No newline at end of file +} diff --git a/core/src/main/java/com/argahutama/submission/core/data/source/remote/RemoteDataSource.kt b/core/src/main/java/com/argahutama/submission/core/data/source/remote/RemoteDataSource.kt index ffa5a09..9081ef5 100644 --- a/core/src/main/java/com/argahutama/submission/core/data/source/remote/RemoteDataSource.kt +++ b/core/src/main/java/com/argahutama/submission/core/data/source/remote/RemoteDataSource.kt @@ -13,31 +13,33 @@ import kotlinx.coroutines.flow.flowOn class RemoteDataSource(private val apiService: ApiService) { private val apiKey = BuildConfig.API_KEY - suspend fun getMovies(): Flow>> = flow { - try { - val response = apiService.getMovies(apiKey) - val movieList = response.results - if (movieList.isNotEmpty()) { - emit(ApiResponse.Success(response.results)) - } else { - emit(ApiResponse.Empty) + fun getMovies(): Flow>> = + flow { + try { + val response = apiService.getMovies(apiKey) + val movieList = response.results + if (movieList.isNotEmpty()) { + emit(ApiResponse.Success(response.results)) + } else { + emit(ApiResponse.Empty) + } + } catch (e: Exception) { + emit(ApiResponse.Error(e.toString())) } - } catch (e: Exception) { - emit(ApiResponse.Error(e.toString())) - } - }.flowOn(Dispatchers.IO) + }.flowOn(Dispatchers.IO) - suspend fun getTvShows(): Flow>> = flow { - try { - val response = apiService.getTvShows(apiKey) - val tvShowList = response.results - if (tvShowList.isNotEmpty()) { - emit(ApiResponse.Success(response.results)) - } else { - emit(ApiResponse.Empty) + fun getTvShows(): Flow>> = + flow { + try { + val response = apiService.getTvShows(apiKey) + val tvShowList = response.results + if (tvShowList.isNotEmpty()) { + emit(ApiResponse.Success(response.results)) + } else { + emit(ApiResponse.Empty) + } + } catch (e: Exception) { + emit(ApiResponse.Error(e.toString())) } - } catch (e: Exception) { - emit(ApiResponse.Error(e.toString())) - } - }.flowOn(Dispatchers.IO) + }.flowOn(Dispatchers.IO) } diff --git a/core/src/main/java/com/argahutama/submission/core/data/source/remote/network/ApiResponse.kt b/core/src/main/java/com/argahutama/submission/core/data/source/remote/network/ApiResponse.kt index a1fb0a6..75a9bcb 100644 --- a/core/src/main/java/com/argahutama/submission/core/data/source/remote/network/ApiResponse.kt +++ b/core/src/main/java/com/argahutama/submission/core/data/source/remote/network/ApiResponse.kt @@ -2,6 +2,8 @@ package com.argahutama.submission.core.data.source.remote.network sealed class ApiResponse { data class Success(val data: T) : ApiResponse() + data class Error(val errorMessage: String) : ApiResponse() + object Empty : ApiResponse() -} \ No newline at end of file +} diff --git a/core/src/main/java/com/argahutama/submission/core/data/source/remote/network/ApiService.kt b/core/src/main/java/com/argahutama/submission/core/data/source/remote/network/ApiService.kt index ec3e15b..471e0c1 100644 --- a/core/src/main/java/com/argahutama/submission/core/data/source/remote/network/ApiService.kt +++ b/core/src/main/java/com/argahutama/submission/core/data/source/remote/network/ApiService.kt @@ -7,8 +7,12 @@ import retrofit2.http.Query interface ApiService { @GET("movie") - suspend fun getMovies(@Query("api_key") apiKey: String): MoviesResponse + suspend fun getMovies( + @Query("api_key") apiKey: String + ): MoviesResponse @GET("tv") - suspend fun getTvShows(@Query("api_key") apiKey: String): TvShowsResponse -} \ No newline at end of file + suspend fun getTvShows( + @Query("api_key") apiKey: String + ): TvShowsResponse +} diff --git a/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/MovieResponse.kt b/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/MovieResponse.kt index dc7ed21..d9464b6 100644 --- a/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/MovieResponse.kt +++ b/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/MovieResponse.kt @@ -12,4 +12,4 @@ data class MovieResponse( @SerializedName("title") val title: String?, @SerializedName("vote_count") val voteCount: Int?, @SerializedName("poster_path") val posterPath: String? -) \ No newline at end of file +) diff --git a/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/MoviesResponse.kt b/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/MoviesResponse.kt index 20f6fb9..6fdaeff 100644 --- a/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/MoviesResponse.kt +++ b/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/MoviesResponse.kt @@ -2,4 +2,6 @@ package com.argahutama.submission.core.data.source.remote.response import com.google.gson.annotations.SerializedName -data class MoviesResponse(@SerializedName("results") val results: List) \ No newline at end of file +data class MoviesResponse( + @SerializedName("results") val results: List +) diff --git a/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/TvShowResponse.kt b/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/TvShowResponse.kt index a7fe709..bcfeaa1 100644 --- a/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/TvShowResponse.kt +++ b/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/TvShowResponse.kt @@ -12,4 +12,4 @@ data class TvShowResponse( @SerializedName("name") val name: String?, @SerializedName("vote_count") val voteCount: Int?, @SerializedName("poster_path") val posterPath: String? -) \ No newline at end of file +) diff --git a/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/TvShowsResponse.kt b/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/TvShowsResponse.kt index ab47b00..ac9105e 100644 --- a/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/TvShowsResponse.kt +++ b/core/src/main/java/com/argahutama/submission/core/data/source/remote/response/TvShowsResponse.kt @@ -2,4 +2,6 @@ package com.argahutama.submission.core.data.source.remote.response import com.google.gson.annotations.SerializedName -data class TvShowsResponse(@SerializedName("results") val results: List) \ No newline at end of file +data class TvShowsResponse( + @SerializedName("results") val results: List +) diff --git a/core/src/main/java/com/argahutama/submission/core/di/CoreModule.kt b/core/src/main/java/com/argahutama/submission/core/di/CoreModule.kt index 78e7c1c..e510c03 100644 --- a/core/src/main/java/com/argahutama/submission/core/di/CoreModule.kt +++ b/core/src/main/java/com/argahutama/submission/core/di/CoreModule.kt @@ -20,47 +20,53 @@ import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit -val dbModule = module { - factory { get().movieDao() } - single { - val passphrase = "argahutama".toByteArray() - val factory = SupportOpenHelperFactory(passphrase) - Room.databaseBuilder( - androidContext(), - MovieDb::class.java, "movie.db" - ).fallbackToDestructiveMigration(true).openHelperFactory(factory).build() +val dbModule = + module { + factory { get().movieDao() } + single { + val passphrase = "argahutama".toByteArray() + val factory = SupportOpenHelperFactory(passphrase) + Room.databaseBuilder( + androidContext(), + MovieDb::class.java, + "movie.db" + ).fallbackToDestructiveMigration(true).openHelperFactory(factory).build() + } } -} -val networkModule = module { - single { - val hostname = "api.themoviedb.org" - val certificatePinner = CertificatePinner.Builder() - .add(hostname, "sha256/QfyoR20v8hyYX7L+ikLzM/euPGSDl67gFFcor/sROMs=") - .add(hostname, "sha256/G9LNNAql897egYsabashkzUCTEJkWBzgoEtk8X/678c=") - .add(hostname, "sha256/++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=") - .build() - OkHttpClient.Builder() - .addInterceptor(PlutoOkhttpInterceptor) - .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) - .connectTimeout(40, TimeUnit.SECONDS) - .readTimeout(40, TimeUnit.SECONDS) - .certificatePinner(certificatePinner) - .build() +val networkModule = + module { + single { + val hostname = "api.themoviedb.org" + val certificatePinner = + CertificatePinner.Builder() + .add(hostname, "sha256/QfyoR20v8hyYX7L+ikLzM/euPGSDl67gFFcor/sROMs=") + .add(hostname, "sha256/G9LNNAql897egYsabashkzUCTEJkWBzgoEtk8X/678c=") + .add(hostname, "sha256/++MBgDH5WGvL9Bcn5Be30cRcL0f5O+NyoXuWtQdX1aI=") + .build() + OkHttpClient.Builder() + .addInterceptor(PlutoOkhttpInterceptor) + .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + .connectTimeout(40, TimeUnit.SECONDS) + .readTimeout(40, TimeUnit.SECONDS) + .certificatePinner(certificatePinner) + .build() + } + single { + val retrofit = + Retrofit.Builder() + .baseUrl(BuildConfig.BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .client(get()) + .build() + retrofit.create(ApiService::class.java) + } } - single { - val retrofit = Retrofit.Builder() - .baseUrl(BuildConfig.BASE_URL) - .addConverterFactory(GsonConverterFactory.create()) - .client(get()) - .build() - retrofit.create(ApiService::class.java) - } -} -val repositoryModule = module { - single { LocalDataSource(get()) } - single { RemoteDataSource(get()) } - factory { AppExecutors() } - single { MovieRepository(get(), get(), get()) } -} \ No newline at end of file +val repositoryModule = + module { + single { LocalDataSource(get()) } + single { RemoteDataSource(get()) } + factory { AppExecutors() } + single { MovieRepository(get(), get(), get()) } + } diff --git a/core/src/main/java/com/argahutama/submission/core/domain/model/Movie.kt b/core/src/main/java/com/argahutama/submission/core/domain/model/Movie.kt index bef4c34..b0d8835 100644 --- a/core/src/main/java/com/argahutama/submission/core/domain/model/Movie.kt +++ b/core/src/main/java/com/argahutama/submission/core/domain/model/Movie.kt @@ -16,4 +16,4 @@ data class Movie( var posterPath: String, var favorite: Boolean = false, var isTvShows: Boolean = false -) : Parcelable \ No newline at end of file +) : Parcelable diff --git a/core/src/main/java/com/argahutama/submission/core/domain/repository/IMovieRepository.kt b/core/src/main/java/com/argahutama/submission/core/domain/repository/IMovieRepository.kt index ece75a5..70679f6 100644 --- a/core/src/main/java/com/argahutama/submission/core/domain/repository/IMovieRepository.kt +++ b/core/src/main/java/com/argahutama/submission/core/domain/repository/IMovieRepository.kt @@ -17,5 +17,8 @@ interface IMovieRepository { fun searchTvShows(search: String): Flow> - fun setMovieFavorite(movie: Movie, state: Boolean) -} \ No newline at end of file + fun setMovieFavorite( + movie: Movie, + state: Boolean + ) +} diff --git a/core/src/main/java/com/argahutama/submission/core/domain/usecase/MovieInteractor.kt b/core/src/main/java/com/argahutama/submission/core/domain/usecase/MovieInteractor.kt index 1659081..07c8599 100644 --- a/core/src/main/java/com/argahutama/submission/core/domain/usecase/MovieInteractor.kt +++ b/core/src/main/java/com/argahutama/submission/core/domain/usecase/MovieInteractor.kt @@ -12,14 +12,14 @@ class MovieInteractor(private val iMovieAppRepository: IMovieRepository) : Movie override fun getFavoriteMovies(): Flow> = iMovieAppRepository.getFavoriteMovies() - override fun searchMovies(search: String): Flow> = - iMovieAppRepository.searchMovies(search) + override fun searchMovies(search: String): Flow> = iMovieAppRepository.searchMovies(search) - override fun searchTvShows(search: String): Flow> = - iMovieAppRepository.searchTvShows(search) + override fun searchTvShows(search: String): Flow> = iMovieAppRepository.searchTvShows(search) override fun getFavoriteTvShows(): Flow> = iMovieAppRepository.getFavoriteTvShows() - override fun setMovieFavorite(movie: Movie, state: Boolean) = - iMovieAppRepository.setMovieFavorite(movie, state) -} \ No newline at end of file + override fun setMovieFavorite( + movie: Movie, + state: Boolean + ) = iMovieAppRepository.setMovieFavorite(movie, state) +} diff --git a/core/src/main/java/com/argahutama/submission/core/domain/usecase/MovieUseCase.kt b/core/src/main/java/com/argahutama/submission/core/domain/usecase/MovieUseCase.kt index 5bb5f3e..4e3c079 100644 --- a/core/src/main/java/com/argahutama/submission/core/domain/usecase/MovieUseCase.kt +++ b/core/src/main/java/com/argahutama/submission/core/domain/usecase/MovieUseCase.kt @@ -17,5 +17,8 @@ interface MovieUseCase { fun searchTvShows(search: String): Flow> - fun setMovieFavorite(movie: Movie, state: Boolean) -} \ No newline at end of file + fun setMovieFavorite( + movie: Movie, + state: Boolean + ) +} diff --git a/core/src/main/java/com/argahutama/submission/core/navigation/NavigationDirection.kt b/core/src/main/java/com/argahutama/submission/core/navigation/NavigationDirection.kt index ed11f99..79d6191 100644 --- a/core/src/main/java/com/argahutama/submission/core/navigation/NavigationDirection.kt +++ b/core/src/main/java/com/argahutama/submission/core/navigation/NavigationDirection.kt @@ -8,5 +8,6 @@ object Extra { sealed class NavigationDirection(val extras: Map) { object Main : NavigationDirection(mapOf()) + data class Detail(val movie: Movie) : NavigationDirection(mapOf(Extra.MOVIE to movie)) -} \ No newline at end of file +} diff --git a/core/src/main/java/com/argahutama/submission/core/ui/MovieAdapter.kt b/core/src/main/java/com/argahutama/submission/core/ui/MovieAdapter.kt index 0761167..4a891d1 100644 --- a/core/src/main/java/com/argahutama/submission/core/ui/MovieAdapter.kt +++ b/core/src/main/java/com/argahutama/submission/core/ui/MovieAdapter.kt @@ -13,7 +13,7 @@ import com.argahutama.submission.core.domain.model.Movie import com.argahutama.submission.core.util.DifferenceUtil import com.argahutama.submission.core.util.GlideListener import com.bumptech.glide.Glide -import java.util.* +import java.util.ArrayList class MovieAdapter : RecyclerView.Adapter() { private var listData = ArrayList() @@ -30,12 +30,18 @@ class MovieAdapter : RecyclerView.Adapter() { fun getSwipedData(swipedPosition: Int) = listData[swipedPosition] - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MovieViewHolder { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): MovieViewHolder { val bd = ItemMovieBinding.inflate(LayoutInflater.from(parent.context), parent, false) return MovieViewHolder(bd, parent.context) } - override fun onBindViewHolder(holder: MovieViewHolder, position: Int) { + override fun onBindViewHolder( + holder: MovieViewHolder, + position: Int + ) { holder.bind(listData[position]) } @@ -45,26 +51,28 @@ class MovieAdapter : RecyclerView.Adapter() { private val binding: ItemMovieBinding, private val context: Context ) : RecyclerView.ViewHolder(binding.root) { - fun bind(movie: Movie) = with(binding) { - ctvTitle.text = movie.title - ctvRating.text = movie.voteAverage.toString() - ctvDescription.run { - text = movie.overview - isVisible = movie.overview.isNotEmpty() - } + fun bind(movie: Movie) = + with(binding) { + ctvTitle.text = movie.title + ctvRating.text = movie.voteAverage.toString() + ctvDescription.run { + text = movie.overview + isVisible = movie.overview.isNotEmpty() + } - if (movie.posterPath.isNotEmpty()) Glide.with(itemView.context) - .load(context.getString(R.string.base_image_url, movie.posterPath)) - .listener(GlideListener(sivMovie, shimmer)) - .into(sivMovie) - else { - sivMovie.visibility = View.GONE - shimmer.visibility = View.GONE + if (movie.posterPath.isNotEmpty()) { + Glide.with(itemView.context) + .load(context.getString(R.string.base_image_url, movie.posterPath)) + .listener(GlideListener(sivMovie, shimmer)) + .into(sivMovie) + } else { + sivMovie.visibility = View.GONE + shimmer.visibility = View.GONE + } } - } init { binding.root.setOnClickListener { onItemClick?.invoke(listData[bindingAdapterPosition]) } } } -} \ No newline at end of file +} diff --git a/core/src/main/java/com/argahutama/submission/core/util/AppExecutors.kt b/core/src/main/java/com/argahutama/submission/core/util/AppExecutors.kt index b6d8c27..a3a3a31 100644 --- a/core/src/main/java/com/argahutama/submission/core/util/AppExecutors.kt +++ b/core/src/main/java/com/argahutama/submission/core/util/AppExecutors.kt @@ -4,8 +4,10 @@ import androidx.annotation.VisibleForTesting import java.util.concurrent.Executor import java.util.concurrent.Executors -class AppExecutors @VisibleForTesting constructor(private val diskIO: Executor) { - constructor() : this(Executors.newSingleThreadExecutor()) +class AppExecutors + @VisibleForTesting + constructor(private val diskIO: Executor) { + constructor() : this(Executors.newSingleThreadExecutor()) - fun diskIO(): Executor = diskIO -} + fun diskIO(): Executor = diskIO + } diff --git a/core/src/main/java/com/argahutama/submission/core/util/DataMapper.kt b/core/src/main/java/com/argahutama/submission/core/util/DataMapper.kt index 2ae30c2..00675c6 100644 --- a/core/src/main/java/com/argahutama/submission/core/util/DataMapper.kt +++ b/core/src/main/java/com/argahutama/submission/core/util/DataMapper.kt @@ -11,19 +11,20 @@ object DataMapper { fun mapMovieResponsesToEntities(input: List): List { val movieList = ArrayList() input.map { - val movie = MovieEntity( - it.id ?: 0, - it.overview.orEmpty(), - it.originalLanguage.orEmpty(), - it.releaseDate.orEmpty(), - it.popularity ?: .0, - (it.voteAverage ?: .0).roundTo1Decimal(), - it.title.orEmpty(), - it.voteCount ?: 0, - it.posterPath.orEmpty(), - favorite = false, - isTvShows = false - ) + val movie = + MovieEntity( + it.id ?: 0, + it.overview.orEmpty(), + it.originalLanguage.orEmpty(), + it.releaseDate.orEmpty(), + it.popularity ?: .0, + (it.voteAverage ?: .0).roundTo1Decimal(), + it.title.orEmpty(), + it.voteCount ?: 0, + it.posterPath.orEmpty(), + favorite = false, + isTvShows = false + ) movieList.add(movie) } return movieList @@ -32,51 +33,54 @@ object DataMapper { fun mapTvShowResponsesToEntities(input: List): List { val movieList = ArrayList() input.map { - val movie = MovieEntity( - it.id ?: 0, - it.overview.orEmpty(), - it.originalLanguage.orEmpty(), - it.firstAirDate.orEmpty(), - it.popularity ?: .0, - (it.voteAverage ?: .0).roundTo1Decimal(), - it.name.orEmpty(), - it.voteCount ?: 0, - it.posterPath.orEmpty(), - favorite = false, - isTvShows = true - ) + val movie = + MovieEntity( + it.id ?: 0, + it.overview.orEmpty(), + it.originalLanguage.orEmpty(), + it.firstAirDate.orEmpty(), + it.popularity ?: .0, + (it.voteAverage ?: .0).roundTo1Decimal(), + it.name.orEmpty(), + it.voteCount ?: 0, + it.posterPath.orEmpty(), + favorite = false, + isTvShows = true + ) movieList.add(movie) } return movieList } - fun mapEntitiesToDomain(input: List): List = input.map { - Movie( - it.id, - it.overview, - it.originalLanguage, - it.releaseDate, - it.popularity, - it.voteAverage, - it.title, - it.voteCount, - it.posterPath, - favorite = it.favorite, - isTvShows = it.isTvShows - ) - } + fun mapEntitiesToDomain(input: List): List = + input.map { + Movie( + it.id, + it.overview, + it.originalLanguage, + it.releaseDate, + it.popularity, + it.voteAverage, + it.title, + it.voteCount, + it.posterPath, + favorite = it.favorite, + isTvShows = it.isTvShows + ) + } - fun mapDomainToEntity(input: Movie): MovieEntity = MovieEntity( - input.id, - input.overview, - input.originalLanguage, - input.releaseDate, - input.popularity, - input.voteAverage, - input.title, - input.voteCount, - input.posterPath, - favorite = input.favorite, - isTvShows = input.isTvShows - ) -} \ No newline at end of file + fun mapDomainToEntity(input: Movie): MovieEntity = + MovieEntity( + input.id, + input.overview, + input.originalLanguage, + input.releaseDate, + input.popularity, + input.voteAverage, + input.title, + input.voteCount, + input.posterPath, + favorite = input.favorite, + isTvShows = input.isTvShows + ) +} diff --git a/core/src/main/java/com/argahutama/submission/core/util/DiffUtil.kt b/core/src/main/java/com/argahutama/submission/core/util/DifferenceUtil.kt similarity index 50% rename from core/src/main/java/com/argahutama/submission/core/util/DiffUtil.kt rename to core/src/main/java/com/argahutama/submission/core/util/DifferenceUtil.kt index ef51df1..2b47f55 100644 --- a/core/src/main/java/com/argahutama/submission/core/util/DiffUtil.kt +++ b/core/src/main/java/com/argahutama/submission/core/util/DifferenceUtil.kt @@ -6,17 +6,23 @@ import com.argahutama.submission.core.domain.model.Movie class DifferenceUtil(private val oldList: List, private val newList: List) : DiffUtil.Callback() { - override fun getOldListSize(): Int = oldList.size override fun getNewListSize(): Int = newList.size - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ): Boolean { return oldList[oldItemPosition].id == newList[newItemPosition].id } - override fun areContentsTheSame(oldPosition: Int, newPosition: Int): Boolean { - val (_, + override fun areContentsTheSame( + oldPosition: Int, + newPosition: Int + ): Boolean { + val ( + _, overview, originalLanguage, releaseDate, @@ -26,8 +32,10 @@ class DifferenceUtil(private val oldList: List, private val newList: List voteCount, posterPath, favorite, - isTvShows) = oldList[oldPosition] - val (_,overview1, + isTvShows + ) = oldList[oldPosition] + val ( + _, overview1, originalLanguage1, releaseDate1, popularity1, @@ -36,21 +44,24 @@ class DifferenceUtil(private val oldList: List, private val newList: List voteCount1, posterPath1, favorite1, - isTvShows1) = newList[newPosition] + isTvShows1 + ) = newList[newPosition] - return overview == overview1 - && originalLanguage == originalLanguage1 - && releaseDate == releaseDate1 - && popularity == popularity1 - && voteAverage == voteAverage1 - && title == title1 - && voteCount == voteCount1 - && posterPath == posterPath1 - && favorite == favorite1 - && isTvShows == isTvShows1 + return overview == overview1 && + originalLanguage == originalLanguage1 && + releaseDate == releaseDate1 && + popularity == popularity1 && + voteAverage == voteAverage1 && + title == title1 && + voteCount == voteCount1 && + posterPath == posterPath1 && + favorite == favorite1 && + isTvShows == isTvShows1 } @Nullable - override fun getChangePayload(oldPosition: Int, newPosition: Int): Any? = - super.getChangePayload(oldPosition, newPosition) -} \ No newline at end of file + override fun getChangePayload( + oldPosition: Int, + newPosition: Int + ): Any? = super.getChangePayload(oldPosition, newPosition) +} diff --git a/core/src/main/java/com/argahutama/submission/core/util/SortUtil.kt b/core/src/main/java/com/argahutama/submission/core/util/SortUtil.kt index 991d2cd..617ae77 100644 --- a/core/src/main/java/com/argahutama/submission/core/util/SortUtil.kt +++ b/core/src/main/java/com/argahutama/submission/core/util/SortUtil.kt @@ -28,4 +28,4 @@ object SortUtil { simpleQuery.append("ORDER BY releaseDate DESC") return SimpleSQLiteQuery(simpleQuery.toString()) } -} \ No newline at end of file +} diff --git a/core/src/test/java/com/argahutama/submission/core/data/MovieRepositoryTest.kt b/core/src/test/java/com/argahutama/submission/core/data/MovieRepositoryTest.kt index 39bb946..490a30b 100644 --- a/core/src/test/java/com/argahutama/submission/core/data/MovieRepositoryTest.kt +++ b/core/src/test/java/com/argahutama/submission/core/data/MovieRepositoryTest.kt @@ -9,46 +9,56 @@ import com.argahutama.submission.core.data.source.remote.response.MovieResponse import com.argahutama.submission.core.data.source.remote.response.TvShowResponse import com.argahutama.submission.core.domain.model.Movie import com.argahutama.submission.core.util.AppExecutors -import io.mockk.* +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.util.concurrent.Executor class MovieRepositoryTest { - private lateinit var remoteDataSource: RemoteDataSource private lateinit var localDataSource: LocalDataSource private lateinit var appExecutors: AppExecutors private lateinit var repository: MovieRepository - private val movieEntity = MovieEntity( - id = 1, overview = "Overview", originalLanguage = "en", - releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, - title = "Movie", voteCount = 100, posterPath = "/poster.jpg", - favorite = false, isTvShows = false - ) - - private val tvShowEntity = MovieEntity( - id = 2, overview = "TV Overview", originalLanguage = "ko", - releaseDate = "2023-05-10", popularity = 8.0, voteAverage = 8.5, - title = "TV Show", voteCount = 200, posterPath = "/tv.jpg", - favorite = false, isTvShows = true - ) - - private val movieResponse = MovieResponse( - id = 1, overview = "Overview", originalLanguage = "en", - releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, - title = "Movie", voteCount = 100, posterPath = "/poster.jpg" - ) - - private val tvShowResponse = TvShowResponse( - id = 2, overview = "TV Overview", originalLanguage = "ko", - firstAirDate = "2023-05-10", popularity = 8.0, voteAverage = 8.5, - name = "TV Show", voteCount = 200, posterPath = "/tv.jpg" - ) + private val movieEntity = + MovieEntity( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg", + favorite = false, isTvShows = false + ) + + private val tvShowEntity = + MovieEntity( + id = 2, overview = "TV Overview", originalLanguage = "ko", + releaseDate = "2023-05-10", popularity = 8.0, voteAverage = 8.5, + title = "TV Show", voteCount = 200, posterPath = "/tv.jpg", + favorite = false, isTvShows = true + ) + + private val movieResponse = + MovieResponse( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg" + ) + + private val tvShowResponse = + TvShowResponse( + id = 2, overview = "TV Overview", originalLanguage = "ko", + firstAirDate = "2023-05-10", popularity = 8.0, voteAverage = 8.5, + name = "TV Show", voteCount = 200, posterPath = "/tv.jpg" + ) @Before fun setUp() { @@ -60,135 +70,147 @@ class MovieRepositoryTest { } @Test - fun `getAllMovies returns cached data without network call when db is not empty`() = runTest { - every { localDataSource.getAllMovies() } returns flowOf(listOf(movieEntity)) - - repository.getAllMovies().test { - assertTrue(awaitItem() is Resource.Loading) - val success = awaitItem() as Resource.Success - assertEquals(1, success.data?.size) - assertEquals("Movie", success.data?.first()?.title) - awaitComplete() + fun `getAllMovies returns cached data without network call when db is not empty`() = + runTest { + every { localDataSource.getAllMovies() } returns flowOf(listOf(movieEntity)) + + repository.getAllMovies().test { + assertTrue(awaitItem() is Resource.Loading) + val success = awaitItem() as Resource.Success + assertEquals(1, success.data?.size) + assertEquals("Movie", success.data?.first()?.title) + awaitComplete() + } + + coVerify(exactly = 0) { remoteDataSource.getMovies() } } - coVerify(exactly = 0) { remoteDataSource.getMovies() } - } - @Test - fun `getAllMovies fetches from network and saves when db is empty`() = runTest { - every { localDataSource.getAllMovies() } returnsMany listOf( - flowOf(emptyList()), - flowOf(listOf(movieEntity)) - ) - coEvery { remoteDataSource.getMovies() } returns flowOf(ApiResponse.Success(listOf(movieResponse))) - coEvery { localDataSource.insertMovies(any()) } just Runs - - repository.getAllMovies().test { - assertTrue(awaitItem() is Resource.Loading) - assertTrue(awaitItem() is Resource.Loading) - val success = awaitItem() as Resource.Success - assertEquals(1, success.data?.size) - awaitComplete() + fun `getAllMovies fetches from network and saves when db is empty`() = + runTest { + every { localDataSource.getAllMovies() } returnsMany + listOf( + flowOf(emptyList()), + flowOf(listOf(movieEntity)) + ) + coEvery { remoteDataSource.getMovies() } returns flowOf(ApiResponse.Success(listOf(movieResponse))) + coEvery { localDataSource.insertMovies(any()) } just Runs + + repository.getAllMovies().test { + assertTrue(awaitItem() is Resource.Loading) + assertTrue(awaitItem() is Resource.Loading) + val success = awaitItem() as Resource.Success + assertEquals(1, success.data?.size) + awaitComplete() + } + + coVerify { localDataSource.insertMovies(any()) } } - coVerify { localDataSource.insertMovies(any()) } - } - @Test - fun `getAllMovies emits Error when network call fails`() = runTest { - every { localDataSource.getAllMovies() } returns flowOf(emptyList()) - coEvery { remoteDataSource.getMovies() } returns flowOf(ApiResponse.Error("Network error")) - - repository.getAllMovies().test { - assertTrue(awaitItem() is Resource.Loading) - assertTrue(awaitItem() is Resource.Loading) - val error = awaitItem() as Resource.Error - assertEquals("Network error", error.message) - awaitComplete() + fun `getAllMovies emits Error when network call fails`() = + runTest { + every { localDataSource.getAllMovies() } returns flowOf(emptyList()) + coEvery { remoteDataSource.getMovies() } returns flowOf(ApiResponse.Error("Network error")) + + repository.getAllMovies().test { + assertTrue(awaitItem() is Resource.Loading) + assertTrue(awaitItem() is Resource.Loading) + val error = awaitItem() as Resource.Error + assertEquals("Network error", error.message) + awaitComplete() + } } - } @Test - fun `getAllMovies emits Success with empty list on Empty api response`() = runTest { - every { localDataSource.getAllMovies() } returnsMany listOf( - flowOf(emptyList()), - flowOf(emptyList()) - ) - coEvery { remoteDataSource.getMovies() } returns flowOf(ApiResponse.Empty) - - repository.getAllMovies().test { - assertTrue(awaitItem() is Resource.Loading) - assertTrue(awaitItem() is Resource.Loading) - val success = awaitItem() as Resource.Success - assertTrue(success.data.isNullOrEmpty()) - awaitComplete() + fun `getAllMovies emits Success with empty list on Empty api response`() = + runTest { + every { localDataSource.getAllMovies() } returnsMany + listOf( + flowOf(emptyList()), + flowOf(emptyList()) + ) + coEvery { remoteDataSource.getMovies() } returns flowOf(ApiResponse.Empty) + + repository.getAllMovies().test { + assertTrue(awaitItem() is Resource.Loading) + assertTrue(awaitItem() is Resource.Loading) + val success = awaitItem() as Resource.Success + assertTrue(success.data.isNullOrEmpty()) + awaitComplete() + } } - } @Test - fun `getAllTvShows fetches from network and saves when db is empty`() = runTest { - every { localDataSource.getAllTvShows() } returnsMany listOf( - flowOf(emptyList()), - flowOf(listOf(tvShowEntity)) - ) - coEvery { remoteDataSource.getTvShows() } returns flowOf(ApiResponse.Success(listOf(tvShowResponse))) - coEvery { localDataSource.insertMovies(any()) } just Runs - - repository.getAllTvShows().test { - assertTrue(awaitItem() is Resource.Loading) - assertTrue(awaitItem() is Resource.Loading) - val success = awaitItem() as Resource.Success - assertEquals(1, success.data?.size) - assertTrue(success.data?.first()?.isTvShows == true) - awaitComplete() + fun `getAllTvShows fetches from network and saves when db is empty`() = + runTest { + every { localDataSource.getAllTvShows() } returnsMany + listOf( + flowOf(emptyList()), + flowOf(listOf(tvShowEntity)) + ) + coEvery { remoteDataSource.getTvShows() } returns flowOf(ApiResponse.Success(listOf(tvShowResponse))) + coEvery { localDataSource.insertMovies(any()) } just Runs + + repository.getAllTvShows().test { + assertTrue(awaitItem() is Resource.Loading) + assertTrue(awaitItem() is Resource.Loading) + val success = awaitItem() as Resource.Success + assertEquals(1, success.data?.size) + assertTrue(success.data?.first()?.isTvShows == true) + awaitComplete() + } } - } @Test - fun `getFavoriteMovies returns mapped domain list`() = runTest { - val favorite = movieEntity.copy(favorite = true) - every { localDataSource.getAllFavoriteMovies() } returns flowOf(listOf(favorite)) - - repository.getFavoriteMovies().test { - val movies = awaitItem() - assertEquals(1, movies.size) - assertTrue(movies.first().favorite) - awaitComplete() + fun `getFavoriteMovies returns mapped domain list`() = + runTest { + val favorite = movieEntity.copy(favorite = true) + every { localDataSource.getAllFavoriteMovies() } returns flowOf(listOf(favorite)) + + repository.getFavoriteMovies().test { + val movies = awaitItem() + assertEquals(1, movies.size) + assertTrue(movies.first().favorite) + awaitComplete() + } } - } @Test - fun `getFavoriteTvShows returns mapped domain list`() = runTest { - val favoriteTvShow = tvShowEntity.copy(favorite = true) - every { localDataSource.getAllFavoriteTvShows() } returns flowOf(listOf(favoriteTvShow)) - - repository.getFavoriteTvShows().test { - val movies = awaitItem() - assertEquals(1, movies.size) - assertTrue(movies.first().isTvShows) - awaitComplete() + fun `getFavoriteTvShows returns mapped domain list`() = + runTest { + val favoriteTvShow = tvShowEntity.copy(favorite = true) + every { localDataSource.getAllFavoriteTvShows() } returns flowOf(listOf(favoriteTvShow)) + + repository.getFavoriteTvShows().test { + val movies = awaitItem() + assertEquals(1, movies.size) + assertTrue(movies.first().isTvShows) + awaitComplete() + } } - } @Test - fun `searchMovies returns matching domain objects`() = runTest { - every { localDataSource.searchMovie("Movie") } returns flowOf(listOf(movieEntity)) - - repository.searchMovies("Movie").test { - val result = awaitItem() - assertEquals(1, result.size) - assertEquals("Movie", result.first().title) - awaitComplete() + fun `searchMovies returns matching domain objects`() = + runTest { + every { localDataSource.searchMovie("Movie") } returns flowOf(listOf(movieEntity)) + + repository.searchMovies("Movie").test { + val result = awaitItem() + assertEquals(1, result.size) + assertEquals("Movie", result.first().title) + awaitComplete() + } } - } @Test fun `setMovieFavorite executes on disk IO and updates local data source`() { - val movie = Movie( - id = 1, overview = "Overview", originalLanguage = "en", - releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, - title = "Movie", voteCount = 100, posterPath = "/poster.jpg" - ) + val movie = + Movie( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg" + ) every { localDataSource.setMovieFavorite(any(), true) } just Runs repository.setMovieFavorite(movie, true) diff --git a/core/src/test/java/com/argahutama/submission/core/data/source/local/LocalDataSourceTest.kt b/core/src/test/java/com/argahutama/submission/core/data/source/local/LocalDataSourceTest.kt index 3a640f0..68bd8cc 100644 --- a/core/src/test/java/com/argahutama/submission/core/data/source/local/LocalDataSourceTest.kt +++ b/core/src/test/java/com/argahutama/submission/core/data/source/local/LocalDataSourceTest.kt @@ -3,7 +3,15 @@ package com.argahutama.submission.core.data.source.local import com.argahutama.submission.core.data.source.local.entity.MovieEntity import com.argahutama.submission.core.data.source.local.room.MovieDao import com.argahutama.submission.core.util.SortUtil -import io.mockk.* +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -13,15 +21,15 @@ import org.junit.Before import org.junit.Test class LocalDataSourceTest { - private lateinit var movieDao: MovieDao private lateinit var localDataSource: LocalDataSource - private val movieEntity = MovieEntity( - id = 1, overview = "Overview", originalLanguage = "en", - releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, - title = "Movie", voteCount = 100, posterPath = "/poster.jpg" - ) + private val movieEntity = + MovieEntity( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg" + ) @Before fun setUp() { @@ -40,82 +48,89 @@ class LocalDataSourceTest { } @Test - fun `getAllMovies returns flow from dao using sorted query`() = runTest { - val expected = listOf(movieEntity) - every { movieDao.getMovies(any()) } returns flowOf(expected) + fun `getAllMovies returns flow from dao using sorted query`() = + runTest { + val expected = listOf(movieEntity) + every { movieDao.getMovies(any()) } returns flowOf(expected) - val result = localDataSource.getAllMovies().first() + val result = localDataSource.getAllMovies().first() - assertEquals(expected, result) - verify { movieDao.getMovies(any()) } - } + assertEquals(expected, result) + verify { movieDao.getMovies(any()) } + } @Test - fun `getAllTvShows returns flow from dao using sorted query`() = runTest { - val tvShow = movieEntity.copy(isTvShows = true) - every { movieDao.getTvShows(any()) } returns flowOf(listOf(tvShow)) + fun `getAllTvShows returns flow from dao using sorted query`() = + runTest { + val tvShow = movieEntity.copy(isTvShows = true) + every { movieDao.getTvShows(any()) } returns flowOf(listOf(tvShow)) - val result = localDataSource.getAllTvShows().first() + val result = localDataSource.getAllTvShows().first() - assertEquals(listOf(tvShow), result) - verify { movieDao.getTvShows(any()) } - } + assertEquals(listOf(tvShow), result) + verify { movieDao.getTvShows(any()) } + } @Test - fun `getAllFavoriteMovies returns flow from dao using sorted query`() = runTest { - val favorite = movieEntity.copy(favorite = true) - every { movieDao.getFavoriteMovies(any()) } returns flowOf(listOf(favorite)) + fun `getAllFavoriteMovies returns flow from dao using sorted query`() = + runTest { + val favorite = movieEntity.copy(favorite = true) + every { movieDao.getFavoriteMovies(any()) } returns flowOf(listOf(favorite)) - val result = localDataSource.getAllFavoriteMovies().first() + val result = localDataSource.getAllFavoriteMovies().first() - assertEquals(listOf(favorite), result) - verify { movieDao.getFavoriteMovies(any()) } - } + assertEquals(listOf(favorite), result) + verify { movieDao.getFavoriteMovies(any()) } + } @Test - fun `getAllFavoriteTvShows returns flow from dao using sorted query`() = runTest { - val favoriteTvShow = movieEntity.copy(favorite = true, isTvShows = true) - every { movieDao.getFavoriteTvShows(any()) } returns flowOf(listOf(favoriteTvShow)) + fun `getAllFavoriteTvShows returns flow from dao using sorted query`() = + runTest { + val favoriteTvShow = movieEntity.copy(favorite = true, isTvShows = true) + every { movieDao.getFavoriteTvShows(any()) } returns flowOf(listOf(favoriteTvShow)) - val result = localDataSource.getAllFavoriteTvShows().first() + val result = localDataSource.getAllFavoriteTvShows().first() - assertEquals(listOf(favoriteTvShow), result) - verify { movieDao.getFavoriteTvShows(any()) } - } + assertEquals(listOf(favoriteTvShow), result) + verify { movieDao.getFavoriteTvShows(any()) } + } @Test - fun `searchMovie returns matching results from dao`() = runTest { - val query = "Movie" - val expected = listOf(movieEntity) - every { movieDao.searchMovies(query) } returns flowOf(expected) + fun `searchMovie returns matching results from dao`() = + runTest { + val query = "Movie" + val expected = listOf(movieEntity) + every { movieDao.searchMovies(query) } returns flowOf(expected) - val result = localDataSource.searchMovie(query).first() + val result = localDataSource.searchMovie(query).first() - assertEquals(expected, result) - verify { movieDao.searchMovies(query) } - } + assertEquals(expected, result) + verify { movieDao.searchMovies(query) } + } @Test - fun `searchTvShow returns matching results from dao`() = runTest { - val query = "Show" - val tvShow = movieEntity.copy(isTvShows = true) - every { movieDao.searchTvShows(query) } returns flowOf(listOf(tvShow)) + fun `searchTvShow returns matching results from dao`() = + runTest { + val query = "Show" + val tvShow = movieEntity.copy(isTvShows = true) + every { movieDao.searchTvShows(query) } returns flowOf(listOf(tvShow)) - val result = localDataSource.searchTvShow(query).first() + val result = localDataSource.searchTvShow(query).first() - assertEquals(listOf(tvShow), result) - verify { movieDao.searchTvShows(query) } - } + assertEquals(listOf(tvShow), result) + verify { movieDao.searchTvShows(query) } + } @Test - fun `insertMovies delegates to dao`() = runTest { - val movies = listOf(movieEntity) - coEvery { movieDao.insertMovie(movies) } just Runs + fun `insertMovies delegates to dao`() = + runTest { + val movies = listOf(movieEntity) + coEvery { movieDao.insertMovie(movies) } just Runs - localDataSource.insertMovies(movies) + localDataSource.insertMovies(movies) - coVerify { movieDao.insertMovie(movies) } - } + coVerify { movieDao.insertMovie(movies) } + } @Test fun `setMovieFavorite updates favorite flag and delegates to dao`() { diff --git a/core/src/test/java/com/argahutama/submission/core/data/source/remote/RemoteDataSourceTest.kt b/core/src/test/java/com/argahutama/submission/core/data/source/remote/RemoteDataSourceTest.kt index f8bc2a1..cf06bc1 100644 --- a/core/src/test/java/com/argahutama/submission/core/data/source/remote/RemoteDataSourceTest.kt +++ b/core/src/test/java/com/argahutama/submission/core/data/source/remote/RemoteDataSourceTest.kt @@ -16,21 +16,22 @@ import org.junit.Before import org.junit.Test class RemoteDataSourceTest { - private lateinit var apiService: ApiService private lateinit var remoteDataSource: RemoteDataSource - private val movieResponse = MovieResponse( - id = 1, overview = "Overview", originalLanguage = "en", - releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, - title = "Movie", voteCount = 100, posterPath = "/poster.jpg" - ) + private val movieResponse = + MovieResponse( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg" + ) - private val tvShowResponse = TvShowResponse( - id = 2, overview = "TV Overview", originalLanguage = "ko", - firstAirDate = "2023-05-10", popularity = 8.0, voteAverage = 8.5, - name = "TV Show", voteCount = 200, posterPath = "/tv.jpg" - ) + private val tvShowResponse = + TvShowResponse( + id = 2, overview = "TV Overview", originalLanguage = "ko", + firstAirDate = "2023-05-10", popularity = 8.0, voteAverage = 8.5, + name = "TV Show", voteCount = 200, posterPath = "/tv.jpg" + ) @Before fun setUp() { @@ -39,72 +40,78 @@ class RemoteDataSourceTest { } @Test - fun `getMovies emits Success when results are not empty`() = runTest { - coEvery { apiService.getMovies(any()) } returns MoviesResponse(listOf(movieResponse)) - - remoteDataSource.getMovies().test { - val result = awaitItem() - assertTrue(result is ApiResponse.Success) - assertEquals(listOf(movieResponse), (result as ApiResponse.Success).data) - awaitComplete() + fun `getMovies emits Success when results are not empty`() = + runTest { + coEvery { apiService.getMovies(any()) } returns MoviesResponse(listOf(movieResponse)) + + remoteDataSource.getMovies().test { + val result = awaitItem() + assertTrue(result is ApiResponse.Success) + assertEquals(listOf(movieResponse), (result as ApiResponse.Success).data) + awaitComplete() + } } - } @Test - fun `getMovies emits Empty when results are empty`() = runTest { - coEvery { apiService.getMovies(any()) } returns MoviesResponse(emptyList()) - - remoteDataSource.getMovies().test { - assertTrue(awaitItem() is ApiResponse.Empty) - awaitComplete() + fun `getMovies emits Empty when results are empty`() = + runTest { + coEvery { apiService.getMovies(any()) } returns MoviesResponse(emptyList()) + + remoteDataSource.getMovies().test { + assertTrue(awaitItem() is ApiResponse.Empty) + awaitComplete() + } } - } @Test - fun `getMovies emits Error on exception`() = runTest { - val errorMessage = "Network error" - coEvery { apiService.getMovies(any()) } throws RuntimeException(errorMessage) - - remoteDataSource.getMovies().test { - val result = awaitItem() - assertTrue(result is ApiResponse.Error) - assertTrue((result as ApiResponse.Error).errorMessage.contains(errorMessage)) - awaitComplete() + fun `getMovies emits Error on exception`() = + runTest { + val errorMessage = "Network error" + coEvery { apiService.getMovies(any()) } throws RuntimeException(errorMessage) + + remoteDataSource.getMovies().test { + val result = awaitItem() + assertTrue(result is ApiResponse.Error) + assertTrue((result as ApiResponse.Error).errorMessage.contains(errorMessage)) + awaitComplete() + } } - } @Test - fun `getTvShows emits Success when results are not empty`() = runTest { - coEvery { apiService.getTvShows(any()) } returns TvShowsResponse(listOf(tvShowResponse)) - - remoteDataSource.getTvShows().test { - val result = awaitItem() - assertTrue(result is ApiResponse.Success) - assertEquals(listOf(tvShowResponse), (result as ApiResponse.Success).data) - awaitComplete() + fun `getTvShows emits Success when results are not empty`() = + runTest { + coEvery { apiService.getTvShows(any()) } returns TvShowsResponse(listOf(tvShowResponse)) + + remoteDataSource.getTvShows().test { + val result = awaitItem() + assertTrue(result is ApiResponse.Success) + assertEquals(listOf(tvShowResponse), (result as ApiResponse.Success).data) + awaitComplete() + } } - } @Test - fun `getTvShows emits Empty when results are empty`() = runTest { - coEvery { apiService.getTvShows(any()) } returns TvShowsResponse(emptyList()) - - remoteDataSource.getTvShows().test { - assertTrue(awaitItem() is ApiResponse.Empty) - awaitComplete() + fun `getTvShows emits Empty when results are empty`() = + runTest { + coEvery { apiService.getTvShows(any()) } returns TvShowsResponse(emptyList()) + + remoteDataSource.getTvShows().test { + assertTrue(awaitItem() is ApiResponse.Empty) + awaitComplete() + } } - } @Test - fun `getTvShows emits Error on exception`() = runTest { - val errorMessage = "Timeout" - coEvery { apiService.getTvShows(any()) } throws RuntimeException(errorMessage) - - remoteDataSource.getTvShows().test { - val result = awaitItem() - assertTrue(result is ApiResponse.Error) - assertTrue((result as ApiResponse.Error).errorMessage.contains(errorMessage)) - awaitComplete() + fun `getTvShows emits Error on exception`() = + runTest { + val errorMessage = "Timeout" + coEvery { apiService.getTvShows(any()) } throws RuntimeException(errorMessage) + + remoteDataSource.getTvShows().test { + val result = awaitItem() + assertTrue(result is ApiResponse.Error) + assertTrue((result as ApiResponse.Error).errorMessage.contains(errorMessage)) + awaitComplete() + } } - } } diff --git a/core/src/test/java/com/argahutama/submission/core/domain/usecase/MovieInteractorTest.kt b/core/src/test/java/com/argahutama/submission/core/domain/usecase/MovieInteractorTest.kt index f5702b2..7cfe5df 100644 --- a/core/src/test/java/com/argahutama/submission/core/domain/usecase/MovieInteractorTest.kt +++ b/core/src/test/java/com/argahutama/submission/core/domain/usecase/MovieInteractorTest.kt @@ -11,15 +11,15 @@ import org.junit.Before import org.junit.Test class MovieInteractorTest { - private lateinit var repository: IMovieRepository private lateinit var interactor: MovieInteractor - private val movie = Movie( - id = 1, overview = "Overview", originalLanguage = "en", - releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, - title = "Movie", voteCount = 100, posterPath = "/poster.jpg" - ) + private val movie = + Movie( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg" + ) @Before fun setUp() { diff --git a/core/src/test/java/com/argahutama/submission/core/util/DataMapperTest.kt b/core/src/test/java/com/argahutama/submission/core/util/DataMapperTest.kt index 99c3229..e4f2477 100644 --- a/core/src/test/java/com/argahutama/submission/core/util/DataMapperTest.kt +++ b/core/src/test/java/com/argahutama/submission/core/util/DataMapperTest.kt @@ -8,14 +8,14 @@ import org.junit.Assert.assertEquals import org.junit.Test class DataMapperTest { - @Test fun `mapMovieResponsesToEntities maps all fields correctly`() { - val response = MovieResponse( - id = 1, overview = "Overview", originalLanguage = "en", - releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, - title = "Movie", voteCount = 100, posterPath = "/poster.jpg" - ) + val response = + MovieResponse( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg" + ) val result = DataMapper.mapMovieResponsesToEntities(listOf(response)) @@ -37,10 +37,11 @@ class DataMapperTest { @Test fun `mapMovieResponsesToEntities rounds voteAverage to one decimal`() { - val response = MovieResponse( - id = 1, overview = null, originalLanguage = null, releaseDate = null, - popularity = null, voteAverage = 7.86, title = null, voteCount = null, posterPath = null - ) + val response = + MovieResponse( + id = 1, overview = null, originalLanguage = null, releaseDate = null, + popularity = null, voteAverage = 7.86, title = null, voteCount = null, posterPath = null + ) val result = DataMapper.mapMovieResponsesToEntities(listOf(response)) @@ -49,10 +50,11 @@ class DataMapperTest { @Test fun `mapMovieResponsesToEntities handles null fields with defaults`() { - val response = MovieResponse( - id = null, overview = null, originalLanguage = null, releaseDate = null, - popularity = null, voteAverage = null, title = null, voteCount = null, posterPath = null - ) + val response = + MovieResponse( + id = null, overview = null, originalLanguage = null, releaseDate = null, + popularity = null, voteAverage = null, title = null, voteCount = null, posterPath = null + ) val result = DataMapper.mapMovieResponsesToEntities(listOf(response)) @@ -67,11 +69,12 @@ class DataMapperTest { @Test fun `mapTvShowResponsesToEntities maps all fields correctly`() { - val response = TvShowResponse( - id = 2, overview = "TV Overview", originalLanguage = "ko", - firstAirDate = "2023-05-10", popularity = 8.0, voteAverage = 8.5, - name = "TV Show", voteCount = 200, posterPath = "/tv.jpg" - ) + val response = + TvShowResponse( + id = 2, overview = "TV Overview", originalLanguage = "ko", + firstAirDate = "2023-05-10", popularity = 8.0, voteAverage = 8.5, + name = "TV Show", voteCount = 200, posterPath = "/tv.jpg" + ) val result = DataMapper.mapTvShowResponsesToEntities(listOf(response)) @@ -93,10 +96,11 @@ class DataMapperTest { @Test fun `mapTvShowResponsesToEntities rounds voteAverage to one decimal`() { - val response = TvShowResponse( - id = 1, overview = null, originalLanguage = null, firstAirDate = null, - popularity = null, voteAverage = 6.56, name = null, voteCount = null, posterPath = null - ) + val response = + TvShowResponse( + id = 1, overview = null, originalLanguage = null, firstAirDate = null, + popularity = null, voteAverage = 6.56, name = null, voteCount = null, posterPath = null + ) val result = DataMapper.mapTvShowResponsesToEntities(listOf(response)) @@ -105,12 +109,13 @@ class DataMapperTest { @Test fun `mapEntitiesToDomain maps all fields correctly`() { - val entity = MovieEntity( - id = 1, overview = "Overview", originalLanguage = "en", - releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, - title = "Movie", voteCount = 100, posterPath = "/poster.jpg", - favorite = true, isTvShows = false - ) + val entity = + MovieEntity( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg", + favorite = true, isTvShows = false + ) val result = DataMapper.mapEntitiesToDomain(listOf(entity)) @@ -132,12 +137,13 @@ class DataMapperTest { @Test fun `mapDomainToEntity maps all fields correctly`() { - val movie = Movie( - id = 1, overview = "Overview", originalLanguage = "en", - releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, - title = "Movie", voteCount = 100, posterPath = "/poster.jpg", - favorite = false, isTvShows = true - ) + val movie = + Movie( + id = 1, overview = "Overview", originalLanguage = "en", + releaseDate = "2024-01-01", popularity = 9.5, voteAverage = 7.8, + title = "Movie", voteCount = 100, posterPath = "/poster.jpg", + favorite = false, isTvShows = true + ) val entity = DataMapper.mapDomainToEntity(movie) diff --git a/custom-ui/.editorconfig b/custom-ui/.editorconfig new file mode 100644 index 0000000..523a610 --- /dev/null +++ b/custom-ui/.editorconfig @@ -0,0 +1,3 @@ +[*.{kt,kts}] +# Package name contains underscore due to module naming convention (custom-ui -> custom_ui) +ktlint_standard_package-name = disabled diff --git a/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomCardView.kt b/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomCardView.kt index 4b4a5d7..4df568f 100644 --- a/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomCardView.kt +++ b/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomCardView.kt @@ -5,85 +5,101 @@ import android.util.AttributeSet import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat -class CustomCardView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr) { - private var cornerRadius: Int = RADIUS_DEFAULT - set(value) { - val availableRadius = - arrayOf(RADIUS_DEFAULT, RADIUS_FLAT, RADIUS_MAXIMAL, RADIUS_MINIMAL) - if (availableRadius.contains(value)) { +class CustomCardView + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 + ) : ConstraintLayout(context, attrs, defStyleAttr) { + private var cornerRadius: Int = RADIUS_DEFAULT + set(value) { + val availableRadius = + arrayOf(RADIUS_DEFAULT, RADIUS_FLAT, RADIUS_MAXIMAL, RADIUS_MINIMAL) + if (availableRadius.contains(value)) { + field = value + updateBackground() + } + } + + private var isBordered: Boolean = false + set(value) { field = value updateBackground() } - } - - private var isBordered: Boolean = false - set(value) { - field = value - updateBackground() - } - private var cardElevation: Int = ELEVATION_NORMAL - set(value) { - val availableRadius = arrayOf(ELEVATION_NONE, ELEVATION_FLOAT, ELEVATION_NORMAL) - if (!availableRadius.contains(value)) return - field = value - val elevationId = when (value) { - ELEVATION_NORMAL -> R.dimen.custom_card_view_elevation_normal - ELEVATION_FLOAT -> R.dimen.custom_card_view_elevation_float - else -> R.dimen.custom_card_view_elevation_none + private var cardElevation: Int = ELEVATION_NORMAL + set(value) { + val availableRadius = arrayOf(ELEVATION_NONE, ELEVATION_FLOAT, ELEVATION_NORMAL) + if (!availableRadius.contains(value)) return + field = value + val elevationId = + when (value) { + ELEVATION_NORMAL -> R.dimen.custom_card_view_elevation_normal + ELEVATION_FLOAT -> R.dimen.custom_card_view_elevation_float + else -> R.dimen.custom_card_view_elevation_none + } + elevation = context.resources.getDimensionPixelSize(elevationId).toFloat() } - elevation = context.resources.getDimensionPixelSize(elevationId).toFloat() - } - private fun updateBackground() { - val backgroundDrawable = when (cornerRadius) { - RADIUS_FLAT -> - if (isBordered) R.drawable.bg_custom_card_view_flat_bordered - else R.drawable.bg_custom_card_view_flat - RADIUS_MINIMAL -> - if (isBordered) R.drawable.bg_custom_card_view_minimal_bordered - else R.drawable.bg_custom_card_view_minimal - RADIUS_MAXIMAL -> - if (isBordered) R.drawable.bg_custom_card_view_maximal_bordered - else R.drawable.bg_custom_card_view_maximal - else -> - if (isBordered) R.drawable.bg_custom_card_view_default_bordered - else R.drawable.bg_custom_card_view_default + private fun updateBackground() { + val backgroundDrawable = + when (cornerRadius) { + RADIUS_FLAT -> + if (isBordered) { + R.drawable.bg_custom_card_view_flat_bordered + } else { + R.drawable.bg_custom_card_view_flat + } + RADIUS_MINIMAL -> + if (isBordered) { + R.drawable.bg_custom_card_view_minimal_bordered + } else { + R.drawable.bg_custom_card_view_minimal + } + RADIUS_MAXIMAL -> + if (isBordered) { + R.drawable.bg_custom_card_view_maximal_bordered + } else { + R.drawable.bg_custom_card_view_maximal + } + else -> + if (isBordered) { + R.drawable.bg_custom_card_view_default_bordered + } else { + R.drawable.bg_custom_card_view_default + } + } + background = ContextCompat.getDrawable(context, backgroundDrawable) } - background = ContextCompat.getDrawable(context, backgroundDrawable) - } - init { - context.theme.obtainStyledAttributes( - attrs, - R.styleable.CustomCardView, - 0, - 0 - ).apply { - try { - cornerRadius = - getInteger(R.styleable.CustomCardView_ccv_cornerRadius, RADIUS_DEFAULT) - cardElevation = - getInteger(R.styleable.CustomCardView_ccv_elevation, ELEVATION_NORMAL) - isBordered = getBoolean(R.styleable.CustomCardView_ccv_isBordered, false) - } finally { - recycle() + init { + context.theme.obtainStyledAttributes( + attrs, + R.styleable.CustomCardView, + 0, + 0 + ).apply { + try { + cornerRadius = + getInteger(R.styleable.CustomCardView_ccv_cornerRadius, RADIUS_DEFAULT) + cardElevation = + getInteger(R.styleable.CustomCardView_ccv_elevation, ELEVATION_NORMAL) + isBordered = getBoolean(R.styleable.CustomCardView_ccv_isBordered, false) + } finally { + recycle() + } } } - } - companion object { - const val RADIUS_FLAT = 0 - const val RADIUS_MINIMAL = 1 - const val RADIUS_DEFAULT = 2 - const val RADIUS_MAXIMAL = 3 + companion object { + const val RADIUS_FLAT = 0 + const val RADIUS_MINIMAL = 1 + const val RADIUS_DEFAULT = 2 + const val RADIUS_MAXIMAL = 3 - const val ELEVATION_NONE = 0 - const val ELEVATION_NORMAL = 1 - const val ELEVATION_FLOAT = 2 + const val ELEVATION_NONE = 0 + const val ELEVATION_NORMAL = 1 + const val ELEVATION_FLOAT = 2 + } } -} \ No newline at end of file diff --git a/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomSnack.kt b/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomSnack.kt index e4bd2f2..7a1d3b8 100644 --- a/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomSnack.kt +++ b/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomSnack.kt @@ -16,42 +16,64 @@ object CustomSnack { const val WARNING = 1 const val FAILED = 2 - fun show(context: Context, message: String, type: Int, root: View) = when (type) { + fun show( + context: Context, + message: String, + type: Int, + root: View + ) = when (type) { SUCCESS -> success(context, root, message) WARNING -> warning(context, root, message) else -> failed(context, root, message) } - private fun success(context: Context, root: View, message: String) = - setupView(context, message, SUCCESS, root) + private fun success( + context: Context, + root: View, + message: String + ) = setupView(context, message, SUCCESS, root) - private fun warning(context: Context, root: View, message: String) = - setupView(context, message, WARNING, root) + private fun warning( + context: Context, + root: View, + message: String + ) = setupView(context, message, WARNING, root) - private fun failed(context: Context, root: View, message: String) = - setupView(context, message, FAILED, root) + private fun failed( + context: Context, + root: View, + message: String + ) = setupView(context, message, FAILED, root) - private fun setupView(context: Context, message: String, type: Int, root: View) { + private fun setupView( + context: Context, + message: String, + type: Int, + root: View + ) { val view = View.inflate(context, R.layout.layout_custom_snack, null) view.findViewById(R.id.ctvSnackMessage).text = HtmlCompat.fromHtml(message, HtmlCompat.FROM_HTML_MODE_LEGACY) - val background = when (type) { - SUCCESS -> R.drawable.bg_custom_snack_success - WARNING -> R.drawable.bg_custom_snack_warning - else -> R.drawable.bg_custom_snack_failed - } + val background = + when (type) { + SUCCESS -> R.drawable.bg_custom_snack_success + WARNING -> R.drawable.bg_custom_snack_warning + else -> R.drawable.bg_custom_snack_failed + } - val drawable = when (type) { - SUCCESS -> R.drawable.ic_round_check_24 - WARNING -> R.drawable.ic_round_warning_24 - else -> R.drawable.ic_round_clear_24 - } + val drawable = + when (type) { + SUCCESS -> R.drawable.ic_round_check_24 + WARNING -> R.drawable.ic_round_warning_24 + else -> R.drawable.ic_round_clear_24 + } - val textColor = when (type) { - FAILED -> R.color.white - else -> R.color.black_pitch - } + val textColor = + when (type) { + FAILED -> R.color.white + else -> R.color.black_pitch + } view.findViewById(R.id.ll) .background = ContextCompat.getDrawable(context, background) @@ -74,4 +96,4 @@ object CustomSnack { snackBar.setBackgroundTint(ContextCompat.getColor(context, android.R.color.transparent)) snackBar.show() } -} \ No newline at end of file +} diff --git a/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomTextField.kt b/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomTextField.kt index b27d201..71816a7 100644 --- a/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomTextField.kt +++ b/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomTextField.kt @@ -6,7 +6,6 @@ import android.graphics.drawable.Drawable import android.text.InputFilter import android.util.AttributeSet import android.view.LayoutInflater -import android.view.View import android.widget.EditText import android.widget.FrameLayout import android.widget.ImageView @@ -16,348 +15,366 @@ import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged -class CustomTextField @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, -) : ConstraintLayout(context, attrs, defStyleAttr) { - private var maxLength: Int = -1 - set(value) { - if (value == -1) return - field = value - etContent.filters = arrayOf(InputFilter.LengthFilter(value)) - } +class CustomTextField + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : ConstraintLayout(context, attrs, defStyleAttr) { + private var maxLength: Int = -1 + set(value) { + if (value == -1) return + field = value + etContent.filters = arrayOf(InputFilter.LengthFilter(value)) + } - private var minLine: Int = 1 - set(value) { - field = value - etContent.minLines = field - } + private var minLine: Int = 1 + set(value) { + field = value + etContent.minLines = field + } - private var maxLine: Int = 1 - set(value) { - field = value - etContent.maxLines = field - } + private var maxLine: Int = 1 + set(value) { + field = value + etContent.maxLines = field + } - private var prefixText: String? = null - set(value) { - if (value == null) return - field = value - - flSuffix.visibility = View.GONE - flPrefix.visibility = View.VISIBLE - ivPrefix.visibility = View.GONE - tvPrefix.apply { - visibility = View.VISIBLE - text = value + private var prefixText: String? = null + set(value) { + if (value == null) return + field = value + + flSuffix.visibility = GONE + flPrefix.visibility = VISIBLE + ivPrefix.visibility = GONE + tvPrefix.apply { + visibility = VISIBLE + text = value + } } - } - private var prefixIcon: Drawable? = null - set(value) { - if (value == null) return - field = value + private var prefixIcon: Drawable? = null + set(value) { + if (value == null) return + field = value - flPrefix.visibility = View.VISIBLE - tvPrefix.visibility = View.GONE - ivPrefix.apply { - visibility = View.VISIBLE - setImageDrawable(value) + flPrefix.visibility = VISIBLE + tvPrefix.visibility = GONE + ivPrefix.apply { + visibility = VISIBLE + setImageDrawable(value) + } } - } - private var suffixIcon: Drawable? = null - set(value) { - if (value == null) return - field = value - - flPrefix.visibility = View.GONE - flSuffix.visibility = View.VISIBLE - tvSuffix.visibility = View.GONE - ivSuffix.apply { - visibility = View.VISIBLE - setImageDrawable(value) + private var suffixIcon: Drawable? = null + set(value) { + if (value == null) return + field = value + + flPrefix.visibility = GONE + flSuffix.visibility = VISIBLE + tvSuffix.visibility = GONE + ivSuffix.apply { + visibility = VISIBLE + setImageDrawable(value) + } } - } - private var suffixText: String? = null - set(value) { - if (value == null) return - field = value - - flSuffix.visibility = View.VISIBLE - flPrefix.visibility = View.GONE - ivSuffix.visibility = View.GONE - tvSuffix.apply { - visibility = View.VISIBLE - text = value + private var suffixText: String? = null + set(value) { + if (value == null) return + field = value + + flSuffix.visibility = VISIBLE + flPrefix.visibility = GONE + ivSuffix.visibility = GONE + tvSuffix.apply { + visibility = VISIBLE + text = value + } } - } - private var shouldBeEnabled: Boolean = true - set(value) { - field = value - etContent.isEnabled = value + private var shouldBeEnabled: Boolean = true + set(value) { + field = value + etContent.isEnabled = value - if (value) reset() - else disable() - } + if (value) { + reset() + } else { + disable() + } + } - private var hint: String? = null - set(value) { - if (value == null) return - field = value - etContent.hint = value - } + private var hint: String? = null + set(value) { + if (value == null) return + field = value + etContent.hint = value + } - private var inputType: Int = 0 - set(value) { - field = value - etContent.inputType = value - } + private var inputType: Int = 0 + set(value) { + field = value + etContent.inputType = value + } - private var label: String? = null - set(value) { - field = value + private var label: String? = null + set(value) { + field = value - if (value.isNullOrEmpty()) { - tvLabel.visibility = View.GONE - } else { - tvLabel.visibility = View.VISIBLE - tvLabel.text = value + if (value.isNullOrEmpty()) { + tvLabel.visibility = GONE + } else { + tvLabel.visibility = VISIBLE + tvLabel.text = value + } } - } - private var optional: Boolean = false - set(value) { - field = value - tvOptional.visibility = if (value) View.VISIBLE else View.GONE - } + private var optional: Boolean = false + set(value) { + field = value + tvOptional.visibility = if (value) VISIBLE else GONE + } - private var helper: String? = null - set(value) { - field = value + private var helper: String? = null + set(value) { + field = value - if (value.isNullOrEmpty()) { - tvHelper.visibility = View.GONE - } else { - tvHelper.visibility = View.VISIBLE - tvHelper.text = value + if (value.isNullOrEmpty()) { + tvHelper.visibility = GONE + } else { + tvHelper.visibility = VISIBLE + tvHelper.text = value + } } - } - private var labelColor: Int = 0 - set(value) { - field = value - tvLabel.setTextColor(value) - } + private var labelColor: Int = 0 + set(value) { + field = value + tvLabel.setTextColor(value) + } - private var optionalColor: Int = 0 - set(value) { - field = value - tvOptional.setTextColor(value) - } + private var optionalColor: Int = 0 + set(value) { + field = value + tvOptional.setTextColor(value) + } - private var helperColor: Int = 0 - set(value) { - field = value - tvHelper.setTextColor(value) - } + private var helperColor: Int = 0 + set(value) { + field = value + tvHelper.setTextColor(value) + } - val text: String - get() = etContent.text.toString() + val text: String + get() = etContent.text.toString() - private var error: Boolean = false - set(value) { - field = value - if (value) indicateError() - else reset() - } + private var error: Boolean = false + set(value) { + field = value + if (value) { + indicateError() + } else { + reset() + } + } - private var errorMessage: String? = null - set(value) { - field = value - error = value != null - } + private var errorMessage: String? = null + set(value) { + field = value + error = value != null + } - private var enableClear: Boolean = false - set(value) { - field = value - if (!value) { - etContent.doOnTextChanged { _, _, _, _ -> } - ivAction.isVisible = false - } else { - etContent.doOnTextChanged { text, _, _, _ -> - ivAction.isVisible = text?.isNotEmpty() == true + private var enableClear: Boolean = false + set(value) { + field = value + if (!value) { + etContent.doOnTextChanged { _, _, _, _ -> } + ivAction.isVisible = false + } else { + etContent.doOnTextChanged { text, _, _, _ -> + ivAction.isVisible = text?.isNotEmpty() == true + } } } - } - private fun indicateError() { - tfRoot.background = ContextCompat.getDrawable(context, R.drawable.bg_custom_text_field_error) - val redColor = ContextCompat.getColor(context, R.color.ui_red) - tvPrefix.setTextColor(redColor) - etContent.setTextColor(redColor) + private fun indicateError() { + tfRoot.background = ContextCompat.getDrawable(context, R.drawable.bg_custom_text_field_error) + val redColor = ContextCompat.getColor(context, R.color.ui_red) + tvPrefix.setTextColor(redColor) + etContent.setTextColor(redColor) - if (errorMessage != null) { - tvHelper.setTextColor(redColor) - tvHelper.visibility = View.VISIBLE - tvHelper.text = errorMessage + if (errorMessage != null) { + tvHelper.setTextColor(redColor) + tvHelper.visibility = VISIBLE + tvHelper.text = errorMessage + } + + ivPrefix.setColorFilter( + ContextCompat.getColor(context, R.color.ui_red), + PorterDuff.Mode.SRC_IN + ) + ivSuffix.setColorFilter( + ContextCompat.getColor(context, R.color.ui_red), + PorterDuff.Mode.SRC_IN + ) } - ivPrefix.setColorFilter( - ContextCompat.getColor(context, R.color.ui_red), - PorterDuff.Mode.SRC_IN - ) - ivSuffix.setColorFilter( - ContextCompat.getColor(context, R.color.ui_red), - PorterDuff.Mode.SRC_IN - ) - } + private fun reset() { + if (error) { + indicateError() + return + } + + flSuffix.background = + ContextCompat.getDrawable( + context, + R.drawable.bg_custom_text_field_prefix_suffix_regular + ) + flPrefix.background = + ContextCompat.getDrawable( + context, + R.drawable.bg_custom_text_field_prefix_suffix_regular + ) + etContent.setTextColor(ContextCompat.getColor(context, R.color.black_900)) + + if (etContent.hasFocus()) { + focus() + } else { + ivPrefix.colorFilter = null + ivSuffix.colorFilter = null + tfRoot.background = + ContextCompat.getDrawable(context, R.drawable.bg_custom_text_field_regular) + tvPrefix.setTextColor(ContextCompat.getColor(context, R.color.black_500)) + } - private fun reset() { - if (error) { - indicateError() - return + if (helper != null) { + tvHelper.visibility = VISIBLE + tvHelper.text = helper + tvHelper.setTextColor(ContextCompat.getColor(context, R.color.black_500)) + } else { + tvHelper.visibility = GONE + } } - flSuffix.background = ContextCompat.getDrawable( - context, - R.drawable.bg_custom_text_field_prefix_suffix_regular - ) - flPrefix.background = ContextCompat.getDrawable( - context, - R.drawable.bg_custom_text_field_prefix_suffix_regular - ) - etContent.setTextColor(ContextCompat.getColor(context, R.color.black_900)) - - if (etContent.hasFocus()) { - focus() - } else { - ivPrefix.colorFilter = null - ivSuffix.colorFilter = null + private fun disable() { tfRoot.background = - ContextCompat.getDrawable(context, R.drawable.bg_custom_text_field_regular) - tvPrefix.setTextColor(ContextCompat.getColor(context, R.color.black_500)) + ContextCompat.getDrawable(context, R.drawable.bg_custom_text_field_disabled) + flSuffix.background = + ContextCompat.getDrawable( + context, + R.drawable.bg_custom_text_field_prefix_suffix_disabled + ) + flPrefix.background = + ContextCompat.getDrawable( + context, + R.drawable.bg_custom_text_field_prefix_suffix_disabled + ) + etContent.setTextColor(ContextCompat.getColor(context, R.color.black_500)) } - if (helper != null) { - tvHelper.visibility = View.VISIBLE - tvHelper.text = helper - tvHelper.setTextColor(ContextCompat.getColor(context, R.color.black_500)) - } else { - tvHelper.visibility = View.GONE + private lateinit var tfRoot: ConstraintLayout + private lateinit var flPrefix: FrameLayout + private lateinit var tvPrefix: TextView + private lateinit var tvSuffix: TextView + private lateinit var tvLabel: TextView + private lateinit var tvOptional: TextView + private lateinit var tvHelper: TextView + private lateinit var ivPrefix: ImageView + private lateinit var flSuffix: FrameLayout + private lateinit var ivSuffix: ImageView + private lateinit var ivAction: ImageView + private lateinit var etContent: EditText + + init { + LayoutInflater.from(context).inflate(R.layout.layout_custom_text_field, this, true) + initView() + initAttributes(context, attrs) } - } - - private fun disable() { - tfRoot.background = - ContextCompat.getDrawable(context, R.drawable.bg_custom_text_field_disabled) - flSuffix.background = ContextCompat.getDrawable( - context, - R.drawable.bg_custom_text_field_prefix_suffix_disabled - ) - flPrefix.background = ContextCompat.getDrawable( - context, - R.drawable.bg_custom_text_field_prefix_suffix_disabled - ) - etContent.setTextColor(ContextCompat.getColor(context, R.color.black_500)) - } - - private lateinit var tfRoot: ConstraintLayout - private lateinit var flPrefix: FrameLayout - private lateinit var tvPrefix: TextView - private lateinit var tvSuffix: TextView - private lateinit var tvLabel: TextView - private lateinit var tvOptional: TextView - private lateinit var tvHelper: TextView - private lateinit var ivPrefix: ImageView - private lateinit var flSuffix: FrameLayout - private lateinit var ivSuffix: ImageView - private lateinit var ivAction: ImageView - private lateinit var etContent: EditText - - init { - LayoutInflater.from(context).inflate(R.layout.layout_custom_text_field, this, true) - initView() - initAttributes(context, attrs) - } - private fun initAttributes(context: Context, attrs: AttributeSet?) { - context.theme.obtainStyledAttributes( - attrs, - R.styleable.CustomTextField, - 0, - 0 - ).apply { - try { - prefixText = getString(R.styleable.CustomTextField_prefixText) - prefixIcon = getDrawable(R.styleable.CustomTextField_prefixIcon) - suffixText = getString(R.styleable.CustomTextField_suffixText) - suffixIcon = getDrawable(R.styleable.CustomTextField_suffixIcon) - shouldBeEnabled = getBoolean(R.styleable.CustomTextField_android_enabled, true) - hint = getString(R.styleable.CustomTextField_android_hint) - inputType = getInteger(R.styleable.CustomTextField_android_inputType, 1) - enableClear = getBoolean(R.styleable.CustomTextField_enableClear, false) - - val defaultTextColor = context.getColor(R.color.black_500) - label = getString(R.styleable.CustomTextField_label) - optional = getBoolean(R.styleable.CustomTextField_optional, false) - helper = getString(R.styleable.CustomTextField_helper) - labelColor = getColor(R.styleable.CustomTextField_labelColor, defaultTextColor) - optionalColor = getColor(R.styleable.CustomTextField_optionalColor, defaultTextColor) - helperColor = getColor(R.styleable.CustomTextField_helperColor, defaultTextColor) - minLine = getInt(R.styleable.CustomTextField_android_minLines, 1) - maxLine = getInt(R.styleable.CustomTextField_android_maxLines, 1) - maxLength = getInt(R.styleable.CustomTextField_android_maxLength, -1) - } finally { - recycle() + private fun initAttributes( + context: Context, + attrs: AttributeSet? + ) { + context.theme.obtainStyledAttributes( + attrs, + R.styleable.CustomTextField, + 0, + 0 + ).apply { + try { + prefixText = getString(R.styleable.CustomTextField_prefixText) + prefixIcon = getDrawable(R.styleable.CustomTextField_prefixIcon) + suffixText = getString(R.styleable.CustomTextField_suffixText) + suffixIcon = getDrawable(R.styleable.CustomTextField_suffixIcon) + shouldBeEnabled = getBoolean(R.styleable.CustomTextField_android_enabled, true) + hint = getString(R.styleable.CustomTextField_android_hint) + inputType = getInteger(R.styleable.CustomTextField_android_inputType, 1) + enableClear = getBoolean(R.styleable.CustomTextField_enableClear, false) + + val defaultTextColor = context.getColor(R.color.black_500) + label = getString(R.styleable.CustomTextField_label) + optional = getBoolean(R.styleable.CustomTextField_optional, false) + helper = getString(R.styleable.CustomTextField_helper) + labelColor = getColor(R.styleable.CustomTextField_labelColor, defaultTextColor) + optionalColor = getColor(R.styleable.CustomTextField_optionalColor, defaultTextColor) + helperColor = getColor(R.styleable.CustomTextField_helperColor, defaultTextColor) + minLine = getInt(R.styleable.CustomTextField_android_minLines, 1) + maxLine = getInt(R.styleable.CustomTextField_android_maxLines, 1) + maxLength = getInt(R.styleable.CustomTextField_android_maxLength, -1) + } finally { + recycle() + } } } - } - private fun initView() { - tfRoot = findViewById(R.id.tfRoot) - flPrefix = findViewById(R.id.flPrefix) - tvPrefix = findViewById(R.id.tvPrefixText) - ivPrefix = findViewById(R.id.ivPrefixIcon) - tvSuffix = findViewById(R.id.tvSuffixText) - flSuffix = findViewById(R.id.flSuffix) - ivSuffix = findViewById(R.id.ivSuffixIcon) - etContent = findViewById(R.id.etTextField) - tvLabel = findViewById(R.id.ctvLabel) - tvHelper = findViewById(R.id.ctvHelper) - tvOptional = findViewById(R.id.ctvLabelOptional) - ivAction = findViewById(R.id.ivAction) - - etContent.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) focus() - else reset() - } + private fun initView() { + tfRoot = findViewById(R.id.tfRoot) + flPrefix = findViewById(R.id.flPrefix) + tvPrefix = findViewById(R.id.tvPrefixText) + ivPrefix = findViewById(R.id.ivPrefixIcon) + tvSuffix = findViewById(R.id.tvSuffixText) + flSuffix = findViewById(R.id.flSuffix) + ivSuffix = findViewById(R.id.ivSuffixIcon) + etContent = findViewById(R.id.etTextField) + tvLabel = findViewById(R.id.ctvLabel) + tvHelper = findViewById(R.id.ctvHelper) + tvOptional = findViewById(R.id.ctvLabelOptional) + ivAction = findViewById(R.id.ivAction) + + etContent.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + focus() + } else { + reset() + } + } - tfRoot.setOnClickListener { etContent.requestFocus() } - ivAction.setOnClickListener { etContent.setText("") } - } + tfRoot.setOnClickListener { etContent.requestFocus() } + ivAction.setOnClickListener { etContent.setText("") } + } - private fun focus() { - tfRoot.background = - ContextCompat.getDrawable(context, R.drawable.bg_custom_text_field_focused) - tvPrefix.setTextColor(ContextCompat.getColor(context, R.color.turqoise)) - etContent.setTextColor(ContextCompat.getColor(context, R.color.black_900)) - ivPrefix.setColorFilter( - ContextCompat.getColor(context, R.color.turqoise), - PorterDuff.Mode.SRC_IN - ) - ivSuffix.setColorFilter( - ContextCompat.getColor(context, R.color.turqoise), - PorterDuff.Mode.SRC_IN - ) - } + private fun focus() { + tfRoot.background = + ContextCompat.getDrawable(context, R.drawable.bg_custom_text_field_focused) + tvPrefix.setTextColor(ContextCompat.getColor(context, R.color.turqoise)) + etContent.setTextColor(ContextCompat.getColor(context, R.color.black_900)) + ivPrefix.setColorFilter( + ContextCompat.getColor(context, R.color.turqoise), + PorterDuff.Mode.SRC_IN + ) + ivSuffix.setColorFilter( + ContextCompat.getColor(context, R.color.turqoise), + PorterDuff.Mode.SRC_IN + ) + } - fun doOnTextChanged(callback: (text: CharSequence?, start: Int, count: Int, after: Int) -> Unit) { - etContent.doOnTextChanged { text, start, count, after -> - callback(text, start, count, after) + fun doOnTextChanged(callback: (text: CharSequence?, start: Int, count: Int, after: Int) -> Unit) { + etContent.doOnTextChanged { text, start, count, after -> + callback(text, start, count, after) + } } } -} \ No newline at end of file diff --git a/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomTextView.kt b/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomTextView.kt index 326af29..b87e32d 100644 --- a/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomTextView.kt +++ b/custom-ui/src/main/java/com/argahutama/submission/custom_ui/CustomTextView.kt @@ -8,75 +8,88 @@ import androidx.appcompat.widget.AppCompatTextView import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat -class CustomTextView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, -) : AppCompatTextView(context, attrs, defStyleAttr) { - private var size: Int = 0 - set(value) { - field = value - when (value) { - B30 -> setTextSizeAndLineHeight(30, R.dimen.bold_30_line_height) - B24 -> setTextSizeAndLineHeight(24, R.dimen.bold_24_line_height) - B20 -> setTextSizeAndLineHeight(20, R.dimen.bold_20_line_height) - B18 -> setTextSizeAndLineHeight(18, R.dimen.bold_18_line_height) - B16 -> setTextSizeAndLineHeight(16, R.dimen.bold_16_line_height) - B14 -> setTextSizeAndLineHeight(14, R.dimen.bold_14_line_height) - S16 -> setTextSizeAndLineHeight(16, R.dimen.normal_16_line_height) - S14 -> setTextSizeAndLineHeight(14, R.dimen.normal_14_line_height) - S12 -> setTextSizeAndLineHeight(12, R.dimen.normal_12_line_height) +class CustomTextView + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : AppCompatTextView(context, attrs, defStyleAttr) { + private var size: Int = 0 + set(value) { + field = value + when (value) { + B30 -> setTextSizeAndLineHeight(30, R.dimen.bold_30_line_height) + B24 -> setTextSizeAndLineHeight(24, R.dimen.bold_24_line_height) + B20 -> setTextSizeAndLineHeight(20, R.dimen.bold_20_line_height) + B18 -> setTextSizeAndLineHeight(18, R.dimen.bold_18_line_height) + B16 -> setTextSizeAndLineHeight(16, R.dimen.bold_16_line_height) + B14 -> setTextSizeAndLineHeight(14, R.dimen.bold_14_line_height) + S16 -> setTextSizeAndLineHeight(16, R.dimen.normal_16_line_height) + S14 -> setTextSizeAndLineHeight(14, R.dimen.normal_14_line_height) + S12 -> setTextSizeAndLineHeight(12, R.dimen.normal_12_line_height) + } } + + private fun setTextSizeAndLineHeight( + newTextSize: Int, + @DimenRes lineHeightRes: Int + ) { + textSize = newTextSize.toFloat() + lineHeight = context.resources.getDimensionPixelSize(lineHeightRes) } - private fun setTextSizeAndLineHeight(newTextSize: Int, @DimenRes lineHeightRes: Int) { - textSize = newTextSize.toFloat() - lineHeight = context.resources.getDimensionPixelSize(lineHeightRes) - } + init { + val defaultColor = ContextCompat.getColor(context, R.color.black_900) + context.theme.obtainStyledAttributes( + attrs, + R.styleable.CustomTextView, + 0, + 0 + ).apply { + try { + size = getInteger(R.styleable.CustomTextView_size, 0) + val textColor = + getColor(R.styleable.CustomTextView_android_textColor, defaultColor) + setTextColor(textColor) + } finally { + recycle() + } + } + letterSpacing = 0f - init { - val defaultColor = ContextCompat.getColor(context, R.color.black_900) - context.theme.obtainStyledAttributes( - attrs, - R.styleable.CustomTextView, - 0, - 0 - ).apply { - try { - size = getInteger(R.styleable.CustomTextView_size, 0) - val textColor = - getColor(R.styleable.CustomTextView_android_textColor, defaultColor) - setTextColor(textColor) - } finally { - recycle() + if (!isInEditMode) { + if (size <= 5) { + setTypeface(getTypeface(context), Typeface.BOLD) + } else { + setTypeface(getTypeface(context), Typeface.NORMAL) + } + } else { + if (size <= 5) { + setTypeface(typeface, Typeface.BOLD) + } else { + setTypeface(typeface, Typeface.NORMAL) + } } } - letterSpacing = 0f - if (!isInEditMode) { - if (size <= 5) setTypeface(Companion.getTypeface(context), Typeface.BOLD) - else setTypeface(Companion.getTypeface(context), Typeface.NORMAL) - } else { - if (size <= 5) setTypeface(typeface, Typeface.BOLD) - else setTypeface(typeface, Typeface.NORMAL) - } - } + companion object { + private var typefaceInstance: Typeface? = null - companion object { - private var typefaceInstance: Typeface? = null - fun getTypeface(context: Context) = typefaceInstance ?: run { - typefaceInstance = ResourcesCompat.getFont(context, R.font.effra) - typefaceInstance - } + fun getTypeface(context: Context) = + typefaceInstance ?: run { + typefaceInstance = ResourcesCompat.getFont(context, R.font.effra) + typefaceInstance + } - const val B30 = 0 - const val B24 = 1 - const val B20 = 2 - const val B18 = 3 - const val B16 = 4 - const val B14 = 5 - const val S16 = 6 - const val S14 = 7 - const val S12 = 8 + const val B30 = 0 + const val B24 = 1 + const val B20 = 2 + const val B18 = 3 + const val B16 = 4 + const val B14 = 5 + const val S16 = 6 + const val S14 = 7 + const val S12 = 8 + } } -} \ No newline at end of file diff --git a/favorite/src/main/java/com/argahutama/submission/favorite/FavoriteFragment.kt b/favorite/src/main/java/com/argahutama/submission/favorite/FavoriteFragment.kt index 6db7484..c31d309 100644 --- a/favorite/src/main/java/com/argahutama/submission/favorite/FavoriteFragment.kt +++ b/favorite/src/main/java/com/argahutama/submission/favorite/FavoriteFragment.kt @@ -15,11 +15,15 @@ import com.google.android.material.tabs.TabLayoutMediator import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.context.loadKoinModules -class FavoriteFragment: BaseFragment() { +class FavoriteFragment : BaseFragment() { override val viewModel by viewModel() - override fun createBinding() = FragmentFavoriteBinding.inflate(layoutInflater) - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun createBinding() = FragmentFavoriteBinding.inflate(layoutInflater) + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle? + ) { super.onViewCreated(view, savedInstanceState) loadKoinModules(favoriteModule) @@ -56,4 +60,4 @@ class FavoriteFragment: BaseFragment() { override fun initView() {} override fun initAction() {} -} \ No newline at end of file +} diff --git a/favorite/src/main/java/com/argahutama/submission/favorite/FavoriteViewModel.kt b/favorite/src/main/java/com/argahutama/submission/favorite/FavoriteViewModel.kt index 34fd128..69a7805 100644 --- a/favorite/src/main/java/com/argahutama/submission/favorite/FavoriteViewModel.kt +++ b/favorite/src/main/java/com/argahutama/submission/favorite/FavoriteViewModel.kt @@ -9,11 +9,16 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn class FavoriteViewModel(private val movieUseCase: MovieUseCase) : BaseViewModel() { - val favoriteMovies: StateFlow> = movieUseCase.getFavoriteMovies() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val favoriteMovies: StateFlow> = + movieUseCase.getFavoriteMovies() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - val favoriteTvShows: StateFlow> = movieUseCase.getFavoriteTvShows() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val favoriteTvShows: StateFlow> = + movieUseCase.getFavoriteTvShows() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - fun setFavorite(movie: Movie, newState: Boolean) = movieUseCase.setMovieFavorite(movie, newState) + fun setFavorite( + movie: Movie, + newState: Boolean + ) = movieUseCase.setMovieFavorite(movie, newState) } diff --git a/favorite/src/main/java/com/argahutama/submission/favorite/adapter/SectionPagerAdapter.kt b/favorite/src/main/java/com/argahutama/submission/favorite/adapter/SectionPagerAdapter.kt index 0b36cc9..892a2ba 100644 --- a/favorite/src/main/java/com/argahutama/submission/favorite/adapter/SectionPagerAdapter.kt +++ b/favorite/src/main/java/com/argahutama/submission/favorite/adapter/SectionPagerAdapter.kt @@ -10,9 +10,12 @@ import com.argahutama.submission.favorite.tvshow.FavoriteTvShowFragment class SectionPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { @StringRes val tabTitles = intArrayOf(R.string.movies, R.string.tv_shows) + override fun getItemCount() = tabTitles.size - override fun createFragment(position: Int): Fragment = when (position) { - 0 -> FavoriteMovieFragment() - else -> FavoriteTvShowFragment() - } + + override fun createFragment(position: Int): Fragment = + when (position) { + 0 -> FavoriteMovieFragment() + else -> FavoriteTvShowFragment() + } } diff --git a/favorite/src/main/java/com/argahutama/submission/favorite/di/FavoriteModule.kt b/favorite/src/main/java/com/argahutama/submission/favorite/di/FavoriteModule.kt index 5f3b403..6be5cd7 100644 --- a/favorite/src/main/java/com/argahutama/submission/favorite/di/FavoriteModule.kt +++ b/favorite/src/main/java/com/argahutama/submission/favorite/di/FavoriteModule.kt @@ -4,8 +4,9 @@ import com.argahutama.submission.favorite.FavoriteViewModel import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module -val favoriteModule = module { - viewModel { - FavoriteViewModel(get()) +val favoriteModule = + module { + viewModel { + FavoriteViewModel(get()) + } } -} \ No newline at end of file diff --git a/favorite/src/main/java/com/argahutama/submission/favorite/movie/FavoriteMovieFragment.kt b/favorite/src/main/java/com/argahutama/submission/favorite/movie/FavoriteMovieFragment.kt index 5d8b1c1..094af15 100644 --- a/favorite/src/main/java/com/argahutama/submission/favorite/movie/FavoriteMovieFragment.kt +++ b/favorite/src/main/java/com/argahutama/submission/favorite/movie/FavoriteMovieFragment.kt @@ -7,7 +7,6 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.argahutama.submission.core.base.BaseFragment -import com.argahutama.submission.core.domain.model.Movie import com.argahutama.submission.core.navigation.NavigationDirection import com.argahutama.submission.core.ui.MovieAdapter import com.argahutama.submission.favorite.FavoriteViewModel @@ -19,29 +18,36 @@ import org.koin.androidx.viewmodel.ext.android.viewModel class FavoriteMovieFragment : BaseFragment() { private val adapter by lazy { MovieAdapter() } override val viewModel: FavoriteViewModel by viewModel(ownerProducer = { requireParentFragment() }) + override fun createBinding() = FragmentMovieBinding.inflate(layoutInflater) - private val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() { - override fun getMovementFlags( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder - ) = makeMovementFlags(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) + private val itemTouchHelper = + ItemTouchHelper( + object : ItemTouchHelper.Callback() { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ) = makeMovementFlags(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ) = true + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ) = true - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - if (view != null) { - val swipedPosition = viewHolder.bindingAdapterPosition - val movie = adapter.getSwipedData(swipedPosition) - viewModel.setFavorite(movie, !movie.favorite) - showSnackbar(getString(R.string.set_unfavorite)) + override fun onSwiped( + viewHolder: RecyclerView.ViewHolder, + direction: Int + ) { + if (view != null) { + val swipedPosition = viewHolder.bindingAdapterPosition + val movie = adapter.getSwipedData(swipedPosition) + viewModel.setFavorite(movie, !movie.favorite) + showSnackbar(getString(R.string.set_unfavorite)) + } + } } - } - }) + ) override fun setup() { super.setup() @@ -58,13 +64,15 @@ class FavoriteMovieFragment : BaseFragment() { } } - override fun initView() = with(binding as FragmentMovieBinding) { - rvMovies.adapter = adapter - ctfSearch.isVisible = false - } + override fun initView() = + with(binding as FragmentMovieBinding) { + rvMovies.adapter = adapter + ctfSearch.isVisible = false + } - override fun initAction() = with(binding as FragmentMovieBinding) { - adapter.onItemClick = { navigateTo(NavigationDirection.Detail(it)) } - itemTouchHelper.attachToRecyclerView(rvMovies) - } + override fun initAction() = + with(binding as FragmentMovieBinding) { + adapter.onItemClick = { navigateTo(NavigationDirection.Detail(it)) } + itemTouchHelper.attachToRecyclerView(rvMovies) + } } diff --git a/favorite/src/main/java/com/argahutama/submission/favorite/tvshow/FavoriteTvShowFragment.kt b/favorite/src/main/java/com/argahutama/submission/favorite/tvshow/FavoriteTvShowFragment.kt index bb41483..dafff43 100644 --- a/favorite/src/main/java/com/argahutama/submission/favorite/tvshow/FavoriteTvShowFragment.kt +++ b/favorite/src/main/java/com/argahutama/submission/favorite/tvshow/FavoriteTvShowFragment.kt @@ -7,7 +7,6 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.argahutama.submission.core.base.BaseFragment -import com.argahutama.submission.core.domain.model.Movie import com.argahutama.submission.core.navigation.NavigationDirection import com.argahutama.submission.core.ui.MovieAdapter import com.argahutama.submission.favorite.FavoriteViewModel @@ -19,29 +18,36 @@ import org.koin.androidx.viewmodel.ext.android.viewModel class FavoriteTvShowFragment : BaseFragment() { private val adapter by lazy { MovieAdapter() } override val viewModel: FavoriteViewModel by viewModel(ownerProducer = { requireParentFragment() }) + override fun createBinding() = FragmentTvShowBinding.inflate(layoutInflater) - private val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() { - override fun getMovementFlags( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder - ) = makeMovementFlags(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) + private val itemTouchHelper = + ItemTouchHelper( + object : ItemTouchHelper.Callback() { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ) = makeMovementFlags(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ) = true + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ) = true - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - if (view != null) { - val swipedPosition = viewHolder.bindingAdapterPosition - val movie = adapter.getSwipedData(swipedPosition) - viewModel.setFavorite(movie, !movie.favorite) - showSnackbar(getString(R.string.set_unfavorite)) + override fun onSwiped( + viewHolder: RecyclerView.ViewHolder, + direction: Int + ) { + if (view != null) { + val swipedPosition = viewHolder.bindingAdapterPosition + val movie = adapter.getSwipedData(swipedPosition) + viewModel.setFavorite(movie, !movie.favorite) + showSnackbar(getString(R.string.set_unfavorite)) + } + } } - } - }) + ) override fun setup() { super.setup() @@ -58,13 +64,15 @@ class FavoriteTvShowFragment : BaseFragment() { } } - override fun initView() = with(binding as FragmentTvShowBinding) { - rvTvShows.adapter = adapter - ctfSearch.isVisible = false - } + override fun initView() = + with(binding as FragmentTvShowBinding) { + rvTvShows.adapter = adapter + ctfSearch.isVisible = false + } - override fun initAction() = with(binding as FragmentTvShowBinding) { - adapter.onItemClick = { navigateTo(NavigationDirection.Detail(it)) } - itemTouchHelper.attachToRecyclerView(rvTvShows) - } + override fun initAction() = + with(binding as FragmentTvShowBinding) { + adapter.onItemClick = { navigateTo(NavigationDirection.Detail(it)) } + itemTouchHelper.attachToRecyclerView(rvTvShows) + } } diff --git a/favorite/src/test/java/com/argahutama/submission/favorite/FavoriteViewModelTest.kt b/favorite/src/test/java/com/argahutama/submission/favorite/FavoriteViewModelTest.kt index 1ca6d5e..230c504 100644 --- a/favorite/src/test/java/com/argahutama/submission/favorite/FavoriteViewModelTest.kt +++ b/favorite/src/test/java/com/argahutama/submission/favorite/FavoriteViewModelTest.kt @@ -10,20 +10,28 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @ExperimentalCoroutinesApi class FavoriteViewModelTest { - private val testDispatcher = UnconfinedTestDispatcher() private lateinit var useCase: MovieUseCase private val movie = Movie(1, "Overview", "en", "2024-01-01", 9.5, 7.8, "Movie", 100, "/poster.jpg", favorite = true) - private val tvShow = Movie(2, "TV Overview", "ko", "2023-05-10", 8.0, 8.5, "TV Show", 200, "/tv.jpg", favorite = true, isTvShows = true) + private val tvShow = + Movie( + 2, "TV Overview", "ko", "2023-05-10", 8.0, 8.5, "TV Show", 200, "/tv.jpg", + favorite = true, isTvShows = true, + ) @Before fun setUp() { @@ -53,38 +61,40 @@ class FavoriteViewModelTest { } @Test - fun `favoriteMovies emits data from use case`() = runTest(testDispatcher) { - val favoriteList = listOf(movie) - every { useCase.getFavoriteMovies() } returns flowOf(favoriteList) - every { useCase.getFavoriteTvShows() } returns emptyFlow() - val vm = FavoriteViewModel(useCase) - - val collected = mutableListOf>() - val job = launch { vm.favoriteMovies.collect { collected.add(it) } } - advanceUntilIdle() - job.cancel() - - assertTrue(collected.any { it.isNotEmpty() }) - assertEquals(favoriteList, collected.last()) - assertTrue(collected.last().all { it.favorite }) - } + fun `favoriteMovies emits data from use case`() = + runTest(testDispatcher) { + val favoriteList = listOf(movie) + every { useCase.getFavoriteMovies() } returns flowOf(favoriteList) + every { useCase.getFavoriteTvShows() } returns emptyFlow() + val vm = FavoriteViewModel(useCase) + + val collected = mutableListOf>() + val job = launch { vm.favoriteMovies.collect { collected.add(it) } } + advanceUntilIdle() + job.cancel() + + assertTrue(collected.any { it.isNotEmpty() }) + assertEquals(favoriteList, collected.last()) + assertTrue(collected.last().all { it.favorite }) + } @Test - fun `favoriteTvShows emits data from use case`() = runTest(testDispatcher) { - every { useCase.getFavoriteMovies() } returns emptyFlow() - val tvShowList = listOf(tvShow) - every { useCase.getFavoriteTvShows() } returns flowOf(tvShowList) - val vm = FavoriteViewModel(useCase) - - val collected = mutableListOf>() - val job = launch { vm.favoriteTvShows.collect { collected.add(it) } } - advanceUntilIdle() - job.cancel() - - assertTrue(collected.any { it.isNotEmpty() }) - assertEquals(tvShowList, collected.last()) - assertTrue(collected.last().all { it.isTvShows }) - } + fun `favoriteTvShows emits data from use case`() = + runTest(testDispatcher) { + every { useCase.getFavoriteMovies() } returns emptyFlow() + val tvShowList = listOf(tvShow) + every { useCase.getFavoriteTvShows() } returns flowOf(tvShowList) + val vm = FavoriteViewModel(useCase) + + val collected = mutableListOf>() + val job = launch { vm.favoriteTvShows.collect { collected.add(it) } } + advanceUntilIdle() + job.cancel() + + assertTrue(collected.any { it.isNotEmpty() }) + assertEquals(tvShowList, collected.last()) + assertTrue(collected.last().all { it.isTvShows }) + } @Test fun `setFavorite marks item as favorite via use case`() { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c4a3944..f5bef6c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ lifecycle = "2.9.0" koin = "3.5.6" featureDelivery = "2.1.0" splashscreen = "1.0.1" +ktlint = "12.1.2" [libraries] androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } @@ -70,3 +71,4 @@ android-dynamic-feature = { id = "com.android.dynamic-feature", version.ref = "a kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } From 0814a4e250a4011aba5d0af71e685902d83a1104 Mon Sep 17 00:00:00 2001 From: Arga Hutama Date: Thu, 11 Jun 2026 19:58:56 +0700 Subject: [PATCH 2/2] disable Jetifier in gradle.properties --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 98bed16..dbc9506 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,6 +16,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true +android.enableJetifier=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official \ No newline at end of file