├── .gitignore
├── .idea
└── .gitignore
├── README.md
├── app
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── developersancho
│ │ │ └── hb
│ │ │ ├── app
│ │ │ ├── HBApp.kt
│ │ │ └── widgets
│ │ │ │ └── SearchEditTextExtension.kt
│ │ │ ├── di
│ │ │ └── AppModule.kt
│ │ │ └── features
│ │ │ ├── detail
│ │ │ ├── DetailContract.kt
│ │ │ ├── DetailFragment.kt
│ │ │ └── DetailViewModel.kt
│ │ │ ├── main
│ │ │ └── MainActivity.kt
│ │ │ └── search
│ │ │ ├── SearchAdapter.kt
│ │ │ ├── SearchContract.kt
│ │ │ ├── SearchFilterType.kt
│ │ │ ├── SearchFragment.kt
│ │ │ └── SearchViewModel.kt
│ └── res
│ │ ├── color
│ │ └── selector_radio_gray_chip_text_color.xml
│ │ ├── drawable-v24
│ │ └── ic_launcher_foreground.xml
│ │ ├── drawable
│ │ ├── bg_search_bar.xml
│ │ ├── bg_search_bar_light.xml
│ │ ├── ic_baseline_arrow_left_24.xml
│ │ ├── ic_close_circle_gray.xml
│ │ ├── ic_close_circle_red_tint.xml
│ │ ├── ic_error_image.xml
│ │ ├── ic_launcher_background.xml
│ │ ├── ic_search_bar_small.xml
│ │ ├── ic_search_bar_small_gray.xml
│ │ └── selector_radio_gray_chip_background_color.xml
│ │ ├── layout
│ │ ├── activity_main.xml
│ │ ├── fragment_detail.xml
│ │ ├── fragment_search.xml
│ │ └── item_search.xml
│ │ ├── mipmap-anydpi-v26
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ │ ├── mipmap-hdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-mdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxxhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── values
│ │ ├── colors.xml
│ │ ├── dimens.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ │ └── xml
│ │ ├── backup_rules.xml
│ │ └── data_extraction_rules.xml
│ └── test
│ └── java
│ └── com
│ └── developersancho
│ └── hb
│ ├── ExampleUnitTest.kt
│ └── features
│ ├── detail
│ ├── DetailContractTest.kt
│ └── DetailViewModelTest.kt
│ └── search
│ ├── SearchContractTest.kt
│ └── SearchViewModelTest.kt
├── appcompose
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── developersancho
│ │ │ └── hb
│ │ │ └── compose
│ │ │ ├── app
│ │ │ ├── HBApp.kt
│ │ │ ├── extensions
│ │ │ │ ├── ClickableSingle.kt
│ │ │ │ ├── NavArgsExtensions.kt
│ │ │ │ ├── RememberFlow.kt
│ │ │ │ ├── SystemUi.kt
│ │ │ │ └── TimedVisibility.kt
│ │ │ ├── theme
│ │ │ │ ├── Color.kt
│ │ │ │ ├── Layout.kt
│ │ │ │ ├── Shape.kt
│ │ │ │ ├── Theme.kt
│ │ │ │ └── Type.kt
│ │ │ └── widgets
│ │ │ │ ├── EmptyView.kt
│ │ │ │ ├── HBToolbar.kt
│ │ │ │ ├── LoadingView.kt
│ │ │ │ ├── ProgressIndicator.kt
│ │ │ │ └── SegmentedControl.kt
│ │ │ ├── di
│ │ │ └── AppModule.kt
│ │ │ └── features
│ │ │ ├── detail
│ │ │ ├── DetailContract.kt
│ │ │ ├── DetailScreen.kt
│ │ │ ├── DetailViewModel.kt
│ │ │ └── view
│ │ │ │ └── DetailTextRow.kt
│ │ │ ├── main
│ │ │ ├── MainActivity.kt
│ │ │ └── MainRoot.kt
│ │ │ └── search
│ │ │ ├── SearchContract.kt
│ │ │ ├── SearchFilterType.kt
│ │ │ ├── SearchScreen.kt
│ │ │ ├── SearchViewModel.kt
│ │ │ └── view
│ │ │ ├── SearchRow.kt
│ │ │ └── SearchTextField.kt
│ └── res
│ │ ├── drawable-v24
│ │ └── ic_launcher_foreground.xml
│ │ ├── drawable
│ │ ├── bg_search_bar.xml
│ │ ├── bg_search_bar_light.xml
│ │ ├── ic_baseline_arrow_left_24.xml
│ │ ├── ic_close_circle_gray.xml
│ │ ├── ic_close_circle_red_tint.xml
│ │ ├── ic_error_image.xml
│ │ ├── ic_launcher_background.xml
│ │ ├── ic_search_bar_small.xml
│ │ └── ic_search_bar_small_gray.xml
│ │ ├── mipmap-anydpi-v26
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ │ ├── mipmap-hdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-mdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxxhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ └── values
│ │ ├── colors.xml
│ │ ├── dimens.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ └── test
│ └── java
│ └── com
│ └── developersancho
│ └── hb
│ └── compose
│ ├── ExampleUnitTest.kt
│ └── features
│ ├── detail
│ ├── DetailContractTest.kt
│ └── DetailViewModelTest.kt
│ └── search
│ ├── SearchContractTest.kt
│ └── SearchViewModelTest.kt
├── art
├── architecture.png
├── clean_arch.jpeg
├── project.png
└── screenshots
│ ├── compose-detail.png
│ ├── compose-search.png
│ ├── detail.png
│ └── search.png
├── build.gradle.kts
├── buildSrc
├── .gitignore
├── build.gradle.kts
└── src
│ └── main
│ └── java
│ ├── com
│ └── developersancho
│ │ └── buildsrc
│ │ ├── Configs.kt
│ │ ├── Libs.kt
│ │ └── Versions.kt
│ └── plugins
│ └── DependencyUpdatePlugin.kt
├── data
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ ├── main
│ ├── AndroidManifest.xml
│ └── java
│ │ └── com
│ │ └── developersancho
│ │ └── data
│ │ ├── model
│ │ ├── dto
│ │ │ └── SearchItemDto.kt
│ │ └── remote
│ │ │ ├── SearchItem.kt
│ │ │ └── SearchResponse.kt
│ │ ├── remote
│ │ ├── di
│ │ │ └── RemoteModule.kt
│ │ └── service
│ │ │ └── SearchService.kt
│ │ └── repository
│ │ ├── SearchRepository.kt
│ │ └── di
│ │ └── RepositoryModule.kt
│ └── test
│ └── java
│ └── com
│ └── developersancho
│ └── data
│ ├── model
│ ├── dto
│ │ └── SearchItemDtoTest.kt
│ └── remote
│ │ ├── SearchItemTest.kt
│ │ └── SearchResponseTest.kt
│ ├── remote
│ └── SearchServiceTest.kt
│ └── repository
│ └── SearchRepositoryTest.kt
├── domain
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ ├── main
│ ├── AndroidManifest.xml
│ └── java
│ │ └── com
│ │ └── developersancho
│ │ └── domain
│ │ ├── di
│ │ └── DomainModule.kt
│ │ └── search
│ │ ├── Search.kt
│ │ └── SearchPagingSource.kt
│ └── test
│ └── java
│ └── com
│ └── developersancho
│ └── domain
│ └── SearchTest.kt
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── libraries
├── framework
│ ├── .gitignore
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ └── src
│ │ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── com
│ │ │ └── developersancho
│ │ │ └── framework
│ │ │ ├── base
│ │ │ ├── BaseActivity.kt
│ │ │ ├── BaseExtension.kt
│ │ │ ├── BaseFragment.kt
│ │ │ ├── BindingViewHolder.kt
│ │ │ ├── mvi
│ │ │ │ ├── BaseMviFragment.kt
│ │ │ │ ├── BaseViewState.kt
│ │ │ │ └── MviViewModel.kt
│ │ │ └── mvvm
│ │ │ │ └── MvvmViewModel.kt
│ │ │ ├── extensions
│ │ │ ├── AnyExtension.kt
│ │ │ ├── DateExtension.kt
│ │ │ ├── EditTextExtension.kt
│ │ │ ├── FragmentExtension.kt
│ │ │ ├── KeyboardExtension.kt
│ │ │ ├── LifecycleOwnerExtension.kt
│ │ │ ├── MoshiExtension.kt
│ │ │ ├── RecyclerViewExtension.kt
│ │ │ ├── ResourceExtension.kt
│ │ │ ├── SnackbarExtension.kt
│ │ │ ├── ToastExtension.kt
│ │ │ ├── VariableExtension.kt
│ │ │ ├── ViewBindingExtension.kt
│ │ │ └── ViewExtension.kt
│ │ │ ├── network
│ │ │ ├── ApiCallExtension.kt
│ │ │ ├── DataState.kt
│ │ │ ├── HandleError.kt
│ │ │ ├── HttpStatusCode.kt
│ │ │ └── interceptor
│ │ │ │ └── HttpRequestInterceptor.kt
│ │ │ └── usecase
│ │ │ ├── RequestPagingUseCase.kt
│ │ │ └── RequestUseCase.kt
│ │ └── test
│ │ └── java
│ │ └── com
│ │ └── developersancho
│ │ └── framework
│ │ └── ExampleUnitTest.kt
├── navigation
│ ├── .gitignore
│ ├── build.gradle.kts
│ └── src
│ │ └── main
│ │ ├── AndroidManifest.xml
│ │ ├── java
│ │ └── com
│ │ │ └── developersancho
│ │ │ └── navigation
│ │ │ ├── AnimationType.kt
│ │ │ ├── FragNav.kt
│ │ │ ├── FragNavExtension.kt
│ │ │ └── TransitionType.kt
│ │ └── res
│ │ ├── anim
│ │ ├── fragnav_anim_in.xml
│ │ ├── fragnav_anim_in_from_pop.xml
│ │ ├── fragnav_anim_out.xml
│ │ ├── fragnav_anim_out_from_pop.xml
│ │ ├── fragnav_anim_scale_in.xml
│ │ ├── fragnav_anim_scale_in_from_pop.xml
│ │ ├── fragnav_anim_scale_out.xml
│ │ ├── fragnav_anim_scale_out_from_pop.xml
│ │ ├── fragnav_anim_vertical_in_from_pop_long.xml
│ │ ├── fragnav_anim_vertical_in_long.xml
│ │ ├── fragnav_anim_vertical_out_from_pop_long.xml
│ │ ├── fragnav_anim_vertical_out_long.xml
│ │ ├── fragnav_slide_in_left.xml
│ │ ├── fragnav_slide_in_right.xml
│ │ ├── fragnav_slide_out_left.xml
│ │ └── fragnav_slide_out_right.xml
│ │ └── values
│ │ └── ids.xml
└── testutils
│ ├── .gitignore
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ └── src
│ └── main
│ ├── AndroidManifest.xml
│ └── java
│ └── com
│ └── developersancho
│ └── testutils
│ ├── BaseServiceTest.kt
│ ├── MockkUnitTest.kt
│ ├── TestCoroutineRule.kt
│ └── TestRobolectric.kt
└── settings.gradle.kts
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 | /.idea/.name
17 | /.idea/compiler.xml
18 | /.idea/gradle.xml
19 | /.idea/kotlinScripting.xml
20 | /.idea/misc.xml
21 | /.idea/vcs.xml
22 | /.idea/deploymentTargetDropDown.xml
23 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import com.developersancho.buildsrc.*
2 |
3 | plugins {
4 | id("com.android.application")
5 | id("org.jetbrains.kotlin.android")
6 | id("org.jetbrains.kotlin.plugin.parcelize")
7 | id("com.google.devtools.ksp")
8 | id("org.jetbrains.kotlin.kapt")
9 | id("dagger.hilt.android.plugin")
10 | }
11 |
12 | android {
13 | compileSdk = Configs.CompileSdk
14 |
15 | defaultConfig {
16 | applicationId = Configs.ApplicationId
17 | minSdk = Configs.MinSdk
18 | targetSdk = Configs.TargetSdk
19 | versionCode = Configs.VersionCode
20 | versionName = Configs.VersionName
21 | testInstrumentationRunner = Configs.TestInstrumentationRunner
22 | vectorDrawables.useSupportLibrary = true
23 | buildConfigField("String", "BASE_URL", "\"https://itunes.apple.com/\"")
24 | }
25 |
26 | buildTypes {
27 | release {
28 | isMinifyEnabled = false
29 | proguardFiles(
30 | getDefaultProguardFile("proguard-android-optimize.txt"),
31 | "proguard-rules.pro"
32 | )
33 | }
34 | }
35 | compileOptions {
36 | sourceCompatibility = JavaVersion.VERSION_11
37 | targetCompatibility = JavaVersion.VERSION_11
38 | }
39 | kotlinOptions {
40 | jvmTarget = JavaVersion.VERSION_11.toString()
41 | freeCompilerArgs = listOf(
42 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
43 | "-opt-in=kotlinx.coroutines.FlowPreview"
44 | )
45 | }
46 | buildFeatures {
47 | viewBinding = true
48 | }
49 | }
50 |
51 | android.applicationVariants.all {
52 | val variantName = name
53 | kotlin.sourceSets {
54 | getByName("main") {
55 | kotlin.srcDir(File("build/generated/ksp/$variantName/kotlin"))
56 | }
57 | }
58 | }
59 |
60 | dependencies {
61 | implementation(project(mapOf("path" to ":data")))
62 | implementation(project(mapOf("path" to ":domain")))
63 | implementation(project(mapOf("path" to ":libraries:framework")))
64 | implementation(project(mapOf("path" to ":libraries:navigation")))
65 | testImplementation(project(mapOf("path" to ":libraries:testutils")))
66 |
67 | implementation(SupportLib.CoreKtx)
68 | implementation(SupportLib.Appcompat)
69 | implementation(SupportLib.Material)
70 | implementation(SupportLib.ConstraintLayout)
71 |
72 | implementation(SupportLib.Recyclerview)
73 |
74 | implementation(DaggerHiltLib.Android)
75 | kapt(DaggerHiltLib.Compiler)
76 |
77 | implementation(SupportLib.ActivityKtx)
78 | implementation(SupportLib.FragmentKtx)
79 |
80 | implementation(SupportLib.Coil)
81 | implementation(SupportLib.Timber)
82 |
83 | implementation(SupportLib.CoroutineCore)
84 | implementation(SupportLib.CoroutineAndroid)
85 |
86 | implementation(NetworkLib.Moshi)
87 | ksp(NetworkLib.MoshiCodegen)
88 | implementation(NetworkLib.Retrofit)
89 | implementation(NetworkLib.RetrofitMoshi)
90 | implementation(NetworkLib.Okhttp)
91 | implementation(NetworkLib.LoggingInterceptor)
92 | debugImplementation(NetworkLib.ChuckerDebug)
93 | releaseImplementation(NetworkLib.ChuckerRelease)
94 |
95 | implementation(StorageLib.RoomKtx)
96 | ksp(StorageLib.RoomCompiler)
97 |
98 | implementation(SupportLib.LifecycleViewModel)
99 | implementation(SupportLib.LifecycleRuntime)
100 | implementation(SupportLib.Paging)
101 |
102 | implementation(SupportLib.SwipeRefreshLayout)
103 |
104 | }
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.kts.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
10 |
21 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/app/src/main/java/com/developersancho/hb/app/HBApp.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.app
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 |
6 | @HiltAndroidApp
7 | class HBApp : Application()
--------------------------------------------------------------------------------
/app/src/main/java/com/developersancho/hb/app/widgets/SearchEditTextExtension.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.app.widgets
2 |
3 | import android.annotation.SuppressLint
4 | import android.view.MotionEvent
5 | import android.view.View
6 | import androidx.appcompat.widget.AppCompatEditText
7 | import androidx.core.widget.addTextChangedListener
8 | import com.developersancho.framework.extensions.orZero
9 | import com.developersancho.hb.R
10 |
11 | private const val DRAWABLE_RIGHT = 2
12 |
13 | fun AppCompatEditText.onSearchQueryRightIconChanged(isLight: Boolean = false) {
14 | addTextChangedListener {
15 | val searchBar = if (isLight) {
16 | R.drawable.ic_search_bar_small_gray
17 | } else {
18 | R.drawable.ic_search_bar_small
19 | }
20 |
21 | val closeCircle = if (isLight) {
22 | R.drawable.ic_close_circle_gray
23 | } else {
24 | R.drawable.ic_close_circle_red_tint
25 | }
26 |
27 | if (it.toString().isEmpty()) {
28 | setCompoundDrawablesWithIntrinsicBounds(searchBar, 0, 0, 0)
29 | } else {
30 | setCompoundDrawablesWithIntrinsicBounds(
31 | searchBar,
32 | 0,
33 | closeCircle,
34 | 0
35 | )
36 | }
37 | }
38 |
39 | setOnTouchListener(object : View.OnTouchListener {
40 | @SuppressLint("ClickableViewAccessibility")
41 | override fun onTouch(v: View?, event: MotionEvent?): Boolean {
42 | if (event?.action == MotionEvent.ACTION_UP) {
43 | if (event.rawX >= (
44 | right - compoundDrawables[DRAWABLE_RIGHT]?.bounds?.width()
45 | .orZero()
46 | ) &&
47 | text?.isEmpty() == false
48 | ) {
49 | this@onSearchQueryRightIconChanged.text?.clear()
50 | return true
51 | }
52 | }
53 | return false
54 | }
55 | })
56 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/developersancho/hb/di/AppModule.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.di
2 |
3 | import com.developersancho.hb.BuildConfig
4 | import dagger.Module
5 | import dagger.Provides
6 | import dagger.hilt.InstallIn
7 | import dagger.hilt.components.SingletonComponent
8 | import javax.inject.Named
9 |
10 | @Module
11 | @InstallIn(SingletonComponent::class)
12 | class AppModule {
13 | @Provides
14 | @Named("base-url")
15 | fun provideBaseUrl(): String = BuildConfig.BASE_URL
16 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/developersancho/hb/features/detail/DetailContract.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.features.detail
2 |
3 | import com.developersancho.data.model.dto.SearchItemDto
4 |
5 | data class DetailViewState(
6 | val dto: SearchItemDto? = null
7 | )
8 |
9 | sealed class DetailEvent {
10 | object LoadDetail : DetailEvent()
11 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/developersancho/hb/features/detail/DetailFragment.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.features.detail
2 |
3 | import android.annotation.SuppressLint
4 | import android.os.Bundle
5 | import androidx.fragment.app.viewModels
6 | import coil.load
7 | import coil.size.Scale
8 | import com.developersancho.data.model.dto.SearchItemDto
9 | import com.developersancho.framework.base.mvi.BaseMviFragment
10 | import com.developersancho.framework.base.mvi.BaseViewState
11 | import com.developersancho.framework.extensions.cast
12 | import com.developersancho.framework.extensions.toReleaseDate
13 | import com.developersancho.framework.extensions.visible
14 | import com.developersancho.framework.extensions.withArgs
15 | import com.developersancho.hb.R
16 | import com.developersancho.hb.databinding.FragmentDetailBinding
17 | import dagger.hilt.android.AndroidEntryPoint
18 |
19 | @AndroidEntryPoint
20 | class DetailFragment : BaseMviFragment() {
21 | companion object {
22 | const val ARG_SEARCH_ITEM_DTO = "arg_search_item_dto"
23 |
24 | @JvmStatic
25 | fun newInstance(searchItemDto: SearchItemDto) = DetailFragment().withArgs {
26 | putParcelable(ARG_SEARCH_ITEM_DTO, searchItemDto)
27 | }
28 | }
29 |
30 | override val viewModel: DetailViewModel by viewModels()
31 |
32 | override fun onViewReady(bundle: Bundle?) {
33 | viewModel.onTriggerEvent(DetailEvent.LoadDetail)
34 | }
35 |
36 | override fun renderViewState(viewState: BaseViewState<*>) {
37 | when (viewState) {
38 | is BaseViewState.Empty -> Unit
39 | is BaseViewState.Error -> Unit
40 | is BaseViewState.Loading -> Unit
41 | is BaseViewState.Success -> {
42 | val data = viewState.cast>().data
43 | val dto = data.dto
44 | setDetailInformation(dto)
45 | }
46 | }
47 | }
48 |
49 | @SuppressLint("SetTextI18n")
50 | private fun setDetailInformation(dto: SearchItemDto?) {
51 | binding.ivArtNetwork.load(dto?.artworkUrl100) {
52 | scale(Scale.FILL)
53 | error(R.drawable.ic_error_image)
54 | }
55 | dto?.trackName?.let {
56 | binding.clTrackName.visible()
57 | binding.tvTrackName.text = it
58 | }
59 | dto?.collectionName?.let {
60 | binding.clCollectionName.visible()
61 | binding.tvCollectionName.text = it
62 | }
63 | dto?.artistName?.let {
64 | binding.clArtistName.visible()
65 | binding.tvArtistName.text = it
66 | }
67 | dto?.description?.let {
68 | binding.clDescription.visible()
69 | binding.tvDescription.text = it
70 | }
71 | dto?.collectionPrice?.let {
72 | binding.clPrice.visible()
73 | binding.tvPrice.text = "${it}-${dto.currency}"
74 | }
75 | dto?.releaseDate?.let {
76 | binding.clReleaseDate.visible()
77 | binding.tvReleaseDate.text = it.toReleaseDate()
78 | }
79 | }
80 |
81 | override fun onViewListener() {
82 | super.onViewListener()
83 | binding.toolbar.setNavigationOnClickListener {
84 | activity?.onBackPressed()
85 | }
86 | }
87 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/developersancho/hb/features/detail/DetailViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.features.detail
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import com.developersancho.data.model.dto.SearchItemDto
5 | import com.developersancho.framework.base.mvi.BaseViewState
6 | import com.developersancho.framework.base.mvi.MviViewModel
7 | import dagger.hilt.android.lifecycle.HiltViewModel
8 | import javax.inject.Inject
9 |
10 | @HiltViewModel
11 | class DetailViewModel @Inject constructor(savedStateHandle: SavedStateHandle) :
12 | MviViewModel, DetailEvent>() {
13 |
14 | private val searchItemDto: SearchItemDto? = savedStateHandle[DetailFragment.ARG_SEARCH_ITEM_DTO]
15 |
16 | override fun onTriggerEvent(eventType: DetailEvent) {
17 | when (eventType) {
18 | DetailEvent.LoadDetail -> loadDetail()
19 | }
20 | }
21 |
22 | private fun loadDetail() {
23 | setState(BaseViewState.Success(DetailViewState(dto = searchItemDto)))
24 | }
25 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/developersancho/hb/features/main/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.features.main
2 |
3 | import android.os.Bundle
4 | import com.developersancho.framework.base.BaseActivity
5 | import com.developersancho.framework.extensions.showSnackBar
6 | import com.developersancho.hb.R
7 | import com.developersancho.hb.databinding.ActivityMainBinding
8 | import com.developersancho.hb.features.search.SearchFragment
9 | import com.developersancho.navigation.navigateFragment
10 | import dagger.hilt.android.AndroidEntryPoint
11 | import kotlinx.coroutines.delay
12 |
13 | @AndroidEntryPoint
14 | class MainActivity : BaseActivity() {
15 |
16 | private var backPressedOnce = false
17 |
18 | override fun onViewReady(bundle: Bundle?) {
19 | navigateFragment(SearchFragment.newInstance(), clearBackStack = true)
20 | }
21 |
22 | override fun onBackPressed() {
23 | if (supportFragmentManager.backStackEntryCount > 1) {
24 | super.onBackPressed()
25 | } else {
26 | if (backPressedOnce) {
27 | finish()
28 | return
29 | }
30 | backPressedOnce = true
31 | showSnackBar(binding.rootView, getString(R.string.app_exit_label))
32 |
33 | safeLaunch {
34 | delay(2000)
35 | backPressedOnce = false
36 | }
37 | }
38 | }
39 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/developersancho/hb/features/search/SearchAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.features.search
2 |
3 | import android.annotation.SuppressLint
4 | import android.view.ViewGroup
5 | import androidx.paging.PagingDataAdapter
6 | import androidx.recyclerview.widget.DiffUtil
7 | import androidx.recyclerview.widget.RecyclerView
8 | import coil.load
9 | import coil.size.Scale
10 | import coil.transform.RoundedCornersTransformation
11 | import com.developersancho.data.model.dto.SearchItemDto
12 | import com.developersancho.framework.base.BindingViewHolder
13 | import com.developersancho.framework.extensions.cast
14 | import com.developersancho.framework.extensions.toBinding
15 | import com.developersancho.framework.extensions.toReleaseDate
16 | import com.developersancho.hb.R
17 | import com.developersancho.hb.databinding.ItemSearchBinding
18 |
19 | class SearchAdapter : PagingDataAdapter(SearchDiffUtil) {
20 | companion object SearchDiffUtil : DiffUtil.ItemCallback() {
21 | override fun areItemsTheSame(oldItem: SearchItemDto, newItem: SearchItemDto) =
22 | oldItem.trackId == newItem.trackId && oldItem.collectionId == newItem.collectionId
23 | && oldItem.trackName == newItem.trackName && oldItem.collectionName == newItem.collectionName
24 | && oldItem.artistName == newItem.artistName && oldItem.artworkUrl100 == newItem.artworkUrl100
25 |
26 | override fun areContentsTheSame(oldItem: SearchItemDto, newItem: SearchItemDto) =
27 | oldItem == newItem
28 | }
29 |
30 | var onClickItem: ((SearchItemDto) -> Unit)? = null
31 |
32 | inner class SearchViewHolder(binding: ItemSearchBinding) :
33 | BindingViewHolder(binding) {
34 |
35 | @SuppressLint("SetTextI18n")
36 | fun bind(item: SearchItemDto) {
37 | binding.ivArtNetwork.load(item.artworkUrl100.toString()) {
38 | error(R.drawable.ic_error_image)
39 | }
40 | binding.tvCollectionName.text = item.collectionName
41 | binding.tvCollectionPrice.text = "${item.collectionPrice}-${item.currency}"
42 | binding.tvReleaseDate.text = item.releaseDate?.toReleaseDate()
43 | binding.root.setOnClickListener {
44 | onClickItem?.invoke(item)
45 | }
46 | }
47 | }
48 |
49 | override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
50 | val searchItem = getItem(position)
51 | searchItem?.let { holder.cast().bind(it) }
52 | }
53 |
54 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
55 | return SearchViewHolder(parent.toBinding())
56 | }
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/developersancho/hb/features/search/SearchContract.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.features.search
2 |
3 | import androidx.paging.PagingData
4 | import com.developersancho.data.model.dto.SearchItemDto
5 | import kotlinx.coroutines.flow.Flow
6 | import kotlinx.coroutines.flow.emptyFlow
7 |
8 | data class SearchViewState(
9 | val pagedData: PagingData = PagingData.empty()
10 | )
11 |
12 | sealed class SearchEvent {
13 | data class SearchByText(val keyword: String?) : SearchEvent()
14 | data class SearchByFilterType(val filterType: SearchFilterType? = null) : SearchEvent()
15 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/developersancho/hb/features/search/SearchFilterType.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.features.search
2 |
3 | enum class SearchFilterType(val type: String) {
4 | MOVIE("movie"),
5 | MUSIC("song"),
6 | EBOOK("ebook"),
7 | PODCAST("podcast");
8 |
9 | companion object {
10 | fun from(findType: String?): SearchFilterType? =
11 | values().firstOrNull { it.type == findType }
12 | }
13 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/developersancho/hb/features/search/SearchFragment.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.features.search
2 |
3 | import android.os.Bundle
4 | import androidx.fragment.app.viewModels
5 | import androidx.lifecycle.lifecycleScope
6 | import com.developersancho.framework.base.mvi.BaseMviFragment
7 | import com.developersancho.framework.base.mvi.BaseViewState
8 | import com.developersancho.framework.extensions.*
9 | import com.developersancho.hb.app.widgets.onSearchQueryRightIconChanged
10 | import com.developersancho.hb.databinding.FragmentSearchBinding
11 | import com.developersancho.hb.features.detail.DetailFragment
12 | import com.developersancho.navigation.AnimationType
13 | import com.developersancho.navigation.navigateFragment
14 | import dagger.hilt.android.AndroidEntryPoint
15 | import kotlinx.coroutines.flow.debounce
16 | import kotlinx.coroutines.flow.launchIn
17 | import kotlinx.coroutines.flow.onEach
18 |
19 | @AndroidEntryPoint
20 | class SearchFragment : BaseMviFragment() {
21 | companion object {
22 | @JvmStatic
23 | fun newInstance() = SearchFragment()
24 | }
25 |
26 | private val adapter = SearchAdapter()
27 |
28 | override val viewModel: SearchViewModel by viewModels()
29 |
30 | override fun onViewReady(bundle: Bundle?) {
31 | initAdapter()
32 | binding.etSearch.onSearchQueryRightIconChanged()
33 | binding.etSearch.hideKeyboard()
34 | }
35 |
36 | private fun initAdapter() {
37 | binding.rvSearch.setHasFixedSize(true)
38 | binding.rvSearch.setItemDecoration(left = 8, top = 12, right = 8, bottom = 0)
39 | binding.rvSearch.adapter = adapter
40 | adapter.onClickItem = {
41 | val detail = DetailFragment.newInstance(it)
42 | navigateFragment(detail, animation = AnimationType.DEFAULT)
43 | }
44 | }
45 |
46 | override fun renderViewState(viewState: BaseViewState<*>) {
47 | when (viewState) {
48 | is BaseViewState.Empty -> {
49 | // todo: can be develop empty view
50 | binding.pbLoading.gone()
51 | }
52 | is BaseViewState.Error -> {
53 | // todo: can be develop error view
54 | binding.pbLoading.gone()
55 | }
56 | is BaseViewState.Loading -> {
57 | // todo: can be develop loading view
58 | binding.pbLoading.visible()
59 | }
60 | is BaseViewState.Success -> {
61 | binding.pbLoading.gone()
62 | val data = viewState.cast>().data
63 | adapter.submitData(lifecycle, data.pagedData)
64 | }
65 | }
66 | }
67 |
68 | override fun onViewListener() {
69 | super.onViewListener()
70 | binding.etSearch.textChanges()
71 | .debounce(300)
72 | .onEach { text ->
73 | takeIf { viewModel.searchKeyword != text.toString() }?.let {
74 | viewModel.onTriggerEvent(SearchEvent.SearchByText(keyword = text.toString()))
75 | }
76 | }
77 | .launchIn(lifecycleScope)
78 |
79 | binding.rbMovie.setOnClickListener {
80 | viewModel.onTriggerEvent(
81 | SearchEvent.SearchByFilterType(filterType = SearchFilterType.MOVIE)
82 | )
83 | }
84 |
85 | binding.rbMusic.setOnClickListener {
86 | viewModel.onTriggerEvent(
87 | SearchEvent.SearchByFilterType(filterType = SearchFilterType.MUSIC)
88 | )
89 | }
90 |
91 | binding.rbEbook.setOnClickListener {
92 | viewModel.onTriggerEvent(
93 | SearchEvent.SearchByFilterType(filterType = SearchFilterType.EBOOK)
94 | )
95 | }
96 |
97 | binding.rbPodcast.setOnClickListener {
98 | viewModel.onTriggerEvent(
99 | SearchEvent.SearchByFilterType(filterType = SearchFilterType.PODCAST)
100 | )
101 | }
102 | }
103 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/developersancho/hb/features/search/SearchViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.features.search
2 |
3 | import androidx.lifecycle.viewModelScope
4 | import androidx.paging.cachedIn
5 | import com.developersancho.domain.search.Search
6 | import com.developersancho.framework.base.mvi.BaseViewState
7 | import com.developersancho.framework.base.mvi.MviViewModel
8 | import dagger.hilt.android.lifecycle.HiltViewModel
9 | import kotlinx.coroutines.flow.collectLatest
10 | import javax.inject.Inject
11 |
12 | @HiltViewModel
13 | class SearchViewModel @Inject constructor(
14 | private val search: Search
15 | ) : MviViewModel, SearchEvent>() {
16 |
17 | companion object {
18 | private const val MAX_SEARCH_LENGTH = 3
19 | }
20 |
21 | var searchFilterType: SearchFilterType? = null
22 | var searchKeyword: String? = null
23 |
24 | override fun onTriggerEvent(eventType: SearchEvent) {
25 | when (eventType) {
26 | is SearchEvent.SearchByText -> searchByText(eventType.keyword)
27 | is SearchEvent.SearchByFilterType -> searchByFilterType(eventType.filterType)
28 | }
29 | }
30 |
31 | private fun searchByText(keyword: String?) = safeLaunch {
32 | if (keyword.orEmpty().length < MAX_SEARCH_LENGTH) return@safeLaunch
33 | searchKeyword = keyword
34 | setState(BaseViewState.Loading)
35 | val params = Search.Params(searchKeyword, searchFilterType?.type)
36 | search(params).cachedIn(scope = viewModelScope).collectLatest {
37 | setState(BaseViewState.Success(SearchViewState(pagedData = it)))
38 | }
39 | }
40 |
41 | private fun searchByFilterType(filterType: SearchFilterType?) = safeLaunch {
42 | if (searchKeyword.orEmpty().length < MAX_SEARCH_LENGTH) return@safeLaunch
43 | searchFilterType = filterType
44 | setState(BaseViewState.Loading)
45 | val params = Search.Params(searchKeyword, searchFilterType?.type)
46 | search(params).cachedIn(scope = viewModelScope).collectLatest {
47 | setState(BaseViewState.Success(SearchViewState(pagedData = it)))
48 | }
49 | }
50 | }
--------------------------------------------------------------------------------
/app/src/main/res/color/selector_radio_gray_chip_text_color.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/bg_search_bar.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | -
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/bg_search_bar_light.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | -
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_baseline_arrow_left_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
14 |
21 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_close_circle_gray.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_close_circle_red_tint.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_error_image.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_search_bar_small.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_search_bar_small_gray.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/selector_radio_gray_chip_background_color.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | -
4 |
5 |
6 |
7 |
8 |
9 | -
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_search.xml:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
17 |
18 |
27 |
28 |
40 |
41 |
52 |
53 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
11 | #D13438
12 | #982626
13 | #8B1C1C
14 | #6A1616
15 | #E05454
16 | #EB9191
17 | #FACFCF
18 | #FFEBEB
19 |
20 | #F8F8F8
21 | #F1F1F1
22 | #ECECEC
23 | #E1E1E1
24 | #C8C8C8
25 | #ACACAC
26 | #919191
27 | #6E6E6E
28 | #535353
29 | #303030
30 | #292929
31 | #212121
32 | #141414
33 |
34 | #00000000
35 |
36 | #F5F2F5
37 |
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 14sp
4 | 16sp
5 |
6 | 40dp
7 | 26dp
8 | 20dp
9 | 16dp
10 | 12dp
11 | 8dp
12 | 4dp
13 | 2dp
14 |
15 | 45dp
16 |
17 | 95dp
18 | 37dp
19 |
20 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | HB Case
3 |
4 | Hello blank fragment
5 |
6 | Press BACK again to exit
7 | Search
8 | Movie
9 | Music
10 | Ebook
11 | Podcast
12 | Detail
13 | Track
14 | Collection
15 | Artist
16 | Description
17 | Price
18 | Release Date
19 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/test/java/com/developersancho/hb/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/developersancho/hb/features/detail/DetailContractTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.features.detail
2 |
3 | import org.junit.Assert
4 | import org.junit.Test
5 |
6 | class DetailContractTest {
7 | private lateinit var event: DetailEvent
8 |
9 | private lateinit var state: DetailViewState
10 |
11 | @Test
12 | fun verifyEvent_LoadDetail() {
13 | event = DetailEvent.LoadDetail
14 |
15 | val eventLoadDetail = event as DetailEvent.LoadDetail
16 | Assert.assertEquals(event, eventLoadDetail)
17 | }
18 |
19 | @Test
20 | fun verifyState_DetailViewState() {
21 | state = DetailViewState(null)
22 |
23 | Assert.assertNull(state.dto)
24 | }
25 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/developersancho/hb/features/detail/DetailViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.features.detail
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import app.cash.turbine.test
5 | import com.developersancho.data.model.dto.SearchItemDto
6 | import com.developersancho.framework.base.mvi.BaseViewState
7 | import com.developersancho.testutils.MockkUnitTest
8 | import com.google.common.truth.Truth
9 | import kotlinx.coroutines.test.runTest
10 | import org.junit.Test
11 |
12 | class DetailViewModelTest : MockkUnitTest() {
13 |
14 | private fun callViewModel(
15 | savedStateHandle: SavedStateHandle = SavedStateHandle()
16 | ): DetailViewModel {
17 | return DetailViewModel(savedStateHandle)
18 | }
19 |
20 | @Test
21 | fun verifyOnTriggerEvent_LoadDetail() = runTest {
22 | // Arrange (Given)
23 | val dto = SearchItemDto.init()
24 | val savedStateHandle = SavedStateHandle(mapOf(DetailFragment.ARG_SEARCH_ITEM_DTO to dto))
25 | val viewModel = callViewModel(savedStateHandle)
26 |
27 | // Act (When)
28 | viewModel.onTriggerEvent(DetailEvent.LoadDetail)
29 |
30 | // Assert (Then)
31 | viewModel.uiState.test {
32 | awaitItem().apply {
33 | Truth.assertThat(this).isNotNull()
34 | Truth.assertThat(this).isInstanceOf(BaseViewState::class.java)
35 | }
36 | }
37 | }
38 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/developersancho/hb/features/search/SearchContractTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.features.search
2 |
3 | import androidx.paging.PagingData
4 | import com.developersancho.data.model.dto.SearchItemDto
5 | import org.junit.Assert
6 | import org.junit.Test
7 |
8 | class SearchContractTest {
9 | private lateinit var event: SearchEvent
10 |
11 | private lateinit var state: SearchViewState
12 |
13 | @Test
14 | fun verifyEvent_SearchByText() {
15 | event = SearchEvent.SearchByText(keyword = "John")
16 |
17 | val eventSearchByText = event as SearchEvent.SearchByText
18 | Assert.assertEquals(event, eventSearchByText)
19 | }
20 |
21 | @Test
22 | fun verifyEvent_SearchByFilterType() {
23 | event = SearchEvent.SearchByFilterType(filterType = SearchFilterType.MUSIC)
24 |
25 | val eventSearchByFilterType = event as SearchEvent.SearchByFilterType
26 | Assert.assertEquals(event, eventSearchByFilterType)
27 | }
28 |
29 | @Test
30 | fun verifyState_SearchViewState() {
31 | val pagedData: PagingData = PagingData.empty()
32 | state = SearchViewState(pagedData)
33 |
34 | Assert.assertEquals(pagedData, state.pagedData)
35 | }
36 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/developersancho/hb/features/search/SearchViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.features.search
2 |
3 | import androidx.paging.PagingData
4 | import com.developersancho.data.model.dto.SearchItemDto
5 | import com.developersancho.domain.search.Search
6 | import com.developersancho.testutils.MockkUnitTest
7 | import io.mockk.coVerify
8 | import io.mockk.every
9 | import io.mockk.impl.annotations.InjectMockKs
10 | import io.mockk.impl.annotations.MockK
11 | import io.mockk.impl.annotations.SpyK
12 | import kotlinx.coroutines.flow.flow
13 | import kotlinx.coroutines.test.runTest
14 | import org.junit.Assert
15 | import org.junit.Test
16 |
17 | class SearchViewModelTest : MockkUnitTest() {
18 |
19 | @MockK(relaxed = true)
20 | lateinit var search: Search
21 |
22 | @SpyK
23 | @InjectMockKs
24 | lateinit var viewModel: SearchViewModel
25 |
26 |
27 | @Test
28 | fun verifyOnTriggerEvent_SearchByText() = runTest {
29 | // Arrange (Given)
30 | val searchKeyword = "John"
31 | every { search.invoke(any()) } returns flow {
32 | emit(PagingData.from(listOf(SearchItemDto.init())))
33 | }
34 |
35 | // Act (When)
36 | viewModel.onTriggerEvent(SearchEvent.SearchByText(searchKeyword))
37 |
38 | // Assert (Then)
39 | coVerify { search.invoke(any()) }
40 | }
41 |
42 | @Test
43 | fun verifyOnTriggerEvent_SearchByFilterType() = runTest {
44 | // Arrange (Given)
45 | viewModel.searchKeyword = "John"
46 | every { search.invoke(any()) } returns flow {
47 | emit(PagingData.from(listOf(SearchItemDto.init())))
48 | }
49 |
50 | // Act (When)
51 | viewModel.onTriggerEvent(SearchEvent.SearchByFilterType(SearchFilterType.MUSIC))
52 |
53 | // Assert (Then)
54 | coVerify { search.invoke(any()) }
55 | Assert.assertNotNull(viewModel.searchKeyword)
56 | Assert.assertNotNull(viewModel.searchFilterType)
57 | }
58 | }
--------------------------------------------------------------------------------
/appcompose/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/appcompose/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/appcompose/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
10 |
18 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/HBApp.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 |
6 | @HiltAndroidApp
7 | class HBApp : Application()
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/extensions/ClickableSingle.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.extensions
2 |
3 | import androidx.compose.foundation.LocalIndication
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.interaction.MutableInteractionSource
6 | import androidx.compose.runtime.remember
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.composed
9 | import androidx.compose.ui.platform.debugInspectorInfo
10 | import androidx.compose.ui.semantics.Role
11 |
12 | internal interface MultipleEventsCutter {
13 | fun processEvent(event: () -> Unit)
14 |
15 | companion object
16 | }
17 |
18 | internal fun MultipleEventsCutter.Companion.get(): MultipleEventsCutter =
19 | MultipleEventsCutterImpl()
20 |
21 | private class MultipleEventsCutterImpl : MultipleEventsCutter {
22 | private val now: Long
23 | get() = System.currentTimeMillis()
24 |
25 | private var lastEventTimeMs: Long = 0
26 |
27 | override fun processEvent(event: () -> Unit) {
28 | if (now - lastEventTimeMs >= 300L) {
29 | event.invoke()
30 | }
31 | lastEventTimeMs = now
32 | }
33 | }
34 |
35 | fun Modifier.clickableSingle(
36 | enabled: Boolean = true,
37 | onClickLabel: String? = null,
38 | role: Role? = null,
39 | onClick: () -> Unit
40 | ) = composed(
41 | inspectorInfo = debugInspectorInfo {
42 | name = "clickable"
43 | properties["enabled"] = enabled
44 | properties["onClickLabel"] = onClickLabel
45 | properties["role"] = role
46 | properties["onClick"] = onClick
47 | }
48 | ) {
49 | val multipleEventsCutter = remember { MultipleEventsCutter.get() }
50 | Modifier.clickable(
51 | enabled = enabled,
52 | onClickLabel = onClickLabel,
53 | onClick = { multipleEventsCutter.processEvent { onClick() } },
54 | role = role,
55 | indication = LocalIndication.current,
56 | interactionSource = remember { MutableInteractionSource() }
57 | )
58 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/extensions/NavArgsExtensions.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.extensions
2 |
3 | import com.developersancho.data.model.dto.SearchItemDto
4 |
5 |
6 | data class SearchItemNavArgs(
7 | val trackId: Int?,
8 | val trackName: String?,
9 | val collectionPrice: Double?,
10 | val artworkUrl100: String?,
11 | val releaseDate: String?,
12 | val collectionName: String?,
13 | val collectionId: Int?,
14 | val currency: String?,
15 | val description: String?,
16 | val artistName: String?
17 | )
18 |
19 | fun SearchItemDto.toSearchItemNavArgs() = SearchItemNavArgs(
20 | trackId,
21 | trackName,
22 | collectionPrice,
23 | artworkUrl100,
24 | releaseDate,
25 | collectionName,
26 | collectionId,
27 | currency,
28 | description,
29 | artistName
30 | )
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/extensions/RememberFlow.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.extensions
2 |
3 | import android.annotation.SuppressLint
4 | import androidx.compose.runtime.*
5 | import androidx.compose.runtime.saveable.rememberSaveable
6 | import androidx.compose.ui.platform.LocalLifecycleOwner
7 | import androidx.lifecycle.Lifecycle
8 | import androidx.lifecycle.flowWithLifecycle
9 | import androidx.lifecycle.repeatOnLifecycle
10 | import kotlinx.coroutines.flow.Flow
11 | import kotlinx.coroutines.flow.StateFlow
12 | import kotlinx.coroutines.flow.collectLatest
13 |
14 | @Composable
15 | fun rememberFlowWithLifecycle(
16 | flow: Flow,
17 | lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
18 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED
19 | ): Flow = remember(flow, lifecycle) {
20 | flow.flowWithLifecycle(
21 | lifecycle = lifecycle,
22 | minActiveState = minActiveState
23 | )
24 | }
25 |
26 | @Composable
27 | fun rememberSaveableFlowWithLifecycle(
28 | flow: Flow,
29 | lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
30 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED
31 | ): Flow = rememberSaveable(flow, lifecycle) {
32 | flow.flowWithLifecycle(
33 | lifecycle = lifecycle,
34 | minActiveState = minActiveState
35 | )
36 | }
37 |
38 | @SuppressLint("StateFlowValueCalledInComposition") // only used as initial value
39 | @Composable
40 | fun rememberFlowWithLifecycle(
41 | stateFlow: StateFlow,
42 | lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
43 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED
44 | ): State = rememberFlowWithLifecycle(
45 | flow = stateFlow,
46 | lifecycle = lifecycle,
47 | minActiveState = minActiveState
48 | ).collectAsState(initial = stateFlow.value)
49 |
50 | @SuppressLint("ComposableNaming")
51 | @Composable
52 | fun collectEvent(
53 | flow: Flow,
54 | lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
55 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
56 | collector: suspend (T) -> Unit
57 | ): Unit = LaunchedEffect(lifecycle, flow) {
58 | lifecycle.repeatOnLifecycle(minActiveState) {
59 | flow.collectLatest {
60 | collector(it)
61 | }
62 | }
63 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/extensions/SystemUi.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.extensions
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.SideEffect
5 | import androidx.compose.ui.graphics.Color
6 | import com.google.accompanist.systemuicontroller.SystemUiController
7 |
8 | @Composable
9 | fun SetupSystemUi(
10 | systemUiController: SystemUiController,
11 | systemColor: Color
12 | ) {
13 | SideEffect {
14 | systemUiController.setStatusBarColor(color = systemColor)
15 | }
16 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/extensions/TimedVisibility.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.extensions
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.animation.fadeIn
5 | import androidx.compose.animation.fadeOut
6 | import androidx.compose.runtime.*
7 | import androidx.compose.ui.Modifier
8 | import kotlinx.coroutines.delay
9 | import kotlinx.coroutines.launch
10 |
11 | /**
12 | * Delays visibility of given [content] for [delayMillis].
13 | */
14 | @Composable
15 | fun Delayed(
16 | modifier: Modifier = Modifier,
17 | delayMillis: Long = 200,
18 | content: @Composable () -> Unit
19 | ) {
20 | TimedVisibility(
21 | delayMillis = delayMillis,
22 | visibility = false,
23 | modifier = modifier,
24 | content = content
25 | )
26 | }
27 |
28 | /**
29 | * Changes visibility of given [content] after [delayMillis] to opposite of initial [visibility].
30 | */
31 | @Composable
32 | fun TimedVisibility(
33 | modifier: Modifier = Modifier,
34 | delayMillis: Long = 4000,
35 | visibility: Boolean = true,
36 | content: @Composable () -> Unit
37 | ) {
38 | var visible by remember { mutableStateOf(visibility) }
39 | val coroutine = rememberCoroutineScope()
40 |
41 | DisposableEffect(Unit) {
42 | val job = coroutine.launch {
43 | delay(delayMillis)
44 | visible = !visible
45 | }
46 |
47 | onDispose {
48 | job.cancel()
49 | }
50 | }
51 | AnimatedVisibility(visible = visible, modifier = modifier, enter = fadeIn(), exit = fadeOut()) {
52 | content()
53 | }
54 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple200 = Color(0xFFBB86FC)
6 | val Purple500 = Color(0xFF6200EE)
7 | val Purple700 = Color(0xFF3700B3)
8 | val Teal200 = Color(0xFF03DAC5)
9 |
10 | val red_primary = Color(0xFFD13438)
11 | val app_bg_color = Color(0xFFF5F2F5)
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/theme/Layout.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.theme
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.Alignment
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.composed
8 | import androidx.compose.ui.platform.LocalConfiguration
9 | import androidx.compose.ui.unit.Dp
10 | import androidx.compose.ui.unit.dp
11 | import androidx.compose.ui.unit.isFinite
12 |
13 | object Layout {
14 |
15 | val bodyMargin: Dp
16 | @Composable get() = when (LocalConfiguration.current.screenWidthDp) {
17 | in 0..599 -> 16.dp
18 | in 600..904 -> 32.dp
19 | in 905..1239 -> 0.dp
20 | in 1240..1439 -> 200.dp
21 | else -> 0.dp
22 | }
23 |
24 | val gutter: Dp
25 | @Composable get() = when (LocalConfiguration.current.screenWidthDp) {
26 | in 0..599 -> 8.dp
27 | in 600..904 -> 16.dp
28 | in 905..1239 -> 16.dp
29 | in 1240..1439 -> 32.dp
30 | else -> 32.dp
31 | }
32 |
33 | val bodyMaxWidth: Dp
34 | @Composable get() = when (LocalConfiguration.current.screenWidthDp) {
35 | in 0..599 -> Dp.Infinity
36 | in 600..904 -> Dp.Infinity
37 | in 905..1239 -> 840.dp
38 | in 1240..1439 -> Dp.Infinity
39 | else -> 1040.dp
40 | }
41 |
42 | val columns: Int
43 | @Composable get() = when (LocalConfiguration.current.screenWidthDp) {
44 | in 0..599 -> 4
45 | in 600..904 -> 8
46 | else -> 12
47 | }
48 | }
49 |
50 | fun Modifier.bodyWidth() = fillMaxWidth()
51 | .wrapContentWidth(align = Alignment.CenterHorizontally)
52 | .composed {
53 | val bodyMaxWidth = Layout.bodyMaxWidth
54 | if (bodyMaxWidth.isFinite) widthIn(max = bodyMaxWidth) else this
55 | }
56 | .composed {
57 | padding(
58 | WindowInsets.systemBars
59 | .only(WindowInsetsSides.Horizontal)
60 | .asPaddingValues()
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/theme/Shape.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.theme
2 |
3 | import androidx.compose.foundation.shape.RoundedCornerShape
4 | import androidx.compose.material.Shapes
5 | import androidx.compose.ui.unit.dp
6 |
7 | val Shapes = Shapes(
8 | small = RoundedCornerShape(4.dp),
9 | medium = RoundedCornerShape(4.dp),
10 | large = RoundedCornerShape(8.dp)
11 | )
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.theme
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.material.MaterialTheme
5 | import androidx.compose.material.darkColors
6 | import androidx.compose.material.lightColors
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.graphics.Color
9 |
10 | private val DarkColorPalette = darkColors(
11 | primary = Purple200,
12 | primaryVariant = Purple700,
13 | secondary = Teal200
14 | )
15 |
16 | private val LightColorPalette = lightColors(
17 | primary = red_primary,
18 | primaryVariant = red_primary,
19 | secondary = Teal200,
20 | onSecondary = Color.Black,
21 | background = app_bg_color,
22 | onPrimary = Color.Black,
23 | onBackground = app_bg_color,
24 | /* Other default colors to override
25 |
26 | surface = Color.White,
27 | onPrimary = Color.White,
28 | onSecondary = Color.Black,
29 |
30 | onSurface = Color.Black,
31 | */
32 | )
33 |
34 | @Composable
35 | fun HBCaseTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
36 | val colors = if (darkTheme) {
37 | DarkColorPalette
38 | } else {
39 | LightColorPalette
40 | }
41 |
42 | MaterialTheme(
43 | colors = LightColorPalette,
44 | typography = Typography,
45 | shapes = Shapes,
46 | content = content
47 | )
48 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.theme
2 |
3 | import androidx.compose.material.Typography
4 | import androidx.compose.ui.graphics.Color
5 | import androidx.compose.ui.text.TextStyle
6 | import androidx.compose.ui.text.font.FontFamily
7 | import androidx.compose.ui.text.font.FontWeight
8 | import androidx.compose.ui.unit.sp
9 |
10 | // Set of Material typography styles to start with
11 | val Typography = Typography(
12 | body1 = TextStyle(
13 | fontFamily = FontFamily.Default,
14 | fontWeight = FontWeight.Normal,
15 | fontSize = 16.sp,
16 | color = Color.Black
17 | )
18 | /* Other default text styles to override
19 | button = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.W500,
22 | fontSize = 14.sp
23 | ),
24 | caption = TextStyle(
25 | fontFamily = FontFamily.Default,
26 | fontWeight = FontWeight.Normal,
27 | fontSize = 12.sp
28 | )
29 | */
30 | )
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/widgets/EmptyView.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.widgets
2 |
3 | import android.content.res.Configuration
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.material.Icon
9 | import androidx.compose.material.MaterialTheme
10 | import androidx.compose.material.Text
11 | import androidx.compose.material.icons.Icons
12 | import androidx.compose.material.icons.filled.HourglassEmpty
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.graphics.vector.rememberVectorPainter
17 | import androidx.compose.ui.res.stringResource
18 | import androidx.compose.ui.text.style.TextAlign
19 | import androidx.compose.ui.tooling.preview.Preview
20 | import com.developersancho.hb.compose.R
21 | import com.developersancho.hb.compose.app.theme.HBCaseTheme
22 | import com.developersancho.hb.compose.app.theme.red_primary
23 |
24 | @Composable
25 | fun EmptyView(modifier: Modifier = Modifier) {
26 | Column(
27 | modifier = modifier
28 | .fillMaxSize(),
29 | verticalArrangement = Arrangement.Center,
30 | horizontalAlignment = Alignment.CenterHorizontally
31 | ) {
32 | Icon(
33 | painter = rememberVectorPainter(Icons.Default.HourglassEmpty),
34 | contentDescription = null,
35 | tint = red_primary,
36 | modifier = modifier
37 | )
38 | Text(
39 | text = stringResource(id = R.string.text_no_data_found),
40 | style = MaterialTheme.typography.h3,
41 | textAlign = TextAlign.Center,
42 | modifier = modifier
43 | .fillMaxWidth()
44 | )
45 | }
46 | }
47 |
48 | @Preview(showBackground = true, name = "Light Mode")
49 | @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode")
50 | @Composable
51 | fun EmptyPageViewPreview() {
52 | HBCaseTheme {
53 | EmptyView()
54 | }
55 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/widgets/HBToolbar.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.widgets
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material.*
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.filled.ArrowBack
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.graphics.Color
13 | import androidx.compose.ui.graphics.vector.rememberVectorPainter
14 | import androidx.compose.ui.res.stringResource
15 | import androidx.compose.ui.text.style.TextAlign
16 | import androidx.compose.ui.unit.Dp
17 | import androidx.compose.ui.unit.dp
18 | import androidx.compose.ui.unit.sp
19 |
20 | @Composable
21 | fun HBToolbar(
22 | @StringRes titleResId: Int,
23 | elevation: Dp = AppBarDefaults.TopAppBarElevation
24 | ) {
25 | TopAppBar(
26 | title = {
27 | Text(
28 | stringResource(titleResId),
29 | textAlign = TextAlign.Center,
30 | modifier = Modifier.fillMaxWidth(),
31 | style = MaterialTheme.typography.h2
32 | )
33 | },
34 | backgroundColor = MaterialTheme.colors.primary,
35 | modifier = Modifier.fillMaxWidth(),
36 | elevation = elevation
37 | )
38 | }
39 |
40 | @Composable
41 | fun HBToolbarWithNavIcon(
42 | @StringRes titleResId: Int,
43 | pressOnBack: () -> Unit
44 | ) {
45 | TopAppBar(
46 | title = {
47 | Text(
48 | stringResource(titleResId),
49 | textAlign = TextAlign.Start,
50 | modifier = Modifier.fillMaxWidth(),
51 | color = Color.White,
52 | style = MaterialTheme.typography.subtitle1.copy(fontSize = 20.sp)
53 | )
54 | },
55 | navigationIcon = {
56 | Icon(
57 | rememberVectorPainter(Icons.Filled.ArrowBack),
58 | contentDescription = null,
59 | tint = Color.White,
60 | modifier = Modifier
61 | .padding(8.dp)
62 | .clickable { pressOnBack.invoke() }
63 | )
64 | },
65 | backgroundColor = MaterialTheme.colors.primary,
66 | modifier = Modifier.fillMaxWidth()
67 | )
68 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/widgets/LoadingView.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.widgets
2 |
3 | import android.content.res.Configuration
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Alignment
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.tooling.preview.Preview
10 | import com.developersancho.hb.compose.app.extensions.Delayed
11 | import com.developersancho.hb.compose.app.theme.HBCaseTheme
12 |
13 | @Composable
14 | fun LoadingView(modifier: Modifier = Modifier, delayMillis: Long = 100L) {
15 | Delayed(delayMillis = delayMillis) {
16 | Box(
17 | contentAlignment = Alignment.Center,
18 | modifier = when (modifier == Modifier) {
19 | true -> Modifier.fillMaxSize()
20 | false -> modifier
21 | }
22 | ) {
23 | ProgressIndicator()
24 | }
25 | }
26 | }
27 |
28 | @Preview(
29 | showBackground = true,
30 | name = "Light Mode"
31 | )
32 | @Preview(
33 | showBackground = true,
34 | uiMode = Configuration.UI_MODE_NIGHT_YES,
35 | name = "Dark Mode"
36 | )
37 | @Composable
38 | fun LoadingViewPreview() {
39 | HBCaseTheme {
40 | LoadingView()
41 | }
42 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/app/widgets/ProgressIndicator.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.app.widgets
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.foundation.layout.size
6 | import androidx.compose.material.CircularProgressIndicator
7 | import androidx.compose.material.MaterialTheme
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Alignment
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.unit.Dp
13 | import androidx.compose.ui.unit.dp
14 | import com.developersancho.hb.compose.app.extensions.Delayed
15 |
16 | object ProgressIndicatorDefaults {
17 | val sizeLarge = 32.dp to 2.dp
18 | val sizeMedium = 24.dp to 1.5.dp
19 | val sizeSmall = 16.dp to 1.dp
20 | val size = 48.dp to 4.dp
21 | }
22 |
23 | @Composable
24 | fun ProgressIndicatorSmall(modifier: Modifier = Modifier) =
25 | ProgressIndicator(
26 | modifier,
27 | ProgressIndicatorDefaults.sizeSmall.first,
28 | ProgressIndicatorDefaults.sizeSmall.second
29 | )
30 |
31 | @Composable
32 | fun ProgressIndicatorMedium(modifier: Modifier = Modifier) =
33 | ProgressIndicator(
34 | modifier,
35 | ProgressIndicatorDefaults.sizeMedium.first,
36 | ProgressIndicatorDefaults.sizeMedium.second
37 | )
38 |
39 | @Composable
40 | fun ProgressIndicator(modifier: Modifier = Modifier) =
41 | ProgressIndicator(
42 | modifier,
43 | ProgressIndicatorDefaults.sizeLarge.first,
44 | ProgressIndicatorDefaults.sizeLarge.second
45 | )
46 |
47 | @Composable
48 | fun ProgressIndicator(
49 | modifier: Modifier = Modifier,
50 | size: Dp = ProgressIndicatorDefaults.size.first,
51 | strokeWidth: Dp = ProgressIndicatorDefaults.size.second,
52 | color: Color = MaterialTheme.colors.secondary,
53 | ) {
54 | CircularProgressIndicator(modifier.size(size), color, strokeWidth)
55 | }
56 |
57 | private const val FULL_SCREEN_LOADING_DELAY = 100L
58 |
59 | @Composable
60 | fun FullScreenLoading(
61 | modifier: Modifier = Modifier,
62 | delayMillis: Long = FULL_SCREEN_LOADING_DELAY
63 | ) {
64 | Delayed(delayMillis = delayMillis) {
65 | Box(
66 | contentAlignment = Alignment.Center,
67 | modifier = when (modifier == Modifier) {
68 | true -> Modifier.fillMaxSize()
69 | false -> modifier
70 | }
71 | ) {
72 | ProgressIndicator()
73 | }
74 | }
75 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/di/AppModule.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.di
2 |
3 | import com.developersancho.hb.compose.BuildConfig
4 | import dagger.Module
5 | import dagger.Provides
6 | import dagger.hilt.InstallIn
7 | import dagger.hilt.components.SingletonComponent
8 | import javax.inject.Named
9 |
10 | @Module
11 | @InstallIn(SingletonComponent::class)
12 | class AppModule {
13 | @Provides
14 | @Named("base-url")
15 | fun provideBaseUrl(): String = BuildConfig.BASE_URL
16 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/features/detail/DetailContract.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.features.detail
2 |
3 | import com.developersancho.hb.compose.app.extensions.SearchItemNavArgs
4 |
5 | data class DetailViewState(
6 | val dto: SearchItemNavArgs? = null
7 | )
8 |
9 | sealed class DetailEvent {
10 | object LoadDetail : DetailEvent()
11 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/features/detail/DetailViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.features.detail
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import com.developersancho.framework.base.mvi.BaseViewState
5 | import com.developersancho.framework.base.mvi.MviViewModel
6 | import com.developersancho.hb.compose.app.extensions.SearchItemNavArgs
7 | import com.developersancho.hb.compose.features.navArgs
8 | import dagger.hilt.android.lifecycle.HiltViewModel
9 | import javax.inject.Inject
10 |
11 | @HiltViewModel
12 | class DetailViewModel @Inject constructor(savedStateHandle: SavedStateHandle) :
13 | MviViewModel, DetailEvent>() {
14 |
15 | private val searchItemDto = savedStateHandle.navArgs() as SearchItemNavArgs
16 |
17 | override fun onTriggerEvent(eventType: DetailEvent) {
18 | when (eventType) {
19 | DetailEvent.LoadDetail -> loadDetail()
20 | }
21 | }
22 |
23 | private fun loadDetail() {
24 | setState(BaseViewState.Success(DetailViewState(dto = searchItemDto)))
25 | }
26 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/features/detail/view/DetailTextRow.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.features.detail.view
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.foundation.lazy.LazyColumn
5 | import androidx.compose.material.MaterialTheme
6 | import androidx.compose.material.Text
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.text.style.TextAlign
11 | import androidx.compose.ui.text.style.TextOverflow
12 | import androidx.compose.ui.tooling.preview.Preview
13 | import androidx.compose.ui.unit.dp
14 | import com.developersancho.hb.compose.app.theme.HBCaseTheme
15 |
16 | @Composable
17 | fun DetailTextRow(key: String, value: String) {
18 | Row(
19 | modifier = Modifier
20 | .fillMaxWidth()
21 | .padding(top = 12.dp, bottom = 12.dp, start = 8.dp, end = 8.dp),
22 | horizontalArrangement = Arrangement.SpaceAround,
23 | verticalAlignment = Alignment.CenterVertically,
24 | ) {
25 | Text(
26 | text = key,
27 | maxLines = 1,
28 | overflow = TextOverflow.Visible,
29 | style = MaterialTheme.typography.subtitle2,
30 | textAlign = TextAlign.Start
31 | )
32 |
33 | Text(
34 | text = value,
35 | maxLines = 2,
36 | overflow = TextOverflow.Ellipsis,
37 | style = MaterialTheme.typography.body2,
38 | textAlign = TextAlign.End,
39 | modifier = Modifier
40 | .fillMaxWidth(),
41 | )
42 | }
43 | }
44 |
45 | @Preview(showBackground = true)
46 | @Composable
47 | fun DetailTextRowPreview() {
48 | HBCaseTheme {
49 | LazyColumn {
50 | items(4) {
51 | DetailTextRow(key = "key", value = "value")
52 | }
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/features/main/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.features.main
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import com.developersancho.framework.extensions.toast
7 | import com.developersancho.hb.compose.R
8 | import dagger.hilt.android.AndroidEntryPoint
9 |
10 | @AndroidEntryPoint
11 | class MainActivity : ComponentActivity() {
12 | private var backPressed = 0L
13 |
14 | private val finish: () -> Unit = {
15 | if (backPressed + 3000 > System.currentTimeMillis()) {
16 | finishAndRemoveTask()
17 | } else {
18 | toast(getString(R.string.app_exit_label))
19 | }
20 | backPressed = System.currentTimeMillis()
21 | }
22 |
23 |
24 | override fun onCreate(savedInstanceState: Bundle?) {
25 | super.onCreate(savedInstanceState)
26 | setContent {
27 | MainRoot(finish = finish)
28 | }
29 | }
30 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/features/main/MainRoot.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.features.main
2 |
3 | import androidx.activity.compose.BackHandler
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.material.MaterialTheme
6 | import androidx.compose.material.Surface
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.getValue
9 | import androidx.compose.ui.Modifier
10 | import androidx.navigation.compose.currentBackStackEntryAsState
11 | import com.developersancho.hb.compose.app.extensions.SetupSystemUi
12 | import com.developersancho.hb.compose.app.theme.HBCaseTheme
13 | import com.developersancho.hb.compose.features.NavGraphs
14 | import com.google.accompanist.systemuicontroller.rememberSystemUiController
15 | import com.ramcosta.composedestinations.DestinationsNavHost
16 | import com.ramcosta.composedestinations.animations.rememberAnimatedNavHostEngine
17 |
18 | @Composable
19 | fun MainRoot(finish: () -> Unit) {
20 | val engine = rememberAnimatedNavHostEngine()
21 | val navController = engine.rememberNavController()
22 | val currentBackStackEntryAsState by navController.currentBackStackEntryAsState()
23 | val destination = currentBackStackEntryAsState?.destination?.route
24 | ?: NavGraphs.root.startRoute.route
25 |
26 | if (destination == NavGraphs.root.startRoute.route) {
27 | BackHandler { finish() }
28 | }
29 |
30 | HBCaseTheme {
31 | SetupSystemUi(rememberSystemUiController(), MaterialTheme.colors.primary)
32 | Surface(
33 | modifier = Modifier.fillMaxSize(),
34 | color = MaterialTheme.colors.background
35 | ) {
36 | DestinationsNavHost(
37 | engine = engine,
38 | navController = navController,
39 | navGraph = NavGraphs.root
40 | )
41 | }
42 | }
43 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/features/search/SearchContract.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.features.search
2 |
3 | import androidx.paging.PagingData
4 | import com.developersancho.data.model.dto.SearchItemDto
5 | import kotlinx.coroutines.flow.Flow
6 | import kotlinx.coroutines.flow.emptyFlow
7 |
8 | data class SearchViewState(
9 | val pagedData: Flow> = emptyFlow()
10 | )
11 |
12 | sealed class SearchEvent {
13 | data class SearchByText(val keyword: String?) : SearchEvent()
14 | data class SearchByFilterType(val filterType: SearchFilterType? = null) : SearchEvent()
15 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/features/search/SearchFilterType.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.features.search
2 |
3 | import android.content.Context
4 | import com.developersancho.hb.compose.R
5 |
6 | enum class SearchFilterType(val type: String) {
7 | MOVIE("movie"),
8 | MUSIC("song"),
9 | EBOOK("ebook"),
10 | PODCAST("podcast");
11 |
12 | companion object {
13 | fun from(findType: String?): SearchFilterType? =
14 | values().firstOrNull { it.type == findType }
15 |
16 | fun fromName(context: Context, findName: String?): SearchFilterType? {
17 | return when (findName) {
18 | context.getString(R.string.chip_movie) -> MOVIE
19 | context.getString(R.string.chip_music) -> MUSIC
20 | context.getString(R.string.chip_ebook) -> EBOOK
21 | context.getString(R.string.chip_podcast) -> PODCAST
22 | else -> null
23 | }
24 | }
25 | }
26 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/features/search/SearchViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.features.search
2 |
3 | import androidx.lifecycle.viewModelScope
4 | import androidx.paging.cachedIn
5 | import com.developersancho.domain.search.Search
6 | import com.developersancho.framework.base.mvi.BaseViewState
7 | import com.developersancho.framework.base.mvi.MviViewModel
8 | import dagger.hilt.android.lifecycle.HiltViewModel
9 | import javax.inject.Inject
10 |
11 | @HiltViewModel
12 | class SearchViewModel @Inject constructor(
13 | private val search: Search
14 | ) : MviViewModel, SearchEvent>() {
15 |
16 | companion object {
17 | private const val MAX_SEARCH_LENGTH = 3
18 | }
19 |
20 | var searchFilterType: SearchFilterType? = null
21 | var searchKeyword: String? = null
22 |
23 | override fun onTriggerEvent(eventType: SearchEvent) {
24 | when (eventType) {
25 | is SearchEvent.SearchByText -> searchByText(eventType.keyword)
26 | is SearchEvent.SearchByFilterType -> searchByFilterType(eventType.filterType)
27 | }
28 | }
29 |
30 | private fun searchByText(keyword: String?) = safeLaunch {
31 | if (keyword.orEmpty().length < MAX_SEARCH_LENGTH) return@safeLaunch
32 | searchKeyword = keyword
33 | setState(BaseViewState.Loading)
34 | val params = Search.Params(searchKeyword, searchFilterType?.type)
35 | val pagedFlow = search(params).cachedIn(scope = viewModelScope)
36 | setState(BaseViewState.Success(SearchViewState(pagedData = pagedFlow)))
37 | }
38 |
39 | private fun searchByFilterType(filterType: SearchFilterType?) = safeLaunch {
40 | if (searchKeyword.orEmpty().length < MAX_SEARCH_LENGTH) return@safeLaunch
41 | searchFilterType = filterType
42 | setState(BaseViewState.Loading)
43 | val params = Search.Params(searchKeyword, searchFilterType?.type)
44 | val pagedFlow = search(params).cachedIn(scope = viewModelScope)
45 | setState(BaseViewState.Success(SearchViewState(pagedData = pagedFlow)))
46 | }
47 | }
--------------------------------------------------------------------------------
/appcompose/src/main/java/com/developersancho/hb/compose/features/search/view/SearchRow.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.features.search.view
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.material.Card
5 | import androidx.compose.material.MaterialTheme
6 | import androidx.compose.material.Text
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.platform.LocalContext
11 | import androidx.compose.ui.text.style.TextOverflow
12 | import androidx.compose.ui.tooling.preview.Preview
13 | import androidx.compose.ui.unit.dp
14 | import coil.compose.AsyncImage
15 | import coil.request.ImageRequest
16 | import coil.size.Scale
17 | import com.developersancho.data.model.dto.SearchItemDto
18 | import com.developersancho.framework.extensions.toReleaseDate
19 | import com.developersancho.hb.compose.R
20 | import com.developersancho.hb.compose.app.theme.HBCaseTheme
21 |
22 | @Composable
23 | fun SearchRow(
24 | dto: SearchItemDto,
25 | onItemClick: () -> Unit = {}
26 | ) {
27 | Card(
28 | onClick = onItemClick,
29 | modifier = Modifier
30 | .fillMaxWidth()
31 | .height(270.dp)
32 | .padding(
33 | vertical = 4.dp,
34 | horizontal = 4.dp
35 | ),
36 | elevation = 4.dp
37 | ) {
38 | Column(
39 | verticalArrangement = Arrangement.spacedBy(8.dp),
40 | horizontalAlignment = Alignment.CenterHorizontally,
41 | modifier = Modifier
42 | .fillMaxSize()
43 | ) {
44 | AsyncImage(
45 | model = ImageRequest.Builder(LocalContext.current)
46 | .data(dto.artworkUrl100)
47 | .scale(Scale.FIT)
48 | .error(R.drawable.ic_error_image)
49 | .build(),
50 | contentDescription = null,
51 | modifier = Modifier
52 | .width(150.dp)
53 | .height(150.dp)
54 | )
55 | Text(
56 | text = dto.collectionName.toString(),
57 | modifier = Modifier
58 | .fillMaxWidth()
59 | .padding(start = 8.dp, end = 8.dp),
60 | style = MaterialTheme.typography.subtitle2,
61 | maxLines = 2,
62 | overflow = TextOverflow.Ellipsis
63 | )
64 | Text(
65 | text = "${dto.collectionPrice}-${dto.currency}",
66 | modifier = Modifier
67 | .fillMaxWidth()
68 | .padding(start = 8.dp, end = 8.dp),
69 | style = MaterialTheme.typography.body2
70 | )
71 | Text(
72 | text = dto.releaseDate.toString().toReleaseDate(),
73 | modifier = Modifier
74 | .fillMaxWidth()
75 | .padding(start = 8.dp, end = 8.dp),
76 | style = MaterialTheme.typography.body2
77 | )
78 | }
79 | }
80 | }
81 |
82 | @Preview(showBackground = true)
83 | @Composable
84 | fun SearchRowPreview() {
85 | HBCaseTheme {
86 | SearchRow(dto = SearchItemDto.init())
87 | }
88 | }
--------------------------------------------------------------------------------
/appcompose/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/drawable/bg_search_bar.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | -
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/drawable/bg_search_bar_light.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | -
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/drawable/ic_baseline_arrow_left_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
14 |
21 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/drawable/ic_close_circle_gray.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
11 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/drawable/ic_close_circle_red_tint.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
11 |
12 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/drawable/ic_error_image.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
11 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/drawable/ic_search_bar_small.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/drawable/ic_search_bar_small_gray.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/appcompose/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/appcompose/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/appcompose/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/appcompose/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/appcompose/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/appcompose/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/appcompose/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/appcompose/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/appcompose/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/appcompose/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/appcompose/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/appcompose/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/appcompose/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/appcompose/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/appcompose/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/appcompose/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/appcompose/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/appcompose/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/appcompose/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/appcompose/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
11 | #D13438
12 | #982626
13 | #8B1C1C
14 | #6A1616
15 | #E05454
16 | #EB9191
17 | #FACFCF
18 | #FFEBEB
19 |
20 | #F8F8F8
21 | #F1F1F1
22 | #ECECEC
23 | #E1E1E1
24 | #C8C8C8
25 | #ACACAC
26 | #919191
27 | #6E6E6E
28 | #535353
29 | #303030
30 | #292929
31 | #212121
32 | #141414
33 |
34 | #00000000
35 |
36 | #F5F2F5
37 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 14sp
4 | 16sp
5 |
6 | 40dp
7 | 26dp
8 | 20dp
9 | 16dp
10 | 12dp
11 | 8dp
12 | 4dp
13 | 2dp
14 |
15 | 45dp
16 |
17 | 95dp
18 | 37dp
19 |
20 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | HB Compose
3 |
4 | Press BACK again to exit
5 | Search
6 | Movie
7 | Music
8 | Ebook
9 | Podcast
10 | Detail
11 | Track
12 | Collection
13 | Artist
14 | Description
15 | Price
16 | Release Date
17 | Clear Text
18 | No suitable data found.
19 |
20 |
--------------------------------------------------------------------------------
/appcompose/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
--------------------------------------------------------------------------------
/appcompose/src/test/java/com/developersancho/hb/compose/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/appcompose/src/test/java/com/developersancho/hb/compose/features/detail/DetailContractTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.features.detail
2 |
3 | import org.junit.Assert
4 | import org.junit.Test
5 |
6 | class DetailContractTest {
7 | private lateinit var event: DetailEvent
8 |
9 | private lateinit var state: DetailViewState
10 |
11 | @Test
12 | fun verifyEvent_LoadDetail() {
13 | event = DetailEvent.LoadDetail
14 |
15 | val eventLoadDetail = event as DetailEvent.LoadDetail
16 | Assert.assertEquals(event, eventLoadDetail)
17 | }
18 |
19 | @Test
20 | fun verifyState_DetailViewState() {
21 | state = DetailViewState(null)
22 |
23 | Assert.assertNull(state.dto)
24 | }
25 | }
--------------------------------------------------------------------------------
/appcompose/src/test/java/com/developersancho/hb/compose/features/detail/DetailViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.features.detail
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import app.cash.turbine.test
5 | import com.developersancho.data.model.dto.SearchItemDto
6 | import com.developersancho.framework.base.mvi.BaseViewState
7 | import com.developersancho.hb.compose.app.extensions.toSearchItemNavArgs
8 | import com.developersancho.hb.compose.features.destinations.DetailScreenDestination
9 | import com.developersancho.testutils.MockkUnitTest
10 | import com.google.common.truth.Truth
11 | import io.mockk.every
12 | import io.mockk.mockk
13 | import kotlinx.coroutines.test.runTest
14 | import org.junit.Test
15 |
16 | // todo: has bug about Destination argsFrom() for compose destination lib = https://github.com/raamcosta/compose-destinations
17 | class DetailViewModelTest : MockkUnitTest() {
18 |
19 | // @MockK(relaxed = true)
20 | // lateinit var savedStateHandle: SavedStateHandle
21 | //
22 | // @SpyK
23 | // @InjectMockKs
24 | // lateinit var viewModel: DetailViewModel
25 |
26 |
27 | private fun callViewModel(
28 | savedStateHandle: SavedStateHandle = SavedStateHandle()
29 | ): DetailViewModel {
30 | return DetailViewModel(savedStateHandle)
31 | }
32 |
33 | @Test
34 | fun verifyOnTriggerEvent_LoadDetail() = runTest {
35 | // Arrange (Given)
36 | val dto = SearchItemDto.init().toSearchItemNavArgs()
37 | // val savedStateHandle =
38 | // SavedStateHandle(mapOf("trackId" to dto.trackId, "trackName" to dto.trackName.toString()))
39 | //every { savedStateHandle.navArgs() as SearchItemNavArgs } returns dto
40 | val savedStateHandle = mockk(relaxed = true)
41 | val detailScreenDestination = mockk(relaxed = true)
42 | // val savedStateHandle = SavedStateHandle().apply {
43 | // set("trackId", dto.trackId)
44 | // set("trackName", dto.trackName)
45 | // }
46 | DetailScreenDestination.invoke(dto)
47 | every { detailScreenDestination.argsFrom(savedStateHandle) } returns dto
48 | val viewModel = callViewModel(savedStateHandle)
49 |
50 | // Act (When)
51 |
52 | viewModel.onTriggerEvent(DetailEvent.LoadDetail)
53 |
54 | // Assert (Then)
55 | viewModel.uiState.test {
56 | awaitItem().apply {
57 | Truth.assertThat(this).isNotNull()
58 | Truth.assertThat(this).isInstanceOf(BaseViewState::class.java)
59 | }
60 | }
61 | }
62 | }
--------------------------------------------------------------------------------
/appcompose/src/test/java/com/developersancho/hb/compose/features/search/SearchContractTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.features.search
2 |
3 | import androidx.paging.PagingData
4 | import com.developersancho.data.model.dto.SearchItemDto
5 | import kotlinx.coroutines.flow.Flow
6 | import kotlinx.coroutines.flow.flow
7 | import org.junit.Assert
8 | import org.junit.Test
9 |
10 | class SearchContractTest {
11 | private lateinit var event: SearchEvent
12 |
13 | private lateinit var state: SearchViewState
14 |
15 | @Test
16 | fun verifyEvent_SearchByText() {
17 | event = SearchEvent.SearchByText(keyword = "John")
18 |
19 | val eventSearchByText = event as SearchEvent.SearchByText
20 | Assert.assertEquals(event, eventSearchByText)
21 | }
22 |
23 | @Test
24 | fun verifyEvent_SearchByFilterType() {
25 | event = SearchEvent.SearchByFilterType(filterType = SearchFilterType.MUSIC)
26 |
27 | val eventSearchByFilterType = event as SearchEvent.SearchByFilterType
28 | Assert.assertEquals(event, eventSearchByFilterType)
29 | }
30 |
31 | @Test
32 | fun verifyState_SearchViewState() {
33 | val pagedData: Flow> =
34 | flow { emit(PagingData.empty()) }
35 | state = SearchViewState(pagedData)
36 |
37 | Assert.assertEquals(pagedData, state.pagedData)
38 | }
39 | }
--------------------------------------------------------------------------------
/appcompose/src/test/java/com/developersancho/hb/compose/features/search/SearchViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.hb.compose.features.search
2 |
3 | import androidx.paging.PagingData
4 | import com.developersancho.data.model.dto.SearchItemDto
5 | import com.developersancho.domain.search.Search
6 | import com.developersancho.testutils.MockkUnitTest
7 | import io.mockk.coVerify
8 | import io.mockk.every
9 | import io.mockk.impl.annotations.InjectMockKs
10 | import io.mockk.impl.annotations.MockK
11 | import io.mockk.impl.annotations.SpyK
12 | import kotlinx.coroutines.flow.flow
13 | import kotlinx.coroutines.test.runTest
14 | import org.junit.Assert
15 | import org.junit.Test
16 |
17 | class SearchViewModelTest : MockkUnitTest() {
18 |
19 | @MockK(relaxed = true)
20 | lateinit var search: Search
21 |
22 | @SpyK
23 | @InjectMockKs
24 | lateinit var viewModel: SearchViewModel
25 |
26 |
27 | @Test
28 | fun verifyOnTriggerEvent_SearchByText() = runTest {
29 | // Arrange (Given)
30 | val searchKeyword = "John"
31 | every { search.invoke(any()) } returns flow {
32 | emit(PagingData.from(listOf(SearchItemDto.init())))
33 | }
34 |
35 | // Act (When)
36 | viewModel.onTriggerEvent(SearchEvent.SearchByText(searchKeyword))
37 |
38 | // Assert (Then)
39 | coVerify { search.invoke(any()) }
40 | }
41 |
42 | @Test
43 | fun verifyOnTriggerEvent_SearchByFilterType() = runTest {
44 | // Arrange (Given)
45 | viewModel.searchKeyword = "John"
46 | every { search.invoke(any()) } returns flow {
47 | emit(PagingData.from(listOf(SearchItemDto.init())))
48 | }
49 |
50 | // Act (When)
51 | viewModel.onTriggerEvent(SearchEvent.SearchByFilterType(SearchFilterType.MUSIC))
52 |
53 | // Assert (Then)
54 | coVerify { search.invoke(any()) }
55 | Assert.assertNotNull(viewModel.searchKeyword)
56 | Assert.assertNotNull(viewModel.searchFilterType)
57 | }
58 | }
--------------------------------------------------------------------------------
/art/architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/art/architecture.png
--------------------------------------------------------------------------------
/art/clean_arch.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/art/clean_arch.jpeg
--------------------------------------------------------------------------------
/art/project.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/art/project.png
--------------------------------------------------------------------------------
/art/screenshots/compose-detail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/art/screenshots/compose-detail.png
--------------------------------------------------------------------------------
/art/screenshots/compose-search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/art/screenshots/compose-search.png
--------------------------------------------------------------------------------
/art/screenshots/detail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/art/screenshots/detail.png
--------------------------------------------------------------------------------
/art/screenshots/search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/art/screenshots/search.png
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.google.devtools.ksp") version "1.7.10-1.0.6" apply false
3 | }
4 |
5 | apply()
6 |
7 | tasks.register("clean", Delete::class) {
8 | delete(rootProject.buildDir)
9 | }
--------------------------------------------------------------------------------
/buildSrc/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/buildSrc/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | `kotlin-dsl`
3 | `kotlin-dsl-precompiled-script-plugins`
4 | }
5 |
6 | repositories {
7 | gradlePluginPortal()
8 | google()
9 | mavenCentral()
10 | }
11 |
12 | group = "com.developersancho.buildSrc"
13 |
14 | java {
15 | sourceCompatibility = JavaVersion.VERSION_1_8
16 | targetCompatibility = JavaVersion.VERSION_1_8
17 | }
18 |
19 | gradlePlugin {
20 | plugins {
21 | register("DependencyUpdatePlugin") {
22 | id = "dependency.update.plugin"
23 | implementationClass = "plugins.DependencyUpdatePlugin"
24 | }
25 | }
26 | }
27 |
28 | dependencies {
29 | implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10")
30 | implementation("com.android.tools.build:gradle:7.2.2")
31 | implementation("com.google.dagger:hilt-android-gradle-plugin:2.42")
32 | implementation("com.github.ben-manes:gradle-versions-plugin:0.42.0")
33 | }
--------------------------------------------------------------------------------
/buildSrc/src/main/java/com/developersancho/buildsrc/Configs.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.buildsrc
2 |
3 | object Configs {
4 | const val ApplicationId = "com.developersancho.hb"
5 | const val CompileSdk = 32
6 | const val TargetSdk = 32
7 | const val MinSdk = 23
8 | const val VersionCode = 1
9 | const val VersionName = "1.0.0"
10 | const val TestInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
11 |
12 | object Compose {
13 | const val ApplicationId = "com.developersancho.hb.compose"
14 | const val VersionCode = 1
15 | const val VersionName = "1.0.0"
16 | }
17 | }
--------------------------------------------------------------------------------
/buildSrc/src/main/java/com/developersancho/buildsrc/Versions.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.buildsrc
2 |
3 | object Versions {
4 | const val ComposeCompiler = "1.3.0-rc02"
5 | const val Compose = "1.2.0"
6 | }
--------------------------------------------------------------------------------
/buildSrc/src/main/java/plugins/DependencyUpdatePlugin.kt:
--------------------------------------------------------------------------------
1 | package plugins
2 |
3 | import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
4 | import org.gradle.api.Plugin
5 | import org.gradle.api.Project
6 | import java.util.*
7 |
8 | class DependencyUpdatePlugin : Plugin {
9 | override fun apply(project: Project) = with(project) {
10 | plugins.apply("com.github.ben-manes.versions")
11 | tasks.named("dependencyUpdates", DependencyUpdatesTask::class.java).configure {
12 | rejectVersionIf {
13 | isNonStable(candidate.version)
14 | }
15 | outputFormatter = "html"
16 | doLast {
17 | exec {
18 | commandLine("open", "build/dependencyUpdates/report.html")
19 | }
20 | }
21 | }
22 | }
23 |
24 | private fun isNonStable(version: String): Boolean {
25 | val stableKeyword = listOf("RELEASE", "FINAL", "GA").any {
26 | version.toUpperCase(Locale.getDefault())
27 | .contains(it)
28 | }
29 | val regex = "^[0-9,.v-]+(-r)?$".toRegex()
30 | val isStable = stableKeyword || regex.matches(version)
31 | return isStable.not()
32 | }
33 | }
--------------------------------------------------------------------------------
/data/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/data/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import com.developersancho.buildsrc.*
2 |
3 | plugins {
4 | id("com.android.library")
5 | id("org.jetbrains.kotlin.android")
6 | id("org.jetbrains.kotlin.plugin.parcelize")
7 | id("org.jetbrains.kotlin.kapt")
8 | id("com.google.devtools.ksp")
9 | id("dagger.hilt.android.plugin")
10 | }
11 |
12 | android {
13 | compileSdk = Configs.CompileSdk
14 |
15 | defaultConfig {
16 | minSdk = Configs.MinSdk
17 | targetSdk = Configs.TargetSdk
18 | testInstrumentationRunner = Configs.TestInstrumentationRunner
19 | }
20 |
21 | buildTypes {
22 | release {
23 | isMinifyEnabled = false
24 | proguardFiles(
25 | getDefaultProguardFile("proguard-android-optimize.txt"),
26 | "proguard-rules.pro"
27 | )
28 | }
29 | }
30 | compileOptions {
31 | sourceCompatibility = JavaVersion.VERSION_11
32 | targetCompatibility = JavaVersion.VERSION_11
33 | }
34 | kotlinOptions {
35 | jvmTarget = JavaVersion.VERSION_11.toString()
36 | freeCompilerArgs = listOf(
37 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
38 | "-opt-in=kotlinx.coroutines.FlowPreview"
39 | )
40 | }
41 | }
42 |
43 | android.libraryVariants.all {
44 | val variantName = name
45 | kotlin.sourceSets {
46 | getByName("main") {
47 | kotlin.srcDir(File("build/generated/ksp/$variantName/kotlin"))
48 | }
49 | }
50 | }
51 |
52 | dependencies {
53 | testImplementation(project(mapOf("path" to ":libraries:testutils")))
54 | implementation(project(mapOf("path" to ":libraries:framework")))
55 |
56 | implementation(SupportLib.CoreKtx)
57 |
58 | implementation(SupportLib.CoroutineCore)
59 | implementation(SupportLib.CoroutineAndroid)
60 |
61 | implementation(NetworkLib.Moshi)
62 | ksp(NetworkLib.MoshiCodegen)
63 | implementation(NetworkLib.Retrofit)
64 | implementation(NetworkLib.RetrofitMoshi)
65 | implementation(NetworkLib.Okhttp)
66 | implementation(NetworkLib.LoggingInterceptor)
67 | debugImplementation(NetworkLib.ChuckerDebug)
68 | releaseImplementation(NetworkLib.ChuckerRelease)
69 |
70 | implementation(StorageLib.RoomKtx)
71 | ksp(StorageLib.RoomCompiler)
72 |
73 | implementation(DaggerHiltLib.Android)
74 | kapt(DaggerHiltLib.Compiler)
75 | }
--------------------------------------------------------------------------------
/data/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/data/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
--------------------------------------------------------------------------------
/data/src/main/java/com/developersancho/data/model/dto/SearchItemDto.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.data.model.dto
2 |
3 | import android.os.Parcelable
4 | import com.developersancho.data.model.remote.SearchItem
5 | import kotlinx.parcelize.Parcelize
6 |
7 | @Parcelize
8 | data class SearchItemDto(
9 | val trackId: Int?,
10 | val trackName: String?,
11 | val collectionPrice: Double?,
12 | val artworkUrl100: String?,
13 | val releaseDate: String?,
14 | val collectionName: String?,
15 | val collectionId: Int?,
16 | val currency: String?,
17 | val description: String?,
18 | val artistName: String?
19 | ) : Parcelable {
20 | companion object {
21 | fun init() = SearchItemDto(
22 | trackId = 120954025,
23 | trackName = "Upside Down",
24 | collectionPrice = 10.99,
25 | artworkUrl100 = "https://is4-ssl.mzstatic.com/image/thumb/Music114/v4/34/f4/d9/34f4d970-ab11-4614-0b9e-aa1a0b849692/00030206730906.rgb.jpg/100x100bb.jpg",
26 | releaseDate = "2014-10-21T12:00:00Z",
27 | collectionName = "Sing-a-Longs and Lullabies for the Film Curious George",
28 | collectionId = 120954021,
29 | currency = "USD",
30 | description = "Description",
31 | artistName = "Jack Johnson"
32 | )
33 | }
34 | }
35 |
36 | fun SearchItem.toSearchItemDto() = SearchItemDto(
37 | trackId,
38 | trackName,
39 | collectionPrice,
40 | artworkUrl100,
41 | releaseDate,
42 | collectionName,
43 | collectionId,
44 | currency,
45 | description,
46 | artistName
47 | )
48 |
49 | fun List.toSearchItemDtoList() = map { it.toSearchItemDto() }
--------------------------------------------------------------------------------
/data/src/main/java/com/developersancho/data/model/remote/SearchItem.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.data.model.remote
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = true)
7 | data class SearchItem(
8 | @Json(name = "trackId") val trackId: Int?,
9 | @Json(name = "trackName") val trackName: String?,
10 | @Json(name = "collectionPrice") val collectionPrice: Double?,
11 | @Json(name = "artworkUrl100") val artworkUrl100: String?,
12 | @Json(name = "releaseDate") val releaseDate: String?,
13 | @Json(name = "collectionName") val collectionName: String?,
14 | @Json(name = "collectionId") val collectionId: Int?,
15 | @Json(name = "currency") val currency: String?,
16 | @Json(name = "description") val description: String?,
17 | @Json(name = "artistName") val artistName: String?
18 | )
19 |
--------------------------------------------------------------------------------
/data/src/main/java/com/developersancho/data/model/remote/SearchResponse.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.data.model.remote
2 |
3 | import com.squareup.moshi.Json
4 | import com.squareup.moshi.JsonClass
5 |
6 | @JsonClass(generateAdapter = true)
7 | data class SearchResponse(
8 | @Json(name = "resultCount") val resultCount: Int?,
9 | @Json(name = "results") val results: List?
10 | )
--------------------------------------------------------------------------------
/data/src/main/java/com/developersancho/data/remote/service/SearchService.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.data.remote.service
2 |
3 | import com.developersancho.data.model.remote.SearchResponse
4 | import retrofit2.http.GET
5 | import retrofit2.http.QueryMap
6 |
7 | interface SearchService {
8 | @GET(SEARCH)
9 | suspend fun search(
10 | @QueryMap options: Map
11 | ): SearchResponse
12 |
13 | companion object {
14 | const val SEARCH = "search"
15 | }
16 | }
--------------------------------------------------------------------------------
/data/src/main/java/com/developersancho/data/repository/SearchRepository.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.data.repository
2 |
3 | import androidx.annotation.VisibleForTesting
4 | import com.developersancho.data.remote.service.SearchService
5 | import javax.inject.Inject
6 |
7 | class SearchRepository @Inject constructor(
8 | @get:VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
9 | internal val service: SearchService
10 | ) {
11 | suspend fun search(queryParams: Map) = service.search(queryParams)
12 | }
--------------------------------------------------------------------------------
/data/src/main/java/com/developersancho/data/repository/di/RepositoryModule.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.data.repository.di
2 |
3 | import com.developersancho.data.remote.service.SearchService
4 | import com.developersancho.data.repository.SearchRepository
5 | import dagger.Module
6 | import dagger.Provides
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.components.SingletonComponent
9 | import javax.inject.Singleton
10 |
11 | @Module
12 | @InstallIn(SingletonComponent::class)
13 | class RepositoryModule {
14 | @Singleton
15 | @Provides
16 | fun provideSearchRepository(
17 | service: SearchService
18 | ) = SearchRepository(service)
19 | }
--------------------------------------------------------------------------------
/data/src/test/java/com/developersancho/data/model/dto/SearchItemDtoTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.data.model.dto
2 |
3 | import com.developersancho.data.model.remote.SearchItem
4 | import org.junit.Assert
5 | import org.junit.Test
6 |
7 | class SearchItemDtoTest {
8 |
9 | @Test
10 | fun check_CorrectAttributes() {
11 | // Given
12 | val trackId = 120954025
13 | val trackName = "Upside Down"
14 |
15 | val dto = SearchItemDto(
16 | trackId = trackId,
17 | trackName = trackName,
18 | collectionPrice = null,
19 | artworkUrl100 = null,
20 | releaseDate = null,
21 | collectionName = null,
22 | collectionId = null,
23 | currency = null,
24 | description = null,
25 | artistName = null
26 | )
27 |
28 | // Then
29 | Assert.assertEquals(trackId, dto.trackId)
30 | Assert.assertEquals(trackName, dto.trackName)
31 | Assert.assertNull(dto.collectionPrice)
32 | }
33 |
34 | @Test
35 | fun check_ToSearchItemDto() {
36 | // Given
37 | val trackId = 120954025
38 | val trackName = "Upside Down"
39 |
40 | val searchItem = SearchItem(
41 | trackId = trackId,
42 | trackName = trackName,
43 | collectionPrice = null,
44 | artworkUrl100 = null,
45 | releaseDate = null,
46 | collectionName = null,
47 | collectionId = null,
48 | currency = null,
49 | description = null,
50 | artistName = null
51 | )
52 |
53 | // When
54 | val dto = searchItem.toSearchItemDto()
55 |
56 | // Then
57 | Assert.assertEquals(searchItem.trackId, dto.trackId)
58 | Assert.assertEquals(searchItem.trackName, dto.trackName)
59 | Assert.assertNull(dto.collectionPrice)
60 | }
61 |
62 | @Test
63 | fun check_ToSearchItemDtoList() {
64 | // Given
65 | val trackId = 120954025
66 | val trackName = "Upside Down"
67 |
68 | val searchItem = SearchItem(
69 | trackId = trackId,
70 | trackName = trackName,
71 | collectionPrice = null,
72 | artworkUrl100 = null,
73 | releaseDate = null,
74 | collectionName = null,
75 | collectionId = null,
76 | currency = null,
77 | description = null,
78 | artistName = null
79 | )
80 |
81 | val searchItemList = listOf(searchItem)
82 |
83 | // When
84 | val dtoList = searchItemList.toSearchItemDtoList()
85 |
86 | // Then
87 | Assert.assertEquals(searchItemList.first().trackId, dtoList.first().trackId)
88 | Assert.assertEquals(searchItemList.first().trackName, dtoList.first().trackName)
89 | Assert.assertNull(dtoList.first().collectionPrice)
90 | }
91 | }
--------------------------------------------------------------------------------
/data/src/test/java/com/developersancho/data/model/remote/SearchItemTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.data.model.remote
2 |
3 | import org.junit.Assert
4 | import org.junit.Test
5 |
6 | class SearchItemTest {
7 |
8 | @Test
9 | fun check_CorrectAttributes() {
10 | // Given
11 | val trackId = 120954025
12 | val trackName = "Upside Down"
13 |
14 | val searchItem = SearchItem(
15 | trackId = trackId,
16 | trackName = trackName,
17 | collectionPrice = null,
18 | artworkUrl100 = null,
19 | releaseDate = null,
20 | collectionName = null,
21 | collectionId = null,
22 | currency = null,
23 | description = null,
24 | artistName = null
25 | )
26 |
27 | // Then
28 | Assert.assertEquals(trackId, searchItem.trackId)
29 | Assert.assertEquals(trackName, searchItem.trackName)
30 | Assert.assertNull(searchItem.collectionPrice)
31 | }
32 | }
--------------------------------------------------------------------------------
/data/src/test/java/com/developersancho/data/model/remote/SearchResponseTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.data.model.remote
2 |
3 | import org.junit.Assert
4 | import org.junit.Test
5 |
6 | class SearchResponseTest {
7 | @Test
8 | fun check_CorrectAttributes() {
9 | // Given
10 | val trackId = 120954025
11 | val trackName = "Upside Down"
12 |
13 | val searchItem = SearchItem(
14 | trackId = trackId,
15 | trackName = trackName,
16 | collectionPrice = null,
17 | artworkUrl100 = null,
18 | releaseDate = null,
19 | collectionName = null,
20 | collectionId = null,
21 | currency = null,
22 | description = null,
23 | artistName = null
24 | )
25 |
26 | val response = SearchResponse(
27 | resultCount = 10,
28 | results = listOf(searchItem)
29 | )
30 |
31 | // Then
32 | Assert.assertEquals(response.results?.first()?.trackId, searchItem.trackId)
33 | Assert.assertEquals(response.results?.first()?.trackName, searchItem.trackName)
34 | Assert.assertNull(response.results?.first()?.collectionPrice)
35 | }
36 | }
--------------------------------------------------------------------------------
/data/src/test/java/com/developersancho/data/remote/SearchServiceTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.data.remote
2 |
3 | import com.developersancho.data.remote.service.SearchService
4 | import com.developersancho.testutils.BaseServiceTest
5 | import kotlinx.coroutines.test.runTest
6 | import org.junit.Assert
7 | import org.junit.Test
8 |
9 | class SearchServiceTest : BaseServiceTest(SearchService::class) {
10 | override val baseUrl: String
11 | get() = "https://itunes.apple.com/"
12 |
13 | @Test
14 | fun requestLiveSearch() = runTest {
15 | // Given
16 | val options = hashMapOf()
17 | options["limit"] = "20"
18 | options["term"] = "John"
19 |
20 | // When
21 | val response = serviceLive.search(options)
22 |
23 | // Then
24 | Assert.assertEquals(928911988, response.results?.first()?.trackId)
25 | Assert.assertEquals("John Wick", response.results?.first()?.trackName)
26 | Assert.assertEquals(20, response.resultCount)
27 | }
28 | }
--------------------------------------------------------------------------------
/data/src/test/java/com/developersancho/data/repository/SearchRepositoryTest.kt:
--------------------------------------------------------------------------------
1 | package com.developersancho.data.repository
2 |
3 | import com.developersancho.data.remote.service.SearchService
4 | import com.developersancho.testutils.MockkUnitTest
5 | import io.mockk.coVerify
6 | import io.mockk.impl.annotations.MockK
7 | import io.mockk.slot
8 | import kotlinx.coroutines.test.runTest
9 | import org.junit.Assert
10 | import org.junit.Test
11 |
12 | class SearchRepositoryTest : MockkUnitTest() {
13 | @MockK(relaxed = true)
14 | lateinit var searchService: SearchService
15 |
16 | private lateinit var repository: SearchRepository
17 |
18 | override fun onCreate() {
19 | super.onCreate()
20 | repository = SearchRepository(searchService)
21 | }
22 |
23 | @Test
24 | fun check_search() = runTest {
25 | // Given
26 | val searchOptions = hashMapOf()
27 | val slotOptions = slot