├── .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>() 28 | 29 | // When 30 | repository.search(searchOptions) 31 | 32 | // Then 33 | coVerify { 34 | searchService.search(options = capture(slotOptions)) 35 | } 36 | 37 | Assert.assertEquals(searchOptions, slotOptions.captured) 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /domain/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /domain/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.kapt") 7 | id("dagger.hilt.android.plugin") 8 | } 9 | 10 | android { 11 | compileSdk = Configs.CompileSdk 12 | 13 | defaultConfig { 14 | minSdk = Configs.MinSdk 15 | targetSdk = Configs.TargetSdk 16 | testInstrumentationRunner = Configs.TestInstrumentationRunner 17 | } 18 | 19 | buildTypes { 20 | release { 21 | isMinifyEnabled = false 22 | proguardFiles( 23 | getDefaultProguardFile("proguard-android-optimize.txt"), 24 | "proguard-rules.pro" 25 | ) 26 | } 27 | } 28 | compileOptions { 29 | sourceCompatibility = JavaVersion.VERSION_11 30 | targetCompatibility = JavaVersion.VERSION_11 31 | } 32 | kotlinOptions { 33 | jvmTarget = JavaVersion.VERSION_11.toString() 34 | freeCompilerArgs = listOf( 35 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", 36 | "-opt-in=kotlinx.coroutines.FlowPreview" 37 | ) 38 | } 39 | } 40 | 41 | dependencies { 42 | implementation(project(mapOf("path" to ":data"))) 43 | implementation(project(mapOf("path" to ":libraries:framework"))) 44 | testImplementation(project(mapOf("path" to ":libraries:testutils"))) 45 | 46 | implementation(SupportLib.CoreKtx) 47 | implementation(SupportLib.CoroutineCore) 48 | implementation(SupportLib.CoroutineAndroid) 49 | 50 | implementation(DaggerHiltLib.Android) 51 | kapt(DaggerHiltLib.Compiler) 52 | 53 | implementation(SupportLib.Paging) 54 | } -------------------------------------------------------------------------------- /domain/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 -------------------------------------------------------------------------------- /domain/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /domain/src/main/java/com/developersancho/domain/di/DomainModule.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.domain.di 2 | 3 | import com.developersancho.data.repository.SearchRepository 4 | import com.developersancho.domain.search.Search 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 DomainModule { 14 | @Singleton 15 | @Provides 16 | fun provideSearch(repository: SearchRepository): Search { 17 | return Search(repository) 18 | } 19 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/developersancho/domain/search/Search.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.domain.search 2 | 3 | import androidx.annotation.VisibleForTesting 4 | import androidx.paging.Pager 5 | import androidx.paging.PagingConfig 6 | import androidx.paging.PagingData 7 | import com.developersancho.data.model.dto.SearchItemDto 8 | import com.developersancho.data.repository.SearchRepository 9 | import com.developersancho.framework.usecase.RequestPagingUseCase 10 | import kotlinx.coroutines.flow.Flow 11 | import javax.inject.Inject 12 | 13 | class Search @Inject constructor( 14 | @get:VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) 15 | internal val repository: SearchRepository 16 | ) : RequestPagingUseCase() { 17 | 18 | data class Params( 19 | val keyword: String? = null, 20 | val entity: String? = null, 21 | val limit: Int = 20 22 | ) 23 | 24 | override fun execute(params: Params): Flow> { 25 | val pagingConfig = PagingConfig(pageSize = params.limit) 26 | return Pager( 27 | config = pagingConfig, 28 | pagingSourceFactory = { SearchPagingSource(repository, params) } 29 | ).flow 30 | } 31 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/developersancho/domain/search/SearchPagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.domain.search 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import com.developersancho.data.model.dto.SearchItemDto 6 | import com.developersancho.data.model.dto.toSearchItemDtoList 7 | import com.developersancho.data.repository.SearchRepository 8 | 9 | class SearchPagingSource( 10 | internal val repository: SearchRepository, 11 | internal val searchParams: Search.Params 12 | ) : PagingSource() { 13 | 14 | companion object { 15 | private const val START_INDEX = 1 16 | private const val QUERY_LIMIT = "limit" 17 | private const val QUERY_OFFSET = "offset" 18 | private const val QUERY_TERM = "term" 19 | private const val QUERY_ENTITY = "entity" 20 | } 21 | 22 | override fun getRefreshKey(state: PagingState): Int? { 23 | return state.anchorPosition?.let { anchorPosition -> 24 | val anchorPage = state.closestPageToPosition(anchorPosition) 25 | anchorPage?.prevKey?.plus(START_INDEX) ?: anchorPage?.nextKey?.minus(START_INDEX) 26 | } 27 | } 28 | 29 | override suspend fun load(params: LoadParams): LoadResult { 30 | val page = params.key ?: START_INDEX 31 | return try { 32 | val queryParams = hashMapOf() 33 | queryParams[QUERY_LIMIT] = searchParams.limit.toString() 34 | queryParams[QUERY_OFFSET] = page.toString() 35 | searchParams.keyword?.let { queryParams[QUERY_TERM] = it } 36 | searchParams.entity?.let { queryParams[QUERY_ENTITY] = it } 37 | 38 | val response = repository.search(queryParams) 39 | val searchList = response.results.orEmpty().toSearchItemDtoList() 40 | 41 | LoadResult.Page( 42 | data = searchList, 43 | prevKey = if (page == START_INDEX) null else page - START_INDEX, 44 | nextKey = if (searchList.isEmpty()) null else page + START_INDEX 45 | ) 46 | } catch (exception: Exception) { 47 | return LoadResult.Error(exception) 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /domain/src/test/java/com/developersancho/domain/SearchTest.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.domain 2 | 3 | import com.developersancho.data.repository.SearchRepository 4 | import com.developersancho.domain.search.Search 5 | import com.developersancho.testutils.MockkUnitTest 6 | import io.mockk.coVerify 7 | import io.mockk.impl.annotations.InjectMockKs 8 | import io.mockk.impl.annotations.MockK 9 | import io.mockk.impl.annotations.SpyK 10 | import kotlinx.coroutines.test.runTest 11 | import org.junit.Test 12 | 13 | class SearchTest : MockkUnitTest() { 14 | 15 | @MockK(relaxed = true) 16 | lateinit var repository: SearchRepository 17 | 18 | @SpyK 19 | @InjectMockKs 20 | private lateinit var search: Search 21 | 22 | @Test 23 | fun verifyExecute() = runTest { 24 | // Given 25 | val params = Search.Params(keyword = null, entity = null) 26 | 27 | // When 28 | search.invoke(params) 29 | 30 | // Then 31 | coVerify { search.invoke(any()) } 32 | } 33 | 34 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developersancho/HB.Case.Android/3557e85b86699d8c053a9fc370077ef580a8019a/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Aug 10 07:22:13 TRT 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /libraries/framework/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /libraries/framework/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.developersancho.buildsrc.* 2 | 3 | plugins { 4 | id("com.android.library") 5 | id("org.jetbrains.kotlin.android") 6 | } 7 | 8 | android { 9 | compileSdk = Configs.CompileSdk 10 | 11 | defaultConfig { 12 | minSdk = Configs.MinSdk 13 | targetSdk = Configs.TargetSdk 14 | testInstrumentationRunner = Configs.TestInstrumentationRunner 15 | } 16 | 17 | buildTypes { 18 | release { 19 | isMinifyEnabled = false 20 | proguardFiles( 21 | getDefaultProguardFile("proguard-android-optimize.txt"), 22 | "proguard-rules.pro" 23 | ) 24 | } 25 | } 26 | compileOptions { 27 | sourceCompatibility = JavaVersion.VERSION_11 28 | targetCompatibility = JavaVersion.VERSION_11 29 | } 30 | kotlinOptions { 31 | jvmTarget = JavaVersion.VERSION_11.toString() 32 | } 33 | buildFeatures { 34 | viewBinding = true 35 | } 36 | } 37 | 38 | dependencies { 39 | implementation(SupportLib.CoreKtx) 40 | implementation(SupportLib.Appcompat) 41 | implementation(SupportLib.Material) 42 | 43 | testImplementation(TestingLib.Junit) 44 | androidTestImplementation(AndroidTestingLib.JunitExt) 45 | androidTestImplementation(AndroidTestingLib.EspressoCore) 46 | 47 | implementation(SupportLib.Recyclerview) 48 | 49 | implementation(SupportLib.ActivityKtx) 50 | implementation(SupportLib.FragmentKtx) 51 | 52 | implementation(SupportLib.Timber) 53 | 54 | implementation(SupportLib.CoroutineCore) 55 | implementation(SupportLib.CoroutineAndroid) 56 | 57 | implementation(SupportLib.LifecycleViewModel) 58 | implementation(SupportLib.LifecycleRuntime) 59 | implementation(SupportLib.Paging) 60 | 61 | implementation(NetworkLib.Retrofit) 62 | implementation(NetworkLib.Moshi) 63 | } -------------------------------------------------------------------------------- /libraries/framework/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 -------------------------------------------------------------------------------- /libraries/framework/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/base/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.base 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.lifecycle.lifecycleScope 6 | import androidx.viewbinding.ViewBinding 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.launch 9 | 10 | abstract class BaseActivity : AppCompatActivity() { 11 | 12 | lateinit var binding: VB 13 | 14 | abstract fun onViewReady(bundle: Bundle?) 15 | 16 | open fun onViewListener() {} 17 | 18 | open fun observeUi() {} 19 | 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | super.onCreate(savedInstanceState) 22 | if (::binding.isInitialized.not()) { 23 | binding = getBinding() 24 | setContentView(binding.root) 25 | } 26 | observeUi() 27 | onViewReady(savedInstanceState) 28 | onViewListener() 29 | } 30 | 31 | protected fun safeLaunch(block: suspend CoroutineScope.() -> Unit) { 32 | lifecycleScope.launch(block = block) 33 | } 34 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/base/BaseExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.base 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.viewbinding.ViewBinding 6 | import com.developersancho.framework.extensions.findClass 7 | import com.developersancho.framework.extensions.getBinding 8 | 9 | internal fun BaseActivity.getBinding(): V { 10 | return findClass().getBinding(layoutInflater) 11 | } 12 | 13 | internal fun BaseFragment.getBinding( 14 | inflater: LayoutInflater, 15 | container: ViewGroup? 16 | ): V { 17 | return findClass().getBinding(inflater, container) 18 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/base/BaseFragment.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.base 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.appcompat.app.AppCompatActivity 8 | import androidx.fragment.app.Fragment 9 | import androidx.viewbinding.ViewBinding 10 | 11 | abstract class BaseFragment : Fragment() { 12 | private var _binding: VB? = null 13 | 14 | val binding: VB 15 | get() = _binding 16 | ?: throw RuntimeException("Should only use binding after onCreateView and before onDestroyView") 17 | 18 | protected fun requireBinding(): VB = requireNotNull(_binding) 19 | 20 | protected var viewId: Int = -1 21 | 22 | abstract fun onViewReady(bundle: Bundle?) 23 | 24 | open fun onViewListener() {} 25 | 26 | open fun observeUi() {} 27 | 28 | final override fun onCreateView( 29 | inflater: LayoutInflater, 30 | container: ViewGroup?, 31 | savedInstanceState: Bundle? 32 | ): View { 33 | _binding = getBinding(inflater, container) 34 | return binding.root 35 | } 36 | 37 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 38 | super.onViewCreated(view, savedInstanceState) 39 | viewId = binding.root.id 40 | observeUi() 41 | onViewReady(savedInstanceState) 42 | onViewListener() 43 | } 44 | 45 | override fun onDestroyView() { 46 | _binding = null 47 | super.onDestroyView() 48 | } 49 | 50 | protected fun checkArgument(argsKey: String): Boolean { 51 | return requireArguments().containsKey(argsKey) 52 | } 53 | 54 | protected fun requireCompatActivity(): AppCompatActivity { 55 | requireActivity() 56 | val activity = requireActivity() 57 | if (activity is AppCompatActivity) { 58 | return activity 59 | } else { 60 | throw TypeCastException("Main activity should extend from 'AppCompatActivity'") 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/base/BindingViewHolder.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.base 2 | 3 | import android.content.Context 4 | import androidx.recyclerview.widget.RecyclerView 5 | import androidx.viewbinding.ViewBinding 6 | 7 | open class BindingViewHolder(val binding: VB) : 8 | RecyclerView.ViewHolder(binding.root) { 9 | val context: Context = binding.root.context 10 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/base/mvi/BaseMviFragment.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.base.mvi 2 | 3 | import androidx.viewbinding.ViewBinding 4 | import com.developersancho.framework.base.BaseFragment 5 | import com.developersancho.framework.extensions.observeFlow 6 | import com.developersancho.framework.extensions.observeFlowStart 7 | 8 | abstract class BaseMviFragment> : 9 | BaseFragment() { 10 | 11 | abstract val viewModel: VM 12 | 13 | abstract fun renderViewState(viewState: BaseViewState<*>) 14 | 15 | override fun observeUi() { 16 | super.observeUi() 17 | observeFlow(viewModel.uiState, ::renderViewState) 18 | } 19 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/base/mvi/BaseViewState.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.base.mvi 2 | 3 | sealed interface BaseViewState { 4 | object Loading : BaseViewState 5 | object Empty : BaseViewState 6 | data class Success(val data: T) : BaseViewState 7 | data class Error(val throwable: Throwable? = null) : BaseViewState 8 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/base/mvi/MviViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.base.mvi 2 | 3 | import com.developersancho.framework.base.mvvm.MvvmViewModel 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.asStateFlow 6 | 7 | abstract class MviViewModel, EVENT> : MvvmViewModel() { 8 | private val _uiState = MutableStateFlow>(BaseViewState.Empty) 9 | val uiState = _uiState.asStateFlow() 10 | 11 | abstract fun onTriggerEvent(eventType: EVENT) 12 | 13 | protected fun setState(state: STATE) = safeLaunch { 14 | _uiState.emit(state) 15 | } 16 | 17 | override fun startLoading() { 18 | super.startLoading() 19 | _uiState.value = BaseViewState.Loading 20 | } 21 | 22 | override fun handleError(exception: Throwable) { 23 | super.handleError(exception) 24 | _uiState.value = BaseViewState.Error(exception) 25 | } 26 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/base/mvvm/MvvmViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.base.mvvm 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.developersancho.framework.network.DataState 6 | import kotlinx.coroutines.CoroutineExceptionHandler 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.catch 10 | import kotlinx.coroutines.flow.onStart 11 | import kotlinx.coroutines.launch 12 | import timber.log.Timber 13 | 14 | open class MvvmViewModel: ViewModel() { 15 | private val handler = CoroutineExceptionHandler { _, exception -> 16 | Timber.tag(SAFE_LAUNCH_EXCEPTION).e(exception) 17 | handleError(exception) 18 | } 19 | 20 | open fun handleError(exception: Throwable) {} 21 | 22 | open fun startLoading() {} 23 | 24 | protected fun safeLaunch(block: suspend CoroutineScope.() -> Unit) { 25 | viewModelScope.launch(handler, block = block) 26 | } 27 | 28 | protected suspend fun call( 29 | callFlow: Flow, 30 | completionHandler: (collect: T) -> Unit = {} 31 | ) { 32 | callFlow 33 | .catch { handleError(it) } 34 | .collect { 35 | completionHandler.invoke(it) 36 | } 37 | } 38 | 39 | protected suspend fun execute( 40 | callFlow: Flow>, 41 | completionHandler: (collect: T) -> Unit = {} 42 | ) { 43 | callFlow 44 | .onStart { startLoading() } 45 | .catch { handleError(it) } 46 | .collect { state -> 47 | when (state) { 48 | is DataState.Error -> handleError(state.error) 49 | is DataState.Success -> completionHandler.invoke(state.result) 50 | } 51 | } 52 | } 53 | 54 | /* 55 | private fun handleError(throwable: Throwable) { 56 | when (throwable) { 57 | // case no internet connection 58 | is UnknownHostException -> { 59 | noInternetConnectionEvent.call() 60 | } 61 | is ConnectException -> { 62 | noInternetConnectionEvent.call() 63 | } 64 | // case request time out 65 | is SocketTimeoutException -> { 66 | connectTimeoutEvent.call() 67 | } 68 | else -> { 69 | // convert throwable to base exception to get error information 70 | val baseException = throwable.toBaseException() 71 | when (baseException.httpCode) { 72 | HttpURLConnection.HTTP_UNAUTHORIZED -> { 73 | errorMessage.value = baseException.message 74 | } 75 | HttpURLConnection.HTTP_INTERNAL_ERROR -> { 76 | errorMessage.value = baseException.message 77 | } 78 | else -> { 79 | unknownErrorEvent.call() 80 | } 81 | } 82 | } 83 | } 84 | } 85 | */ 86 | 87 | companion object { 88 | private const val SAFE_LAUNCH_EXCEPTION = "ViewModel-ExceptionHandler" 89 | } 90 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/AnyExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.extensions 2 | 3 | val Any.classTag: String get() = this.javaClass.canonicalName.orEmpty() 4 | 5 | val Any.methodTag get() = classTag + object : Any() {}.javaClass.enclosingMethod?.name 6 | 7 | fun Any.hashCodeAsString(): String { 8 | return hashCode().toString() 9 | } 10 | 11 | inline fun Any.cast(): T { 12 | return this as T 13 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/DateExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.extensions 2 | 3 | import java.text.SimpleDateFormat 4 | import java.util.* 5 | 6 | const val ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'" 7 | const val DATE_FORMAT_WITH_DOTS = "dd.MM.yyy" 8 | 9 | fun Date.toLongDateString(): String { 10 | return SimpleDateFormat( 11 | ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT, 12 | Locale.getDefault() 13 | ).format(this) 14 | } 15 | 16 | fun String.toReleaseDate(): String { 17 | return try { 18 | val dateFormatter = SimpleDateFormat( 19 | ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT, 20 | Locale.getDefault() 21 | ) 22 | dateFormatter.parse(this)?.let { 23 | SimpleDateFormat(DATE_FORMAT_WITH_DOTS, Locale.getDefault()).format(it) 24 | } ?: run { "" } 25 | } catch (e: Exception) { 26 | "" 27 | } 28 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/EditTextExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.extensions 2 | 3 | import android.text.Editable 4 | import android.text.TextWatcher 5 | import android.view.inputmethod.EditorInfo 6 | import android.widget.EditText 7 | import kotlinx.coroutines.channels.awaitClose 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.callbackFlow 10 | import kotlinx.coroutines.flow.onStart 11 | 12 | fun EditText.textChanges(): Flow { 13 | return callbackFlow { 14 | val listener = object : TextWatcher { 15 | override fun afterTextChanged(s: Editable?) = Unit 16 | override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = 17 | Unit 18 | 19 | override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { 20 | trySend(s) 21 | } 22 | } 23 | addTextChangedListener(listener) 24 | awaitClose { removeTextChangedListener(listener) } 25 | }.onStart { emit(text) } 26 | } 27 | 28 | fun EditText.onSubmitHideKeyboard() { 29 | setOnEditorActionListener { _, actionId, _ -> 30 | when (actionId) { 31 | EditorInfo.IME_ACTION_DONE, 32 | EditorInfo.IME_ACTION_NONE, 33 | EditorInfo.IME_ACTION_GO, 34 | EditorInfo.IME_ACTION_SEARCH, 35 | EditorInfo.IME_ACTION_SEND, 36 | EditorInfo.IME_ACTION_NEXT -> { 37 | clearFocus() 38 | hideKeyboard() 39 | true 40 | } 41 | else -> false 42 | } 43 | } 44 | } 45 | 46 | fun EditText.afterTextChanged(afterTextChanged: (String) -> Unit) { 47 | this.addTextChangedListener(object : TextWatcher { 48 | override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) { 49 | } 50 | 51 | override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) { 52 | } 53 | 54 | override fun afterTextChanged(editable: Editable?) { 55 | afterTextChanged.invoke(editable.toString()) 56 | } 57 | }) 58 | } 59 | 60 | fun EditText.onSubmit(code: () -> Unit) { 61 | setOnEditorActionListener { _, actionId, _ -> 62 | when (actionId) { 63 | EditorInfo.IME_ACTION_DONE, 64 | EditorInfo.IME_ACTION_NONE, 65 | EditorInfo.IME_ACTION_GO, 66 | EditorInfo.IME_ACTION_SEARCH, 67 | EditorInfo.IME_ACTION_SEND, 68 | EditorInfo.IME_ACTION_NEXT -> code() 69 | } 70 | clearFocus() 71 | true 72 | } 73 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/FragmentExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.extensions 2 | 3 | import android.os.Bundle 4 | import androidx.fragment.app.Fragment 5 | 6 | inline fun T.withArgs(argsBuilder: Bundle.() -> Unit): T = this.apply { 7 | arguments = Bundle().apply(argsBuilder) 8 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/KeyboardExtension.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION") 2 | 3 | package com.developersancho.framework.extensions 4 | 5 | import android.app.Activity 6 | import android.content.Context 7 | import android.view.View 8 | import android.view.inputmethod.InputMethod 9 | import android.view.inputmethod.InputMethodManager 10 | import android.widget.EditText 11 | import androidx.fragment.app.Fragment 12 | 13 | fun View.hideKeyboard() { 14 | val imm = this.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 15 | imm.hideSoftInputFromWindow(this.windowToken, 0) 16 | } 17 | 18 | fun View.showKeyboard() { 19 | val imm = this.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 20 | this.requestFocus() 21 | //imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) 22 | imm.showSoftInput(this, InputMethod.SHOW_FORCED) 23 | } 24 | 25 | fun View.showToggleKeyboard() { 26 | val imm = this.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 27 | this.requestFocus() 28 | imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) 29 | } 30 | 31 | fun Fragment.hideKeyboard() { 32 | view?.let { activity?.hideKeyboard(it) } 33 | } 34 | 35 | fun Activity.hideKeyboard() { 36 | hideKeyboard(currentFocus ?: View(this)) 37 | } 38 | 39 | fun Context.hideKeyboard(view: View) { 40 | val inputMethodManager = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager 41 | inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) 42 | } 43 | 44 | fun EditText.hideKeyboardOnLostFocus() { 45 | setOnFocusChangeListener { _, hasFocus -> 46 | if (!hasFocus) { 47 | isCursorVisible = false 48 | } 49 | 50 | hideKeyboard() 51 | } 52 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/LifecycleOwnerExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.extensions 2 | 3 | import androidx.lifecycle.* 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.launch 7 | 8 | fun LifecycleOwner.observeFlow(property: Flow, block: (T) -> Unit) { 9 | lifecycleScope.launch { 10 | property.collect { block(it) } 11 | } 12 | } 13 | 14 | fun LifecycleOwner.observeFlowStart(property: Flow, block: (T) -> Unit) { 15 | lifecycleScope.launch { 16 | lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { 17 | property.collect { block(it) } 18 | } 19 | } 20 | } 21 | 22 | fun LifecycleOwner.observeLiveData(liveData: LiveData, block: (T) -> Unit) { 23 | liveData.observe(this, Observer { block(it) }) 24 | } 25 | 26 | fun > LifecycleOwner.observeLiveData( 27 | liveData: L, 28 | block: (T) -> Unit 29 | ) { 30 | liveData.observe(this, Observer { block(it) }) 31 | } 32 | 33 | fun LifecycleOwner.repeatOnStared(block: suspend CoroutineScope.() -> Unit) { 34 | lifecycleScope.launch { 35 | lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED, block) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/MoshiExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.extensions 2 | 3 | import com.squareup.moshi.JsonAdapter 4 | import com.squareup.moshi.Moshi 5 | import com.squareup.moshi.Types 6 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 7 | 8 | val moshi: Moshi = Moshi.Builder() 9 | .addLast(KotlinJsonAdapterFactory()) 10 | .build() 11 | 12 | inline fun String.fromJson(): T? { 13 | return try { 14 | val jsonAdapter = moshi.adapter(T::class.java) 15 | jsonAdapter.fromJson(this) 16 | } catch (ex: Exception) { 17 | null 18 | } 19 | } 20 | 21 | inline fun String.fromJsonList(): List? { 22 | return try { 23 | val type = Types.newParameterizedType(MutableList::class.java, T::class.java) 24 | val jsonAdapter: JsonAdapter> = moshi.adapter(type) 25 | jsonAdapter.fromJson(this) 26 | } catch (ex: Exception) { 27 | null 28 | } 29 | } 30 | 31 | inline fun T.toJson(): String { 32 | return try { 33 | val jsonAdapter = moshi.adapter(T::class.java).serializeNulls().lenient() 34 | jsonAdapter.toJson(this) 35 | } catch (ex: Exception) { 36 | "" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/RecyclerViewExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.extensions 2 | 3 | import android.graphics.Rect 4 | import android.view.View 5 | import androidx.recyclerview.widget.RecyclerView 6 | 7 | fun RecyclerView.setItemDecoration(left: Int, top: Int, right: Int, bottom: Int) { 8 | addItemDecoration(object : RecyclerView.ItemDecoration() { 9 | override fun getItemOffsets( 10 | outRect: Rect, 11 | view: View, 12 | parent: RecyclerView, 13 | state: RecyclerView.State 14 | ) { 15 | outRect.left = context.dp2px(left) 16 | outRect.top = context.dp2px(top) 17 | outRect.right = context.dp2px(right) 18 | outRect.bottom = context.dp2px(bottom) 19 | } 20 | }) 21 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/ResourceExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.extensions 2 | 3 | import android.content.Context 4 | 5 | fun Context.dp2px(value: Int): Int { 6 | val scale = resources.displayMetrics.density 7 | return (value.toFloat() * scale + 0.5f).toInt() 8 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/SnackbarExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.extensions 2 | 3 | import android.app.Activity 4 | import android.view.View 5 | import androidx.annotation.IdRes 6 | import androidx.fragment.app.Fragment 7 | import com.google.android.material.snackbar.Snackbar 8 | 9 | 10 | fun Fragment.showSnackBar(view: View, message: String, @IdRes targetViewId: Int? = null) { 11 | Snackbar.make(view, message, Snackbar.LENGTH_LONG).apply { 12 | targetViewId?.let { 13 | anchorView = view.rootView.findViewById(it) 14 | } 15 | show() 16 | } 17 | } 18 | 19 | fun Activity.showSnackBar(view: View, message: String, @IdRes targetViewId: Int? = null) { 20 | Snackbar.make(view, message, Snackbar.LENGTH_LONG).apply { 21 | targetViewId?.let { 22 | anchorView = view.rootView.findViewById(it) 23 | } 24 | show() 25 | } 26 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/ToastExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.extensions 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.widget.Toast 6 | import androidx.fragment.app.Fragment 7 | 8 | fun Activity.toast(message: String) { 9 | Toast.makeText(this, message, Toast.LENGTH_SHORT).show() 10 | } 11 | 12 | fun Activity.toastLong(message: String) { 13 | Toast.makeText(this, message, Toast.LENGTH_LONG).show() 14 | } 15 | 16 | fun Fragment.toast(message: String) { 17 | Toast.makeText(context, message, Toast.LENGTH_SHORT).show() 18 | } 19 | 20 | fun Fragment.toastLong(message: String) { 21 | Toast.makeText(context, message, Toast.LENGTH_LONG).show() 22 | } 23 | 24 | fun Context.toast(message: String) { 25 | Toast.makeText(this, message, Toast.LENGTH_SHORT).show() 26 | } 27 | 28 | fun Context.toastLong(message: String) { 29 | Toast.makeText(this, message, Toast.LENGTH_LONG).show() 30 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/VariableExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.extensions 2 | 3 | import java.math.BigDecimal 4 | 5 | fun Int?.orZero(): Int = this ?: 0 6 | fun Double?.orZero(): Double = this ?: 0.0 7 | fun Long?.orZero(): Long = this ?: 0L 8 | fun BigDecimal?.orZero(): BigDecimal = this ?: BigDecimal.ZERO 9 | 10 | fun Int?.orOne(): Int = this ?: 1 11 | fun Double?.orOne(): Double = this ?: 1.0 12 | fun Long?.orOne(): Long = this ?: 1L 13 | fun BigDecimal?.orOne(): BigDecimal = this ?: BigDecimal.ONE 14 | 15 | fun Int.greaterThan(number: Int): Boolean = this > number 16 | 17 | fun Boolean?.orFalse(): Boolean = this ?: false 18 | 19 | fun Long?.isNull(): Boolean = this == null 20 | fun Int?.isNull(): Boolean = this == null 21 | fun Double?.isNull(): Boolean = this == null 22 | fun BigDecimal?.isNull(): Boolean = this == null 23 | fun Boolean?.isNull(): Boolean = this == null 24 | fun Boolean?.isNotNull(): Boolean = this != null 25 | -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/ViewBindingExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.extensions 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.viewbinding.ViewBinding 6 | import java.lang.reflect.ParameterizedType 7 | 8 | internal fun Class<*>.getBinding(layoutInflater: LayoutInflater): V { 9 | return try { 10 | @Suppress("UNCHECKED_CAST") 11 | getMethod( 12 | "inflate", 13 | LayoutInflater::class.java 14 | ).invoke(null, layoutInflater) as V 15 | } catch (ex: Exception) { 16 | throw RuntimeException("The ViewBinding inflate function has been changed.", ex) 17 | } 18 | } 19 | 20 | internal fun Class<*>.getBinding( 21 | layoutInflater: LayoutInflater, 22 | container: ViewGroup? 23 | ): V { 24 | return try { 25 | @Suppress("UNCHECKED_CAST") 26 | getMethod( 27 | "inflate", 28 | LayoutInflater::class.java, 29 | ViewGroup::class.java, 30 | Boolean::class.java 31 | ).invoke(null, layoutInflater, container, false) as V 32 | } catch (ex: Exception) { 33 | throw RuntimeException("The ViewBinding inflate function has been changed.", ex) 34 | } 35 | } 36 | 37 | internal fun Class<*>.checkMethod(): Boolean { 38 | return try { 39 | getMethod( 40 | "inflate", 41 | LayoutInflater::class.java 42 | ) 43 | true 44 | } catch (ex: Exception) { 45 | false 46 | } 47 | } 48 | 49 | internal fun Any.findClass(): Class<*> { 50 | var javaClass: Class<*> = this.javaClass 51 | var result: Class<*>? = null 52 | while (result == null || !result.checkMethod()) { 53 | result = (javaClass.genericSuperclass as? ParameterizedType) 54 | ?.actualTypeArguments?.firstOrNull { 55 | if (it is Class<*>) { 56 | it.checkMethod() 57 | } else { 58 | false 59 | } 60 | } as? Class<*> 61 | javaClass = javaClass.superclass 62 | } 63 | return result 64 | } 65 | 66 | inline fun ViewGroup.toBinding(): V { 67 | return V::class.java.getMethod( 68 | "inflate", 69 | LayoutInflater::class.java, 70 | ViewGroup::class.java, 71 | Boolean::class.java 72 | ).invoke(null, LayoutInflater.from(context), this, false) as V 73 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/extensions/ViewExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.extensions 2 | 3 | import android.content.Context 4 | import android.os.SystemClock 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.lifecycle.coroutineScope 9 | import androidx.lifecycle.findViewTreeLifecycleOwner 10 | import kotlinx.coroutines.* 11 | 12 | fun View.visible() { 13 | visibility = View.VISIBLE 14 | } 15 | 16 | fun View.gone() { 17 | visibility = View.GONE 18 | } 19 | 20 | fun View.invisible() { 21 | visibility = View.INVISIBLE 22 | } 23 | 24 | val Context.inflater get() = LayoutInflater.from(this) 25 | 26 | fun ViewGroup.inflate(layoutRes: Int): View { 27 | return LayoutInflater.from(context).inflate(layoutRes, this, false) 28 | } 29 | 30 | fun View.onFocusChanged(func: (Boolean) -> Unit) { 31 | this.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> func.invoke(hasFocus) } 32 | } 33 | 34 | fun View.setSafeOnClickListener(debounceTime: Long = 600L, action: () -> Unit) { 35 | this.setOnClickListener(object : View.OnClickListener { 36 | private var lastClickTime: Long = 0 37 | 38 | override fun onClick(v: View) { 39 | if (SystemClock.elapsedRealtime() - lastClickTime < debounceTime) return 40 | else action() 41 | 42 | lastClickTime = SystemClock.elapsedRealtime() 43 | } 44 | }) 45 | } 46 | 47 | // myView.delayOnLifecycle(500L){ } 48 | fun View.delayOnLifecycle( 49 | durationInMillis: Long, 50 | dispatcher: CoroutineDispatcher = Dispatchers.Main, 51 | block: () -> Unit 52 | ): Job? = findViewTreeLifecycleOwner()?.let { lifecycleOwner -> 53 | lifecycleOwner.lifecycle.coroutineScope.launch(dispatcher) { 54 | delay(durationInMillis) 55 | block() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/network/ApiCallExtension.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.network 2 | 3 | suspend fun apiCall(call: suspend () -> T): DataState { 4 | return try { 5 | val response = call() 6 | DataState.Success(response) 7 | } catch (ex: Throwable) { 8 | DataState.Error(ex.handleThrowable()) 9 | } 10 | } 11 | 12 | var successCodeRange: IntRange = 200..299 -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/network/DataState.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.network 2 | 3 | import kotlinx.coroutines.flow.* 4 | 5 | sealed class DataState { 6 | data class Success(val result: T) : DataState() 7 | data class Error(val error: Throwable) : DataState() 8 | 9 | override fun toString(): String { 10 | return when (this) { 11 | is Success<*> -> "Success[data=$result]" 12 | is Error -> "Error[exception=$error]" 13 | } 14 | } 15 | 16 | inline fun map(transform: (T) -> R): DataState { 17 | return when (this) { 18 | is Error -> Error(this.error) 19 | is Success -> Success(transform(this.result)) 20 | } 21 | } 22 | 23 | suspend inline fun suspendMap(crossinline transform: suspend (T) -> R): DataState { 24 | return when (this) { 25 | is Error -> Error(this.error) 26 | is Success -> Success(transform(this.result)) 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/network/HandleError.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.network 2 | 3 | import com.developersancho.framework.common.network.HttpStatusCode 4 | import retrofit2.HttpException 5 | import java.io.IOException 6 | import java.net.SocketTimeoutException 7 | import java.net.UnknownHostException 8 | 9 | sealed class Failure : IOException() { 10 | object JsonError : Failure() 11 | object UnknownError : Failure() 12 | object UnknownHostError : Failure() 13 | object EmptyResponse : Failure() 14 | object ConnectivityError : Failure() 15 | object InternetError : Failure() 16 | object UnAuthorizedException : Failure() 17 | object ParsingDataError : Failure() 18 | object IgnorableError : Failure() 19 | data class TimeOutError(override var message: String) : Failure() 20 | data class ApiError(var code: Int = 0, override var message: String) : Failure() 21 | data class ServerError(var code: Int = 0, override var message: String) : Failure() 22 | data class NotFoundException(override var message: String) : Failure() 23 | data class SocketTimeoutError(override var message: String) : Failure() 24 | data class BusinessError(override var message: String, val stackTrace: String) : Failure() 25 | data class HttpError(var code: Int, override var message: String) : Failure() 26 | } 27 | 28 | fun Throwable.handleThrowable(): Failure { 29 | // Timber.e(this) 30 | return if (this is UnknownHostException) { 31 | Failure.ConnectivityError 32 | } else if (this is HttpException && this.code() == HttpStatusCode.Unauthorized.code) { 33 | Failure.UnAuthorizedException 34 | } else if (this is SocketTimeoutException) { 35 | Failure.SocketTimeoutError(this.message!!) 36 | } else if (this.message != null) { 37 | Failure.NotFoundException(this.message!!) 38 | } else { 39 | Failure.UnknownError 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/network/HttpStatusCode.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.common.network 2 | 3 | enum class HttpStatusCode(val code: Int) { 4 | Unknown(0), 5 | 6 | Continue(100), 7 | SwitchingProtocols(101), 8 | Processing(102), 9 | EarlyHints(103), 10 | 11 | OK(200), 12 | Created(201), 13 | Accepted(202), 14 | NonAuthoritative(203), 15 | NoContent(204), 16 | ResetContent(205), 17 | PartialContent(206), 18 | MultiStatus(207), 19 | AlreadyReported(208), 20 | IMUsed(209), 21 | 22 | MultipleChoices(300), 23 | MovePermanently(301), 24 | Found(302), 25 | SeeOther(303), 26 | NotModified(304), 27 | UseProxy(305), 28 | SwitchProxy(306), 29 | TemporaryRedirect(307), 30 | PermanentRedirect(308), 31 | 32 | BadRequest(400), 33 | Unauthorized(401), 34 | PaymentRequired(402), 35 | Forbidden(403), 36 | NotFound(404), 37 | MethodNotAllowed(405), 38 | NotAcceptable(406), 39 | ProxyAuthenticationRequired(407), 40 | RequestTimeout(408), 41 | Conflict(409), 42 | Gone(410), 43 | LengthRequired(411), 44 | PreconditionFailed(412), 45 | PayloadTooLarge(413), 46 | URITooLong(414), 47 | UnsupportedMediaType(415), 48 | RangeNotSatisfiable(416), 49 | ExpectationFailed(417), 50 | IMATeapot(418), 51 | MisdirectedRequest(421), 52 | UnProcessableEntity(422), 53 | Locked(423), 54 | FailedDependency(424), 55 | TooEarly(425), 56 | UpgradeRequired(426), 57 | PreconditionRequired(428), 58 | TooManyRequests(429), 59 | RequestHeaderFieldsTooLarge(431), 60 | UnavailableForLegalReasons(451), 61 | 62 | InternalServerError(500), 63 | NotImplemented(501), 64 | BadGateway(502), 65 | ServiceUnavailable(503), 66 | GatewayTimeout(504), 67 | HTTPVersionNotSupported(505), 68 | NotExtended(510), 69 | NetworkAuthenticationRequired(511); 70 | } 71 | -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/network/interceptor/HttpRequestInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.network.interceptor 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Response 5 | import timber.log.Timber 6 | 7 | class HttpRequestInterceptor : Interceptor { 8 | override fun intercept(chain: Interceptor.Chain): Response { 9 | val originalRequest = chain.request() 10 | val request = originalRequest.newBuilder().url(originalRequest.url()).build() 11 | Timber.d(request.toString()) 12 | return chain.proceed(request) 13 | } 14 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/usecase/RequestPagingUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.usecase 2 | 3 | import androidx.paging.PagingData 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.flowOn 7 | 8 | abstract class RequestPagingUseCase where ReturnType : Any { 9 | 10 | protected abstract fun execute(params: Params): Flow> 11 | 12 | operator fun invoke(params: Params): Flow> = execute(params) 13 | .flowOn(Dispatchers.IO) 14 | } -------------------------------------------------------------------------------- /libraries/framework/src/main/java/com/developersancho/framework/usecase/RequestUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework.usecase 2 | 3 | import com.developersancho.framework.network.DataState 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.flowOn 7 | 8 | abstract class RequestUseCase where ReturnType : Any { 9 | 10 | protected abstract suspend fun execute(params: Params): Flow> 11 | 12 | suspend operator fun invoke(params: Params): Flow> = execute(params) 13 | .flowOn(Dispatchers.IO) 14 | } -------------------------------------------------------------------------------- /libraries/framework/src/test/java/com/developersancho/framework/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.framework 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 | } -------------------------------------------------------------------------------- /libraries/navigation/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /libraries/navigation/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.developersancho.buildsrc.* 2 | 3 | plugins { 4 | id("com.android.library") 5 | id("org.jetbrains.kotlin.android") 6 | } 7 | 8 | android { 9 | compileSdk = Configs.CompileSdk 10 | 11 | defaultConfig { 12 | minSdk = Configs.MinSdk 13 | targetSdk = Configs.TargetSdk 14 | testInstrumentationRunner = Configs.TestInstrumentationRunner 15 | } 16 | 17 | buildTypes { 18 | release { 19 | isMinifyEnabled = false 20 | proguardFiles( 21 | getDefaultProguardFile("proguard-android-optimize.txt"), 22 | "proguard-rules.pro" 23 | ) 24 | } 25 | } 26 | 27 | compileOptions { 28 | sourceCompatibility = JavaVersion.VERSION_11 29 | targetCompatibility = JavaVersion.VERSION_11 30 | } 31 | 32 | kotlinOptions { 33 | jvmTarget = JavaVersion.VERSION_11.toString() 34 | } 35 | 36 | resourcePrefix = "fragnav_" 37 | } 38 | 39 | dependencies { 40 | implementation(SupportLib.CoreKtx) 41 | implementation(SupportLib.Appcompat) 42 | } -------------------------------------------------------------------------------- /libraries/navigation/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/java/com/developersancho/navigation/AnimationType.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.navigation 2 | 3 | enum class AnimationType { 4 | NO_ANIM, 5 | DEFAULT, 6 | ENTER_FROM_LEFT, 7 | ENTER_FROM_LEFT_WITH_SCALE, 8 | ENTER_FROM_RIGHT, 9 | ENTER_FROM_RIGHT_WITH_SCALE, 10 | ENTER_FROM_BOTTOM; 11 | 12 | companion object { 13 | fun getAnimation(type: AnimationType): List { 14 | when (type) { 15 | DEFAULT -> return listOf( 16 | R.anim.fragnav_slide_in_right, 17 | R.anim.fragnav_slide_out_left, 18 | R.anim.fragnav_slide_in_left, 19 | R.anim.fragnav_slide_out_right 20 | ) 21 | ENTER_FROM_LEFT -> return listOf( 22 | R.anim.fragnav_anim_in_from_pop, 23 | R.anim.fragnav_anim_out_from_pop, 24 | R.anim.fragnav_anim_in, 25 | R.anim.fragnav_anim_out 26 | ) 27 | ENTER_FROM_LEFT_WITH_SCALE -> return listOf( 28 | R.anim.fragnav_anim_scale_in_from_pop, 29 | R.anim.fragnav_anim_scale_out_from_pop, 30 | R.anim.fragnav_anim_scale_in, 31 | R.anim.fragnav_anim_scale_out 32 | ) 33 | ENTER_FROM_RIGHT -> return listOf( 34 | R.anim.fragnav_anim_in, 35 | R.anim.fragnav_anim_out, 36 | R.anim.fragnav_anim_in_from_pop, 37 | R.anim.fragnav_anim_out_from_pop 38 | ) 39 | ENTER_FROM_RIGHT_WITH_SCALE -> return listOf( 40 | R.anim.fragnav_anim_scale_in, 41 | R.anim.fragnav_anim_scale_out, 42 | R.anim.fragnav_anim_scale_in_from_pop, 43 | R.anim.fragnav_anim_scale_out_from_pop 44 | ) 45 | ENTER_FROM_BOTTOM -> return listOf( 46 | R.anim.fragnav_anim_vertical_in_long, 47 | R.anim.fragnav_anim_vertical_out_long, 48 | R.anim.fragnav_anim_vertical_in_from_pop_long, 49 | R.anim.fragnav_anim_vertical_out_from_pop_long 50 | ) 51 | NO_ANIM -> return listOf() 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/java/com/developersancho/navigation/FragNav.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.navigation 2 | 3 | import android.view.View 4 | import androidx.fragment.app.DialogFragment 5 | import androidx.fragment.app.Fragment 6 | import androidx.fragment.app.FragmentManager 7 | 8 | class FragNav( 9 | val manager: FragmentManager?, 10 | val fragment: Fragment?, 11 | val dialogFragment: DialogFragment?, 12 | val animationType: AnimationType, 13 | val view: View?, 14 | val transitionType: TransitionType, 15 | val addToBackStack: Boolean, 16 | val viewId: Int, 17 | val tag: String?, 18 | val clearBackStack: Boolean 19 | ) { 20 | 21 | private constructor(builder: Builder) : this( 22 | builder.manager, 23 | builder.fragment, 24 | builder.dialogFragment, 25 | builder.animationType, 26 | builder.view, 27 | builder.transitionType, 28 | builder.addToBackStack, 29 | builder.viewId, 30 | builder.tag, 31 | builder.clearBackStack 32 | ) 33 | 34 | companion object { 35 | inline fun create(block: Builder.() -> Unit) = Builder().apply(block).build() 36 | } 37 | 38 | class Builder { 39 | var manager: FragmentManager? = null 40 | private set 41 | var fragment: Fragment? = null 42 | private set 43 | var dialogFragment: DialogFragment? = null 44 | private set 45 | var animationType: AnimationType = AnimationType.NO_ANIM 46 | private set 47 | var view: View? = null 48 | private set 49 | var transitionType: TransitionType = TransitionType.REPLACE 50 | private set 51 | var addToBackStack: Boolean = false 52 | private set 53 | var viewId: Int = -1 54 | private set 55 | var tag: String? = null 56 | private set 57 | var clearBackStack: Boolean = false 58 | private set 59 | 60 | fun setViewId(viewId: Int) = apply { this.viewId = viewId } 61 | 62 | fun setView(view: View) = apply { this.view = view } 63 | 64 | fun setTag(tag: String) = apply { this.tag = tag } 65 | 66 | fun setAddToBackStack(addToBackStack: Boolean) = 67 | apply { this.addToBackStack = addToBackStack } 68 | 69 | fun setAnimation(animationType: AnimationType) = 70 | apply { this.animationType = animationType } 71 | 72 | fun setTransitionType(type: TransitionType) = apply { this.transitionType = type } 73 | 74 | fun setClearBackStack(clearBackStack: Boolean) = 75 | apply { this.clearBackStack = clearBackStack } 76 | 77 | fun setFragmentManager(manager: FragmentManager?) = apply { this.manager = manager } 78 | 79 | fun setFragment(fragment: Fragment) = apply { 80 | if (fragment is DialogFragment) { 81 | this.dialogFragment = fragment 82 | } else { 83 | this.fragment = fragment 84 | this.tag = fragment.javaClass.simpleName 85 | } 86 | } 87 | 88 | fun build() = FragNav(this) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/java/com/developersancho/navigation/TransitionType.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.navigation 2 | 3 | enum class TransitionType { 4 | ADD, REPLACE, SHOW, HIDE 5 | } 6 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_anim_in.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_anim_in_from_pop.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_anim_out.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_anim_out_from_pop.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_anim_scale_in.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 19 | 20 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_anim_scale_in_from_pop.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 17 | 18 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_anim_scale_out.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 17 | 18 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_anim_scale_out_from_pop.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 19 | 20 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_anim_vertical_in_from_pop_long.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_anim_vertical_in_long.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_anim_vertical_out_from_pop_long.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_anim_vertical_out_long.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_slide_in_left.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_slide_in_right.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_slide_out_left.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/anim/fragnav_slide_out_right.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /libraries/navigation/src/main/res/values/ids.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /libraries/testutils/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /libraries/testutils/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.developersancho.buildsrc.* 2 | 3 | plugins { 4 | id("com.android.library") 5 | id("org.jetbrains.kotlin.android") 6 | } 7 | 8 | android { 9 | compileSdk = Configs.CompileSdk 10 | 11 | defaultConfig { 12 | minSdk = Configs.MinSdk 13 | targetSdk = Configs.TargetSdk 14 | testInstrumentationRunner = Configs.TestInstrumentationRunner 15 | } 16 | 17 | buildTypes { 18 | release { 19 | isMinifyEnabled = false 20 | proguardFiles( 21 | getDefaultProguardFile("proguard-android-optimize.txt"), 22 | "proguard-rules.pro" 23 | ) 24 | } 25 | } 26 | compileOptions { 27 | sourceCompatibility = JavaVersion.VERSION_11 28 | targetCompatibility = JavaVersion.VERSION_11 29 | } 30 | kotlinOptions { 31 | jvmTarget = JavaVersion.VERSION_11.toString() 32 | freeCompilerArgs = listOf( 33 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", 34 | "-opt-in=kotlinx.coroutines.FlowPreview" 35 | ) 36 | } 37 | } 38 | 39 | dependencies { 40 | api(TestingLib.Junit) 41 | api(AndroidTestingLib.JunitExt) 42 | api(AndroidTestingLib.EspressoCore) 43 | api(TestingLib.Coroutine) 44 | api(TestingLib.Truth) 45 | api(TestingLib.Robolectric) 46 | api(TestingLib.Turbine) 47 | api(TestingLib.Mockk) 48 | api(TestingLib.Okhttp) 49 | api(TestingLib.Hamcrest) 50 | api(TestingLib.Json) 51 | implementation(NetworkLib.Moshi) 52 | implementation(NetworkLib.Retrofit) 53 | implementation(NetworkLib.RetrofitMoshi) 54 | implementation(NetworkLib.LoggingInterceptor) 55 | } -------------------------------------------------------------------------------- /libraries/testutils/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 -------------------------------------------------------------------------------- /libraries/testutils/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /libraries/testutils/src/main/java/com/developersancho/testutils/BaseServiceTest.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.testutils 2 | 3 | import com.squareup.moshi.Moshi 4 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 5 | import okhttp3.OkHttpClient 6 | import okhttp3.logging.HttpLoggingInterceptor 7 | import okhttp3.mockwebserver.MockResponse 8 | import okhttp3.mockwebserver.MockWebServer 9 | import okio.buffer 10 | import okio.source 11 | import org.junit.After 12 | import org.junit.Before 13 | import org.junit.Rule 14 | import retrofit2.Retrofit 15 | import retrofit2.converter.moshi.MoshiConverterFactory 16 | import kotlin.reflect.KClass 17 | 18 | abstract class BaseServiceTest(service: KClass) { 19 | 20 | lateinit var mockWebServer: MockWebServer 21 | private lateinit var moshi: Moshi 22 | private lateinit var okhttp: OkHttpClient 23 | 24 | @get:Rule 25 | var testCoroutineRule = TestCoroutineRule() 26 | 27 | abstract val baseUrl: String 28 | 29 | val serviceLive: S by lazy { 30 | require(baseUrl != "") { "baseUrl must be not empty" } 31 | 32 | Retrofit.Builder() 33 | .client(okhttp) 34 | .baseUrl(baseUrl) 35 | .addConverterFactory(MoshiConverterFactory.create(moshi)) 36 | .build() 37 | .create(service) 38 | } 39 | 40 | val serviceMock: S by lazy { 41 | Retrofit.Builder() 42 | .client(okhttp) 43 | .baseUrl(mockWebServer.url("")) 44 | .addConverterFactory(MoshiConverterFactory.create(moshi)) 45 | .build() 46 | .create(service) 47 | } 48 | 49 | @Before 50 | fun setUp() { 51 | mockWebServer = MockWebServer().apply { 52 | start() 53 | } 54 | moshi = Moshi.Builder() 55 | .add(KotlinJsonAdapterFactory()) 56 | .build() 57 | okhttp = OkHttpClient.Builder() 58 | .followSslRedirects(true) 59 | .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) 60 | .build() 61 | } 62 | 63 | @After 64 | fun tearDown() { 65 | mockWebServer.shutdown() 66 | } 67 | 68 | fun enqueueResponse(filePath: String) { 69 | val inputStream = javaClass.classLoader?.getResourceAsStream(filePath) 70 | val bufferSource = inputStream?.source()?.buffer() ?: return 71 | val mockResponse = MockResponse() 72 | 73 | mockWebServer.enqueue( 74 | mockResponse.setBody( 75 | bufferSource.readString(Charsets.UTF_8) 76 | ) 77 | ) 78 | println( 79 | "🍏 enqueueResponse() ${Thread.currentThread().name}," + 80 | " ${bufferSource.readString(Charsets.UTF_8).length} $mockResponse" 81 | ) 82 | } 83 | 84 | private fun Retrofit.create(service: KClass): T = create(service.javaObjectType) 85 | } 86 | -------------------------------------------------------------------------------- /libraries/testutils/src/main/java/com/developersancho/testutils/MockkUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.testutils 2 | 3 | import io.mockk.MockKAnnotations 4 | import io.mockk.clearAllMocks 5 | import io.mockk.unmockkAll 6 | import org.junit.After 7 | import org.junit.Before 8 | import org.junit.Rule 9 | 10 | open class MockkUnitTest { 11 | 12 | open fun onCreate() {} 13 | 14 | open fun onDestroy() {} 15 | 16 | @get:Rule 17 | var testCoroutineRule = TestCoroutineRule() 18 | 19 | @Before 20 | fun setUp() { 21 | MockKAnnotations.init(this) 22 | onCreate() 23 | } 24 | 25 | @After 26 | fun tearDown() { 27 | onDestroy() 28 | unmockkAll() 29 | clearAllMocks() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /libraries/testutils/src/main/java/com/developersancho/testutils/TestCoroutineRule.kt: -------------------------------------------------------------------------------- 1 | package com.developersancho.testutils 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.test.* 5 | import org.junit.rules.TestRule 6 | import org.junit.runner.Description 7 | import org.junit.runners.model.Statement 8 | 9 | class TestCoroutineRule : TestRule { 10 | 11 | private val testCoroutineDispatcher = UnconfinedTestDispatcher() 12 | 13 | val testCoroutineScope = TestScope(testCoroutineDispatcher) 14 | 15 | override fun apply(base: Statement, description: Description): Statement = object : Statement() { 16 | @Throws(Throwable::class) 17 | override fun evaluate() { 18 | Dispatchers.setMain(testCoroutineDispatcher) 19 | 20 | base.evaluate() 21 | 22 | Dispatchers.resetMain() 23 | } 24 | } 25 | 26 | fun runTest(block: suspend TestScope.() -> Unit) = 27 | testCoroutineScope.runTest { block() } 28 | } 29 | -------------------------------------------------------------------------------- /libraries/testutils/src/main/java/com/developersancho/testutils/TestRobolectric.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022, developersancho 3 | * All rights reserved. 4 | */ 5 | package com.developersancho.testutils 6 | 7 | import android.app.Application 8 | import android.content.Context 9 | import android.os.Build 10 | import androidx.test.core.app.ApplicationProvider 11 | import org.junit.runner.RunWith 12 | import org.robolectric.RobolectricTestRunner 13 | import org.robolectric.annotation.Config 14 | 15 | @RunWith(RobolectricTestRunner::class) 16 | @Config( 17 | manifest = "AndroidManifest.xml", 18 | application = TestRobolectric.ApplicationStub::class, 19 | sdk = [Build.VERSION_CODES.M] 20 | ) 21 | open class TestRobolectric : MockkUnitTest() { 22 | 23 | protected val application: Application by lazy { 24 | ApplicationProvider.getApplicationContext() 25 | } 26 | protected val context: Context by lazy { 27 | application 28 | } 29 | 30 | internal class ApplicationStub : Application() 31 | } 32 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | maven("https://jitpack.io") 7 | } 8 | } 9 | @kotlin.Suppress("UnstableApiUsage") 10 | dependencyResolutionManagement { 11 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 12 | repositories { 13 | google() 14 | mavenCentral() 15 | maven("https://jitpack.io") 16 | } 17 | } 18 | rootProject.name = "HB Case" 19 | include(":appcompose") 20 | include(":app") 21 | include(":data") 22 | include(":domain") 23 | include(":libraries:framework") 24 | include(":libraries:testutils") 25 | include(":libraries:navigation") 26 | --------------------------------------------------------------------------------