├── .gitignore ├── CHANGELOG.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro ├── release │ └── output.json └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── fajarca │ │ └── buzznews │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── fajarca │ │ │ └── buzznews │ │ │ └── MainActivity.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── bottom_navigation_colors.xml │ │ ├── ic_channel.xml │ │ ├── ic_home.xml │ │ ├── ic_launcher_background.xml │ │ └── ic_trending.xml │ │ ├── layout │ │ └── activity_main.xml │ │ ├── menu │ │ └── menu_bottom_nav.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── values-night │ │ ├── colors.xml │ │ └── styles.xml │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ ├── java │ └── io │ │ └── fajarca │ │ └── buzznews │ │ └── ExampleUnitTest.kt │ └── resources │ └── mockito-extensions │ └── org.mockito.plugins.MockMaker ├── build.gradle ├── buildSrc ├── build.gradle.kts └── src │ └── main │ └── java │ └── Dependencies.kt ├── core ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── fajarca │ │ └── core │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── fajarca │ │ │ └── core │ │ │ ├── BuzzNewsApp.kt │ │ │ ├── database │ │ │ ├── Converters.kt │ │ │ ├── NewsDatabase.kt │ │ │ ├── dao │ │ │ │ ├── NewsChannelDao.kt │ │ │ │ └── NewsDao.kt │ │ │ └── entity │ │ │ │ ├── NewsChannelEntity.kt │ │ │ │ └── NewsEntity.kt │ │ │ ├── di │ │ │ ├── CoreComponent.kt │ │ │ ├── ViewModelFactory.kt │ │ │ ├── ViewModelKey.kt │ │ │ ├── modules │ │ │ │ ├── ContextModule.kt │ │ │ │ ├── CoroutineDispatcherModule.kt │ │ │ │ ├── DatabaseModule.kt │ │ │ │ ├── NetworkModule.kt │ │ │ │ └── SharedPreferenceModule.kt │ │ │ └── scope │ │ │ │ └── FeatureScope.kt │ │ │ ├── dispatcher │ │ │ ├── CoroutineDispatcherProvider.kt │ │ │ └── DispatcherProvider.kt │ │ │ ├── mapper │ │ │ ├── AsyncMapper.kt │ │ │ └── Mapper.kt │ │ │ ├── network │ │ │ ├── HttpResult.kt │ │ │ └── RemoteDataSource.kt │ │ │ ├── usecase │ │ │ └── UseCase.kt │ │ │ └── vo │ │ │ ├── Constant.kt │ │ │ ├── Result.kt │ │ │ └── UiState.kt │ └── res │ │ └── values │ │ └── strings.xml │ └── test │ └── java │ └── io │ └── fajarca │ └── core │ └── network │ └── RemoteDataSourceTest.kt ├── feature_news ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── fajarca │ │ └── news │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── fajarca │ │ │ └── news │ │ │ ├── data │ │ │ ├── NewsRepositoryImpl.kt │ │ │ ├── NewsService.kt │ │ │ ├── mapper │ │ │ │ └── NewsMapper.kt │ │ │ ├── response │ │ │ │ └── NewsDto.kt │ │ │ └── source │ │ │ │ └── NewsRemoteDataSource.kt │ │ │ ├── di │ │ │ ├── NewsComponent.kt │ │ │ ├── NewsModule.kt │ │ │ ├── RepositoryModule.kt │ │ │ └── ViewModelModule.kt │ │ │ ├── domain │ │ │ ├── entities │ │ │ │ ├── News.kt │ │ │ │ └── SearchQuery.kt │ │ │ ├── repository │ │ │ │ ├── NewsBoundaryCallback.kt │ │ │ │ └── NewsRepository.kt │ │ │ └── usecase │ │ │ │ ├── GetCachedNewsUseCase.kt │ │ │ │ ├── InsertNewsUseCase.kt │ │ │ │ └── RefreshNewsUseCase.kt │ │ │ └── presentation │ │ │ ├── adapter │ │ │ └── NewsRecyclerAdapter.kt │ │ │ ├── mapper │ │ │ └── NewsPresentationMapper.kt │ │ │ ├── model │ │ │ └── SearchResult.kt │ │ │ ├── screen │ │ │ ├── HomeFragment.kt │ │ │ └── NewsFragment.kt │ │ │ └── viewmodel │ │ │ └── HomeViewModel.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ └── ic_placeholder.xml │ │ ├── layout │ │ ├── fragment_home.xml │ │ ├── item_footer.xml │ │ ├── item_headline.xml │ │ ├── item_news.xml │ │ └── placeholder_item_news.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── colors.xml │ │ └── strings.xml │ └── test │ ├── java │ └── io │ │ └── fajarca │ │ └── news │ │ ├── data │ │ ├── NewsRepositoryImplTest.kt │ │ ├── mapper │ │ │ └── NewsMapperTest.kt │ │ └── source │ │ │ └── NewsRemoteDataSourceTest.kt │ │ ├── domain │ │ └── usecase │ │ │ ├── GetCachedNewsUseCaseTest.kt │ │ │ └── InsertNewsUseCaseTest.kt │ │ └── presentation │ │ ├── CharactersViewModelTest.kt │ │ ├── mapper │ │ └── NewsPresentationMapperTest.kt │ │ └── viewmodel │ │ └── HomeViewModelTest.kt │ └── resources │ └── mockito-extensions │ └── org.mockito.plugins.MockMaker ├── feature_news_category ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── fajarca │ │ └── news_category │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── fajarca │ │ │ └── news_category │ │ │ └── presentation │ │ │ ├── NewsCategoryFragment.kt │ │ │ ├── adapter │ │ │ └── NewsCategoryRecyclerAdapter.kt │ │ │ └── model │ │ │ └── NewsCategory.kt │ └── res │ │ ├── drawable │ │ ├── ic_business.xml │ │ ├── ic_entertainment.xml │ │ ├── ic_general.xml │ │ ├── ic_health.xml │ │ ├── ic_science.xml │ │ ├── ic_sports.xml │ │ └── ic_technology.xml │ │ ├── layout │ │ ├── fragment_news_category.xml │ │ └── item_news_category.xml │ │ └── values │ │ └── strings.xml │ └── test │ └── java │ └── io │ └── fajarca │ └── news_category │ └── ExampleUnitTest.kt ├── feature_news_channel ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── fajarca │ │ └── news_channel │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── fajarca │ │ │ └── news_channel │ │ │ ├── data │ │ │ ├── ChannelService.kt │ │ │ ├── NewsChannelRepositoryImpl.kt │ │ │ ├── mapper │ │ │ │ └── NewsChannelMapper.kt │ │ │ ├── response │ │ │ │ └── SourcesDto.kt │ │ │ └── source │ │ │ │ └── NewsChannelRemoteDataSource.kt │ │ │ ├── di │ │ │ ├── NewsChannelComponent.kt │ │ │ ├── NewsChannelModule.kt │ │ │ ├── RepositoryModule.kt │ │ │ └── ViewModelModule.kt │ │ │ ├── domain │ │ │ ├── entities │ │ │ │ ├── ChannelContent.kt │ │ │ │ ├── ChannelHeader.kt │ │ │ │ ├── NewsChannel.kt │ │ │ │ └── NewsChannelItem.kt │ │ │ ├── repository │ │ │ │ └── NewsChannelRepository.kt │ │ │ └── usecase │ │ │ │ └── GetNewsChannelUseCase.kt │ │ │ └── presentation │ │ │ ├── NewsChannelFragment.kt │ │ │ ├── NewsChannelViewModel.kt │ │ │ ├── adapter │ │ │ └── NewsChannelRecyclerAdapter.kt │ │ │ └── mapper │ │ │ └── NewsChannelPresentationMapper.kt │ └── res │ │ ├── drawable │ │ └── rounded_background.xml │ │ ├── layout │ │ ├── fragment_news_channel.xml │ │ ├── item_news_channel.xml │ │ └── item_news_channel_header.xml │ │ └── values │ │ └── strings.xml │ └── test │ ├── java │ └── io │ │ └── fajarca │ │ └── news_channel │ │ ├── ExampleUnitTest.kt │ │ ├── data │ │ └── source │ │ │ └── NewsChannelRemoteDataSourceTest.kt │ │ ├── domain │ │ └── usecase │ │ │ └── GetNewsChannelUseCaseTest.kt │ │ └── presentation │ │ └── NewsChannelViewModelTest.kt │ └── resources │ └── mockito-extensions │ └── org.mockito.plugins.MockMaker ├── feature_web_browser ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── fajarca │ │ └── web_browser │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── fajarca │ │ │ └── web_browser │ │ │ └── WebBrowserFragment.kt │ └── res │ │ ├── layout │ │ └── fragment_web_browser.xml │ │ └── values │ │ └── strings.xml │ └── test │ └── java │ └── io │ └── fajarca │ └── web_browser │ └── ExampleUnitTest.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── navigation ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── fajarca │ │ └── navigation │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── fajarca │ │ │ └── navigation │ │ │ └── Origin.kt │ └── res │ │ ├── navigation │ │ └── nav_main.xml │ │ └── values │ │ └── strings.xml │ └── test │ └── java │ └── io │ └── fajarca │ └── navigation │ └── ExampleUnitTest.kt ├── presentation ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── io │ │ └── fajarca │ │ └── presentation │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── io │ │ │ └── fajarca │ │ │ └── presentation │ │ │ ├── BaseFragment.kt │ │ │ ├── adapter │ │ │ └── BindingAdapter.kt │ │ │ ├── customview │ │ │ ├── ShimmerView.kt │ │ │ └── UiStateView.kt │ │ │ └── extension │ │ │ ├── Extensions.kt │ │ │ └── ViewExtension.kt │ └── res │ │ ├── drawable │ │ ├── ic_error.xml │ │ ├── ic_no_connection.xml │ │ ├── ic_no_data.xml │ │ └── ic_placeholder.xml │ │ ├── font │ │ ├── googlesans.xml │ │ ├── googlesans_italic.ttf │ │ └── googlesans_regular.ttf │ │ ├── layout │ │ ├── default_placeholder.xml │ │ ├── layout_ui_state_view.xml │ │ ├── shimmer_placeholder.xml │ │ └── toolbar.xml │ │ └── values │ │ ├── attrs.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── io │ └── fajarca │ └── presentation │ └── ExampleUnitTest.kt ├── settings.gradle └── test_util ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src ├── androidTest └── java │ └── io │ └── fajarca │ └── testutil │ └── ExampleInstrumentedTest.kt ├── main ├── AndroidManifest.xml ├── java │ └── io │ │ └── fajarca │ │ └── testutil │ │ ├── LifeCycleTestOwner.kt │ │ ├── extension │ │ └── TestExtensions.kt │ │ └── rule │ │ └── CoroutineTestRule.kt └── res │ └── values │ └── strings.xml └── test └── java └── io └── fajarca └── testutil └── ExampleUnitTest.kt /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/androidstudio 3 | # Edit at https://www.gitignore.io/?templates=androidstudio 4 | 5 | ### AndroidStudio ### 6 | # Covers files to be ignored for android development using Android Studio. 7 | 8 | # Built application files 9 | *.apk 10 | *.ap_ 11 | 12 | # Files for the ART/Dalvik VM 13 | *.dex 14 | 15 | # Java class files 16 | *.class 17 | 18 | # Generated files 19 | bin/ 20 | gen/ 21 | out/ 22 | 23 | # Gradle files 24 | .gradle 25 | .gradle/ 26 | build/ 27 | 28 | # Signing files 29 | .signing/ 30 | 31 | # Local configuration file (sdk path, etc) 32 | local.properties 33 | 34 | # Proguard folder generated by Eclipse 35 | proguard/ 36 | 37 | # Log Files 38 | *.log 39 | 40 | # Android Studio 41 | /*/build/ 42 | /*/local.properties 43 | /*/out 44 | /*/*/build 45 | /*/*/production 46 | captures/ 47 | .navigation/ 48 | *.ipr 49 | *~ 50 | *.swp 51 | 52 | # Android Patch 53 | gen-external-apklibs 54 | 55 | # External native build folder generated in Android Studio 2.2 and later 56 | .externalNativeBuild 57 | 58 | # NDK 59 | obj/ 60 | 61 | # IntelliJ IDEA 62 | *.iml 63 | *.iws 64 | /out/ 65 | 66 | # User-specific configurations 67 | .idea/caches/ 68 | .idea/libraries/ 69 | .idea/shelf/ 70 | .idea/workspace.xml 71 | .idea/tasks.xml 72 | .idea/.name 73 | .idea/compiler.xml 74 | .idea/copyright/profiles_settings.xml 75 | .idea/encodings.xml 76 | .idea/misc.xml 77 | .idea/modules.xml 78 | .idea/scopes/scope_settings.xml 79 | .idea/dictionaries 80 | .idea/vcs.xml 81 | .idea/jsLibraryMappings.xml 82 | .idea/datasources.xml 83 | .idea/dataSources.ids 84 | .idea/sqlDataSources.xml 85 | .idea/dynamic.xml 86 | .idea/uiDesigner.xml 87 | .idea/assetWizardSettings.xml 88 | .idea/gradle.xml 89 | 90 | # OS-specific files 91 | .DS_Store 92 | .DS_Store? 93 | ._* 94 | .Spotlight-V100 95 | .Trashes 96 | ehthumbs.db 97 | Thumbs.db 98 | 99 | # Legacy Eclipse project files 100 | .classpath 101 | .project 102 | .cproject 103 | .settings/ 104 | 105 | # Mobile Tools for Java (J2ME) 106 | .mtj.tmp/ 107 | 108 | # Package Files # 109 | *.war 110 | *.ear 111 | 112 | # virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) 113 | hs_err_pid* 114 | 115 | ## Plugin-specific files: 116 | 117 | # mpeltonen/sbt-idea plugin 118 | .idea_modules/ 119 | 120 | # JIRA plugin 121 | atlassian-ide-plugin.xml 122 | 123 | # Mongo Explorer plugin 124 | .idea/mongoSettings.xml 125 | 126 | # Crashlytics plugin (for Android Studio and IntelliJ) 127 | com_crashlytics_export_strings.xml 128 | crashlytics.properties 129 | crashlytics-build.properties 130 | fabric.properties 131 | 132 | ### AndroidStudio Patch ### 133 | 134 | !/gradle/wrapper/gradle-wrapper.jar 135 | 136 | # End of https://www.gitignore.io/api/androidstudio -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC. 4 | 5 | Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). 6 | 7 | #### v2.2.1 8 | 9 | > 8 January 2020 10 | 11 | - Initial commit [`15214d1`](https://github.com/fajarca/android-clean-architecture-coroutine/commit/15214d1b8f740f105298518958de324278560a6b) 12 | - Move app code to feature module [`416d0c9`](https://github.com/fajarca/android-clean-architecture-coroutine/commit/416d0c9d29d2087516d5a9af791363af71a8b253) 13 | - Display list of marvel characters [`6ac15d2`](https://github.com/fajarca/android-clean-architecture-coroutine/commit/6ac15d28134e82cfadb5676715751bf9346851ac) 14 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /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. 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 22 | -------------------------------------------------------------------------------- /app/release/output.json: -------------------------------------------------------------------------------- 1 | [{"outputType":{"type":"APK"},"apkData":{"type":"MAIN","splits":[],"versionCode":1,"versionName":"1.0","enabled":true,"outputFile":"app-release.apk","fullName":"release","baseName":"release"},"path":"app-release.apk","properties":{}}] -------------------------------------------------------------------------------- /app/src/androidTest/java/io/fajarca/buzznews/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.buzznews 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.* 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("io.fajarca.todo", appContext.packageName) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/java/io/fajarca/buzznews/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.buzznews 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.navigation.NavController 7 | import androidx.navigation.Navigation 8 | import androidx.navigation.ui.setupWithNavController 9 | import kotlinx.android.synthetic.main.activity_main.* 10 | 11 | class MainActivity : AppCompatActivity() { 12 | 13 | 14 | private val navController by lazy { Navigation.findNavController(this, R.id.navHostFragment) } 15 | 16 | override fun onCreate(savedInstanceState: Bundle?) { 17 | super.onCreate(savedInstanceState) 18 | setContentView(R.layout.activity_main) 19 | setupNavigation() 20 | } 21 | 22 | private fun setupNavigation() { 23 | bottomNavigationView.setupWithNavController(navController) 24 | navController.addOnDestinationChangedListener(navigationListener) 25 | } 26 | 27 | private val navigationListener = NavController.OnDestinationChangedListener { _, destination, _ -> 28 | when(destination.id) { 29 | R.id.fragmentWebBrowser -> hideBottomNavigation() 30 | else -> showBottomNavigation() 31 | } 32 | } 33 | 34 | private fun showBottomNavigation() { 35 | bottomNavigationView.visibility = View.VISIBLE 36 | } 37 | private fun hideBottomNavigation() { 38 | bottomNavigationView.visibility = View.GONE 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/bottom_navigation_colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_channel.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_home.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_trending.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 21 | 22 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_bottom_nav.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 9 | 10 | 15 | 16 | 21 | 22 | -------------------------------------------------------------------------------- /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.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #212121 4 | #424242 5 | #212121 6 | #bdbdbd 7 | #bdbdbd 8 | #eeeeee 9 | #F44336 10 | #FFFFFF 11 | #212121 12 | #FAFAFA 13 | #FAFAFA 14 | #616161 15 | #212121 16 | #9e9e9e 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #fafafa 4 | #c7c7c7 5 | #212121 6 | #bdbdbd 7 | #efefef 8 | #fafafa 9 | #F44336 10 | #FFFFFF 11 | #fafafa 12 | #212121 13 | #212121 14 | #FAFAFA 15 | #bdbdbd 16 | #212121 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | BuzzNews 3 | Home 4 | Category 5 | Channel 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/test/java/io/fajarca/buzznews/ExampleUnitTest.kt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/app/src/test/java/io/fajarca/buzznews/ExampleUnitTest.kt -------------------------------------------------------------------------------- /app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | ext.kotlin_version = '1.3.61' 5 | 6 | 7 | repositories { 8 | google() 9 | jcenter() 10 | maven { 11 | url "https://plugins.gradle.org/m2/" 12 | } 13 | 14 | } 15 | dependencies { 16 | classpath "com.android.tools.build:gradle:${Versions.gradle}" 17 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}" 18 | classpath "androidx.navigation:navigation-safe-args-gradle-plugin:${Versions.navigationComponentSafeArgsPlugins}" 19 | classpath "org.jlleitschuh.gradle:ktlint-gradle:${Versions.ktlint}" 20 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}" 21 | classpath "org.jacoco:org.jacoco.core:${Versions.jacoco}" 22 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 23 | } 24 | 25 | 26 | } 27 | 28 | 29 | allprojects { 30 | repositories { 31 | google() 32 | jcenter() 33 | maven { url 'https://jitpack.io' } 34 | } 35 | apply plugin: "org.jlleitschuh.gradle.ktlint" 36 | } 37 | 38 | task clean(type: Delete) { 39 | delete rootProject.buildDir 40 | } 41 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | google() 7 | jcenter() 8 | } 9 | -------------------------------------------------------------------------------- /core/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /core/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | 6 | android { 7 | dataBinding { 8 | enabled = true 9 | } 10 | compileSdkVersion ApplicationConfig.compileSdk 11 | defaultConfig { 12 | minSdkVersion ApplicationConfig.minSdk 13 | targetSdkVersion ApplicationConfig.targetSdk 14 | versionCode ApplicationConfig.versionCode 15 | versionName ApplicationConfig.versionName 16 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 17 | } 18 | buildTypes { 19 | debug { 20 | buildConfigField "String", "BASE_URL", BASE_URL 21 | buildConfigField "String", "API_KEY", API_KEY 22 | } 23 | release { 24 | buildConfigField "String", "BASE_URL", BASE_URL 25 | buildConfigField "String", "API_KEY", API_KEY 26 | minifyEnabled false 27 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 28 | } 29 | } 30 | compileOptions { 31 | sourceCompatibility JavaVersion.VERSION_1_8 32 | targetCompatibility JavaVersion.VERSION_1_8 33 | } 34 | 35 | kotlinOptions { 36 | jvmTarget = JavaVersion.VERSION_1_8.toString() 37 | } 38 | } 39 | 40 | dependencies { 41 | implementation fileTree(dir: 'libs', include: ['*.jar']) 42 | 43 | api Libraries.room_ktx 44 | api Libraries.room_runtime 45 | kapt Libraries.room_compiler 46 | 47 | implementation Libraries.moshiConverter 48 | implementation Libraries.loggingInterceptor 49 | 50 | implementation AndroidXLibraries.paging 51 | 52 | api Libraries.timber 53 | api Libraries.retrofit 54 | api Libraries.moshi 55 | 56 | api Libraries.dagger 57 | kapt Libraries.daggerCompiler 58 | 59 | implementation Libraries.lifecycleExtensions 60 | kapt Libraries.lifecycleCompiler 61 | 62 | testImplementation TestLibraries.junit 63 | testImplementation TestLibraries.lifecycleTesting 64 | testImplementation TestLibraries.coroutine 65 | testImplementation TestLibraries.mockito 66 | } 67 | -------------------------------------------------------------------------------- /core/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/core/consumer-rules.pro -------------------------------------------------------------------------------- /core/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 22 | -------------------------------------------------------------------------------- /core/src/androidTest/java/io/fajarca/core/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("io.fajarca.core.test", appContext.packageName) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /core/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/BuzzNewsApp.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import io.fajarca.core.di.CoreComponent 6 | import io.fajarca.core.di.DaggerCoreComponent 7 | import timber.log.Timber 8 | 9 | class BuzzNewsApp : Application() { 10 | 11 | 12 | 13 | lateinit var coreComponent: CoreComponent 14 | 15 | companion object { 16 | 17 | /** 18 | * Obtain core dagger component. 19 | * 20 | * @param context The application context 21 | */ 22 | @JvmStatic 23 | fun coreComponent(context: Context)= (context.applicationContext as? BuzzNewsApp)?.coreComponent 24 | } 25 | 26 | override fun onCreate() { 27 | super.onCreate() 28 | 29 | initCoreDependencyInjection() 30 | initTimber() 31 | } 32 | 33 | 34 | 35 | private fun initTimber() { 36 | if (BuildConfig.DEBUG) { 37 | Timber.plant(Timber.DebugTree()) 38 | } 39 | } 40 | 41 | /** 42 | * Initialize core dependency injection component. 43 | */ 44 | private fun initCoreDependencyInjection() { 45 | coreComponent = DaggerCoreComponent 46 | .builder() 47 | .application(this) 48 | .build() 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/database/Converters.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.database 2 | 3 | import androidx.room.TypeConverter 4 | import java.util.* 5 | 6 | class Converters { 7 | @TypeConverter 8 | fun fromTimestamp(timestamp: Long?): Date? = timestamp?.let { Date(it) } 9 | 10 | @TypeConverter 11 | fun dateToTimestamp(date: Date?): Long? = date?.time 12 | 13 | @TypeConverter 14 | fun isActive(isActive: Boolean): Int = if (isActive) 1 else 0 15 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/database/NewsDatabase.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.database 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import androidx.room.TypeConverters 6 | import io.fajarca.core.database.dao.NewsChannelDao 7 | import io.fajarca.core.database.dao.NewsDao 8 | import io.fajarca.core.database.entity.NewsChannelEntity 9 | import io.fajarca.core.database.entity.NewsEntity 10 | 11 | 12 | @Database(entities = [NewsEntity::class, NewsChannelEntity::class], version = 1) 13 | @TypeConverters(Converters::class) 14 | abstract class NewsDatabase : RoomDatabase() { 15 | abstract fun newsDao(): NewsDao 16 | abstract fun newsChannelDao() : NewsChannelDao 17 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/database/dao/NewsChannelDao.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.database.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import io.fajarca.core.database.entity.NewsChannelEntity 8 | 9 | @Dao 10 | abstract class NewsChannelDao { 11 | 12 | @Query("SELECT * FROM news_channels") 13 | abstract suspend fun findAll(): List 14 | 15 | @Insert(onConflict = OnConflictStrategy.REPLACE) 16 | abstract suspend fun insertAll(newsChannels: List) 17 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/database/dao/NewsDao.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.database.dao 2 | 3 | import androidx.paging.DataSource 4 | import androidx.room.* 5 | import io.fajarca.core.database.entity.NewsEntity 6 | 7 | @Dao 8 | abstract class NewsDao { 9 | 10 | @Query("SELECT * FROM news WHERE country = :country ORDER BY publishedAt DESC") 11 | abstract fun findByCountry(country : String): DataSource.Factory 12 | 13 | @Query("SELECT * FROM news WHERE category = :category ORDER BY publishedAt DESC") 14 | abstract fun findByCategory(category : String): DataSource.Factory 15 | 16 | @Query("SELECT * FROM news ORDER BY publishedAt DESC") 17 | abstract fun findAll(): DataSource.Factory 18 | 19 | @Insert(onConflict = OnConflictStrategy.REPLACE) 20 | abstract suspend fun insertAll(news: List) 21 | 22 | @Query("DELETE FROM news") 23 | abstract suspend fun deleteAll() 24 | /** 25 | * Execute multiple queries in single transaction 26 | */ 27 | @Transaction 28 | open suspend fun deleteAndInsertInTransaction(news : List) { 29 | deleteAll() 30 | insertAll(news) 31 | } 32 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/database/entity/NewsChannelEntity.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.database.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | @Entity(tableName = "news_channels") 8 | data class NewsChannelEntity( 9 | @PrimaryKey 10 | @ColumnInfo(name = "id") 11 | var id: String = "", 12 | @ColumnInfo(name = "name") 13 | var name: String = "", 14 | @ColumnInfo(name = "country") 15 | var country: String = "", 16 | @ColumnInfo(name = "url") 17 | var url: String = "" 18 | ) -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/database/entity/NewsEntity.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.database.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | @Entity(tableName = "news") 8 | data class NewsEntity( 9 | @PrimaryKey 10 | @ColumnInfo(name = "title") 11 | var title: String = "", 12 | @ColumnInfo(name = "url") 13 | var url: String = "", 14 | @ColumnInfo(name = "imageUrl") 15 | var imageUrl: String = "", 16 | @ColumnInfo(name = "country") 17 | var country: String = "", 18 | @ColumnInfo(name = "category") 19 | var category: String = "", 20 | @ColumnInfo(name = "publishedAt") 21 | var publishedAt: String = "", 22 | @ColumnInfo(name = "source_id") 23 | var sourceId: String = "", 24 | @ColumnInfo(name = "source_name") 25 | var sourceName: String = "" 26 | ) -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/di/CoreComponent.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.di 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.content.SharedPreferences 6 | import dagger.BindsInstance 7 | import dagger.Component 8 | import io.fajarca.core.database.NewsDatabase 9 | import io.fajarca.core.di.modules.* 10 | import io.fajarca.core.dispatcher.DispatcherProvider 11 | import retrofit2.Retrofit 12 | import javax.inject.Singleton 13 | 14 | 15 | @Singleton 16 | @Component( 17 | modules = [ 18 | ContextModule::class, 19 | NetworkModule::class, 20 | DatabaseModule::class, 21 | SharedPreferenceModule::class, 22 | CoroutineDispatcherModule::class 23 | ] 24 | ) 25 | interface CoreComponent { 26 | fun context() : Context 27 | fun sharedPreference() : SharedPreferences 28 | fun sharedPreferenceEditor() : SharedPreferences.Editor 29 | fun marvelDatabase() : NewsDatabase 30 | fun retrofit() : Retrofit 31 | fun dispatcher() : DispatcherProvider 32 | 33 | 34 | @Component.Builder 35 | interface Builder { 36 | @BindsInstance 37 | fun application(application: Application): Builder 38 | 39 | fun build(): CoreComponent 40 | } 41 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/di/ViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.di 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import io.fajarca.core.di.scope.FeatureScope 6 | import javax.inject.Inject 7 | import javax.inject.Provider 8 | 9 | @FeatureScope 10 | class ViewModelFactory @Inject constructor(private val viewModels: MutableMap, Provider>) : ViewModelProvider.Factory { 11 | 12 | override fun create(modelClass: Class): T = viewModels[modelClass]?.get() as T 13 | } 14 | -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/di/ViewModelKey.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.di 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.MapKey 5 | import kotlin.reflect.KClass 6 | 7 | @Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) 8 | @kotlin.annotation.Retention(AnnotationRetention.RUNTIME) 9 | @MapKey 10 | annotation class ViewModelKey(val value: KClass) 11 | -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/di/modules/ContextModule.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.di.modules 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import dagger.Module 6 | import dagger.Provides 7 | import javax.inject.Singleton 8 | 9 | @Module 10 | class ContextModule { 11 | @Provides 12 | @Singleton 13 | fun provideContext(app: Application): Context = app 14 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/di/modules/CoroutineDispatcherModule.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.di.modules 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import io.fajarca.core.dispatcher.CoroutineDispatcherProvider 6 | import io.fajarca.core.dispatcher.DispatcherProvider 7 | 8 | @Module 9 | interface CoroutineDispatcherModule { 10 | @Binds 11 | fun bindDispatcher(dispatcherProvider: CoroutineDispatcherProvider) : DispatcherProvider 12 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/di/modules/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.di.modules 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import dagger.Module 6 | import dagger.Provides 7 | import io.fajarca.core.database.NewsDatabase 8 | import io.fajarca.core.vo.Constant 9 | import javax.inject.Singleton 10 | 11 | @Module 12 | class DatabaseModule { 13 | 14 | @Provides 15 | @Singleton 16 | fun provideDatabase(context: Context) : NewsDatabase = Room.databaseBuilder(context, NewsDatabase::class.java, Constant.DATABASE_NAME) 17 | .build() 18 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/di/modules/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.di.modules 2 | 3 | import android.content.Context 4 | import dagger.Module 5 | import dagger.Provides 6 | import io.fajarca.core.BuildConfig 7 | import okhttp3.* 8 | import okhttp3.logging.HttpLoggingInterceptor 9 | import retrofit2.Retrofit 10 | import retrofit2.converter.moshi.MoshiConverterFactory 11 | import javax.inject.Singleton 12 | 13 | @Module 14 | class NetworkModule { 15 | 16 | @Provides 17 | @Singleton 18 | fun provideHttpCache(context: Context): Cache { 19 | val cacheSize: Long = 10 * 10 * 1024 20 | return Cache(context.cacheDir, cacheSize) 21 | } 22 | 23 | @Provides 24 | @Singleton 25 | fun provideLoggingInterceptor(): HttpLoggingInterceptor { 26 | val loggingInterceptor = HttpLoggingInterceptor() 27 | loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY 28 | return loggingInterceptor 29 | } 30 | 31 | 32 | @Provides 33 | @Singleton 34 | fun provideAuthenticator() : Authenticator { 35 | return object : Authenticator { 36 | override fun authenticate(route: Route?, response: Response): Request? { 37 | return response 38 | .request 39 | .newBuilder() 40 | .header("Authorization", BuildConfig.API_KEY) 41 | .build() 42 | } 43 | 44 | } 45 | } 46 | 47 | @Provides 48 | @Singleton 49 | fun provideOkHttpClient( 50 | loggingInterceptor: HttpLoggingInterceptor, 51 | cache: Cache, 52 | authenticator: Authenticator 53 | ): OkHttpClient { 54 | val client = OkHttpClient.Builder() 55 | client.cache(cache) 56 | client.authenticator(authenticator) 57 | if (BuildConfig.DEBUG) { 58 | client.addInterceptor(loggingInterceptor) 59 | } 60 | return client.build() 61 | } 62 | 63 | @Provides 64 | @Singleton 65 | fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { 66 | return Retrofit.Builder() 67 | .baseUrl(BuildConfig.BASE_URL) 68 | .addConverterFactory(MoshiConverterFactory.create()) 69 | .client(okHttpClient) 70 | .build() 71 | } 72 | 73 | 74 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/di/modules/SharedPreferenceModule.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.di.modules 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import dagger.Module 6 | import dagger.Provides 7 | import io.fajarca.core.vo.Constant 8 | import javax.inject.Singleton 9 | 10 | 11 | @Module 12 | class SharedPreferenceModule { 13 | 14 | @Provides 15 | @Singleton 16 | fun providesPreference(context: Context): SharedPreferences = context.getSharedPreferences(Constant.PREF_NAME, Context.MODE_PRIVATE) 17 | 18 | @Provides 19 | @Singleton 20 | fun providesSharedPreference(sharedPreferences: SharedPreferences): SharedPreferences.Editor = sharedPreferences.edit() 21 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/di/scope/FeatureScope.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.di.scope 2 | 3 | import javax.inject.Scope 4 | 5 | 6 | @Scope 7 | @kotlin.annotation.Retention(AnnotationRetention.RUNTIME) 8 | annotation class FeatureScope -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/dispatcher/CoroutineDispatcherProvider.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.dispatcher 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.Dispatchers 5 | import javax.inject.Inject 6 | 7 | /** 8 | * Provide coroutines context. 9 | */ 10 | class CoroutineDispatcherProvider @Inject constructor() : DispatcherProvider { 11 | override val ui: CoroutineDispatcher 12 | get() = Dispatchers.Main 13 | override val io: CoroutineDispatcher 14 | get() = Dispatchers.IO 15 | override val default: CoroutineDispatcher 16 | get() = Dispatchers.Default 17 | override val unconfined: CoroutineDispatcher 18 | get() = Dispatchers.Unconfined 19 | } 20 | -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/dispatcher/DispatcherProvider.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.dispatcher 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | 5 | interface DispatcherProvider { 6 | val io: CoroutineDispatcher 7 | val ui: CoroutineDispatcher 8 | val default: CoroutineDispatcher 9 | val unconfined: CoroutineDispatcher 10 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/mapper/AsyncMapper.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.mapper 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | 5 | abstract class AsyncMapper{ 6 | abstract suspend fun map(dispatcher: CoroutineDispatcher, input : I) : O 7 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/mapper/Mapper.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.mapper 2 | 3 | abstract class Mapper{ 4 | abstract fun map(input : I) : O 5 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/network/HttpResult.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.network 2 | 3 | /** 4 | * various error status to know what happened if something goes wrong with a repository call 5 | */ 6 | enum class HttpResult { 7 | NO_CONNECTION, 8 | TIMEOUT, 9 | CLIENT_ERROR, 10 | BAD_RESPONSE, 11 | SERVER_ERROR, 12 | NOT_DEFINED, 13 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/network/RemoteDataSource.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.network 2 | 3 | import com.squareup.moshi.Moshi 4 | import io.fajarca.core.vo.Result 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import kotlinx.coroutines.withContext 7 | import retrofit2.HttpException 8 | import java.io.IOException 9 | import java.net.SocketTimeoutException 10 | import java.net.UnknownHostException 11 | 12 | open class RemoteDataSource { 13 | open suspend fun safeApiCall(dispatcher: CoroutineDispatcher, apiCall: suspend () -> T): Result { 14 | return withContext(dispatcher) { 15 | try { 16 | Result.Success(apiCall.invoke()) 17 | } catch (throwable: Throwable) { 18 | when (throwable) { 19 | is HttpException -> { 20 | val result = when(throwable.code()) { 21 | in 400..451 -> parseHttpError(throwable) 22 | in 500..599 -> error(HttpResult.SERVER_ERROR, throwable.code(),"Server error") 23 | else -> error(HttpResult.NOT_DEFINED, throwable.code(), "Undefined error") 24 | } 25 | result 26 | } 27 | is UnknownHostException -> error(HttpResult.NO_CONNECTION, null, "No internet connection") 28 | is SocketTimeoutException -> error(HttpResult.TIMEOUT,null, "Slow connection") 29 | is IOException -> error(HttpResult.BAD_RESPONSE, null, throwable.message) 30 | else -> error(HttpResult.NOT_DEFINED, null, throwable.message) 31 | 32 | } 33 | } 34 | } 35 | } 36 | 37 | private fun error(cause : HttpResult, code : Int?, errorMessage : String?) : Result.Error { 38 | return Result.Error(cause, code, errorMessage) 39 | } 40 | 41 | private fun parseHttpError(throwable: HttpException) : Result { 42 | return try { 43 | val errorBody = throwable.response()?.errorBody()?.string() ?: "Unknown HTTP error body" 44 | val moshi = Moshi.Builder().build() 45 | val adapter = moshi.adapter(Object::class.java) 46 | val errorMessage = adapter.fromJson(errorBody) 47 | error(HttpResult.CLIENT_ERROR, throwable.code(), errorMessage.toString()) 48 | } catch (exception : Exception) { 49 | error(HttpResult.CLIENT_ERROR, throwable.code(), exception.localizedMessage) 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/usecase/UseCase.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.usecase 2 | 3 | abstract class UseCase { 4 | abstract suspend fun execute(onSuccess : (data : T) -> Unit, onError : (throwable : Throwable) -> Unit): T 5 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/vo/Constant.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.vo 2 | 3 | object Constant { 4 | const val DATABASE_NAME = "news.db" 5 | const val PREF_NAME = "app_pref" 6 | } 7 | -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/vo/Result.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.vo 2 | 3 | import io.fajarca.core.network.HttpResult 4 | 5 | sealed class Result { 6 | object Loading : Result() 7 | data class Success(val data: T) : Result() 8 | data class Error(val cause: HttpResult, val code : Int? = null, val errorMessage : String? = null) : Result() 9 | } -------------------------------------------------------------------------------- /core/src/main/java/io/fajarca/core/vo/UiState.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.vo 2 | 3 | sealed class UiState { 4 | object Loading : UiState() 5 | object Success: UiState() 6 | object Complete : UiState() 7 | object Error: UiState() 8 | } 9 | -------------------------------------------------------------------------------- /core/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Core 3 | 4 | -------------------------------------------------------------------------------- /core/src/test/java/io/fajarca/core/network/RemoteDataSourceTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.core.network 2 | 3 | import io.fajarca.core.vo.Result 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.test.TestCoroutineDispatcher 6 | import kotlinx.coroutines.test.runBlockingTest 7 | import org.junit.Assert.assertEquals 8 | import org.junit.Before 9 | import org.junit.Test 10 | import java.io.IOException 11 | import java.net.SocketTimeoutException 12 | import java.net.UnknownHostException 13 | 14 | @ExperimentalCoroutinesApi 15 | class RemoteDataSourceTest { 16 | 17 | private val dispatcher = TestCoroutineDispatcher() 18 | private lateinit var dataSource: RemoteDataSource 19 | 20 | 21 | @Before 22 | fun setUp() { 23 | dataSource = RemoteDataSource() 24 | } 25 | 26 | 27 | @Test 28 | fun `when lambda returns successfully then it should emit the result as success`() { 29 | runBlockingTest { 30 | val lambdaResult = true 31 | val result = dataSource.safeApiCall(dispatcher) { lambdaResult } 32 | assertEquals(Result.Success(lambdaResult), result) 33 | } 34 | } 35 | 36 | @Test 37 | fun `when lambda throws SocketTimeOutException then it should emit the result as timeout error`() { 38 | runBlockingTest { 39 | val result = dataSource.safeApiCall(dispatcher) { throw SocketTimeoutException() } 40 | assertEquals(Result.Error(HttpResult.TIMEOUT), result) 41 | } 42 | } 43 | 44 | @Test 45 | fun `when lambda throws UnknownHostException then it should emit the result as no connection error`() { 46 | runBlockingTest { 47 | val result = dataSource.safeApiCall(dispatcher) { throw UnknownHostException() } 48 | assertEquals(Result.Error(HttpResult.NO_CONNECTION), result) 49 | } 50 | } 51 | 52 | @Test 53 | fun `when lambda throws IOException then it should emit the result as bad response error`() { 54 | runBlockingTest { 55 | val result = dataSource.safeApiCall(dispatcher) { throw IOException() } 56 | assertEquals(Result.Error(HttpResult.BAD_RESPONSE), result) 57 | } 58 | } 59 | 60 | @Test 61 | fun `when lambda throws other exception then it should emit the result as not defined error`() { 62 | runBlockingTest { 63 | val result = dataSource.safeApiCall(dispatcher) { throw Exception() } 64 | assertEquals(Result.Error(HttpResult.NOT_DEFINED), result) 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /feature_news/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /feature_news/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_news/consumer-rules.pro -------------------------------------------------------------------------------- /feature_news/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 22 | -------------------------------------------------------------------------------- /feature_news/src/androidTest/java/io/fajarca/news/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("io.fajarca.feature.test", appContext.packageName) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /feature_news/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/data/NewsRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.data 2 | 3 | import androidx.paging.DataSource 4 | import io.fajarca.core.database.dao.NewsDao 5 | import io.fajarca.core.dispatcher.DispatcherProvider 6 | import io.fajarca.core.network.HttpResult 7 | import io.fajarca.core.vo.Result 8 | import io.fajarca.news.data.mapper.NewsMapper 9 | import io.fajarca.news.data.source.NewsRemoteDataSource 10 | import io.fajarca.news.domain.entities.News 11 | import io.fajarca.news.domain.repository.NewsRepository 12 | import javax.inject.Inject 13 | 14 | class NewsRepositoryImpl @Inject constructor( 15 | private val dispatcher: DispatcherProvider, 16 | private val mapper: NewsMapper, 17 | private val dao: NewsDao, 18 | private val remoteDataSource: NewsRemoteDataSource 19 | ) : NewsRepository { 20 | 21 | override suspend fun refreshNews(country: String?, category: String?, page: Int, pageSize: Int): Result> { 22 | return when(val apiResult = remoteDataSource.getNews(dispatcher.io, country, category, page, pageSize)) { 23 | is Result.Loading -> Result.Loading 24 | is Result.Success -> { 25 | val news = mapper.map(country, category, apiResult.data) 26 | dao.insertAll(news) 27 | Result.Success(emptyList()) 28 | } 29 | is Result.Error -> Result.Error(apiResult.cause, apiResult.code, apiResult.errorMessage) 30 | } 31 | } 32 | 33 | 34 | override suspend fun findAllNews(country: String?, category: String?, page: Int, pageSize: Int, onSuccessAction: () -> Unit, onErrorAction: (cause: HttpResult, code : Int, errorMessage : String) -> Unit) { 35 | when(val apiResult = remoteDataSource.getNews(dispatcher.io, country, category, page, pageSize)) { 36 | is Result.Success -> { 37 | onSuccessAction() 38 | val news = mapper.map(country, category, apiResult.data) 39 | dao.insertAll(news) 40 | } 41 | is Result.Error -> { 42 | onErrorAction(apiResult.cause, apiResult.code ?: 0, apiResult.errorMessage ?: "") 43 | } 44 | } 45 | } 46 | 47 | override fun findByCountry(country: String?): DataSource.Factory { 48 | return dao.findByCountry(country ?: "").map { mapper.mapToDomain(it) } 49 | } 50 | 51 | override fun findByCategory(category: String?): DataSource.Factory { 52 | return dao.findByCategory(category ?: "").map { mapper.mapToDomain(it) } 53 | } 54 | 55 | override fun findAll(): DataSource.Factory { 56 | return dao.findAll().map { mapper.mapToDomain(it) } 57 | } 58 | } -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/data/NewsService.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.data 2 | 3 | import io.fajarca.news.data.response.NewsDto 4 | import retrofit2.http.GET 5 | import retrofit2.http.Query 6 | 7 | 8 | interface NewsService { 9 | 10 | @GET("v2/top-headlines") 11 | suspend fun getNews(@Query("country") country : String?, 12 | @Query("category") category : String?, 13 | @Query("page") page: Int, 14 | @Query("pageSize") pageSize: Int): NewsDto 15 | 16 | 17 | } -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/data/mapper/NewsMapper.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.data.mapper 2 | 3 | import io.fajarca.core.database.entity.NewsEntity 4 | import io.fajarca.news.data.response.NewsDto 5 | import io.fajarca.news.domain.entities.News 6 | 7 | class NewsMapper { 8 | 9 | fun map(country: String?, category: String?, input: NewsDto): List { 10 | val headlines = mutableListOf() 11 | input.articles?.map { 12 | headlines.add( 13 | NewsEntity( 14 | it?.title ?: "", 15 | it?.url ?: "", 16 | it?.urlToImage ?: "", 17 | country ?: "", 18 | category ?: "", 19 | it?.publishedAt ?: "", 20 | it?.source?.id ?: "", 21 | it?.source?.name ?: "" 22 | ) 23 | ) 24 | } 25 | return headlines 26 | } 27 | 28 | fun mapToDomain(input: NewsEntity): News { 29 | return News( 30 | input.title, 31 | input.url, 32 | input.imageUrl, 33 | input.publishedAt, 34 | input.category, 35 | input.sourceId, 36 | input.sourceName 37 | ) 38 | } 39 | } -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/data/response/NewsDto.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.data.response 2 | 3 | 4 | import com.squareup.moshi.Json 5 | 6 | data class NewsDto( 7 | @Json(name = "articles") 8 | val articles: List? = null 9 | ) { 10 | data class Article( 11 | @Json(name = "publishedAt") 12 | val publishedAt: String? = null, 13 | @Json(name = "title") 14 | val title: String? = null, 15 | @Json(name = "url") 16 | val url: String? = null, 17 | @Json(name = "urlToImage") 18 | val urlToImage: String? = null, 19 | @Json(name = "source") 20 | val source : Source? = null 21 | ) { 22 | data class Source( 23 | @Json(name = "id") 24 | val id: String? = null, 25 | @Json(name = "name") 26 | val name : String? = null 27 | ) 28 | } 29 | } -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/data/source/NewsRemoteDataSource.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.data.source 2 | 3 | 4 | import io.fajarca.core.network.RemoteDataSource 5 | import io.fajarca.core.vo.Result 6 | import io.fajarca.news.data.NewsService 7 | import io.fajarca.news.data.response.NewsDto 8 | import kotlinx.coroutines.CoroutineDispatcher 9 | 10 | class NewsRemoteDataSource (private val newsService: NewsService) : RemoteDataSource() { 11 | 12 | suspend fun getNews(dispatcher: CoroutineDispatcher, country : String?, category : String?, page : Int, pageSize : Int): Result { 13 | return safeApiCall(dispatcher) { newsService.getNews(country, category, page, pageSize) } 14 | } 15 | 16 | } -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/di/NewsComponent.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.di 2 | 3 | import dagger.Component 4 | import io.fajarca.core.di.CoreComponent 5 | import io.fajarca.core.di.scope.FeatureScope 6 | import io.fajarca.news.presentation.screen.HomeFragment 7 | import io.fajarca.news.presentation.screen.NewsFragment 8 | 9 | @FeatureScope 10 | @Component( 11 | dependencies = [CoreComponent::class], 12 | modules = [ 13 | NewsModule::class, 14 | RepositoryModule::class, 15 | ViewModelModule::class 16 | ] 17 | ) 18 | interface NewsComponent { 19 | fun inject(homeFragment: HomeFragment) 20 | fun inject(newsFragment: NewsFragment) 21 | } 22 | -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/di/NewsModule.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import io.fajarca.core.database.NewsDatabase 6 | import io.fajarca.core.database.dao.NewsDao 7 | import io.fajarca.core.di.scope.FeatureScope 8 | import io.fajarca.news.data.NewsService 9 | import io.fajarca.news.data.mapper.NewsMapper 10 | import io.fajarca.news.data.source.NewsRemoteDataSource 11 | import retrofit2.Retrofit 12 | 13 | 14 | @Module 15 | class NewsModule { 16 | 17 | @Provides 18 | @FeatureScope 19 | fun provideNewsDao(db: NewsDatabase) : NewsDao = db.newsDao() 20 | 21 | @Provides 22 | @FeatureScope 23 | fun provideMapper() : NewsMapper = NewsMapper() 24 | 25 | @Provides 26 | @FeatureScope 27 | fun provideNewsService(retrofit: Retrofit) : NewsService = retrofit.create(NewsService::class.java) 28 | 29 | @Provides 30 | @FeatureScope 31 | fun provideRemoteDataSource(characterService: NewsService) = NewsRemoteDataSource(characterService) 32 | } -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/di/RepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import io.fajarca.news.data.NewsRepositoryImpl 6 | import io.fajarca.news.domain.repository.NewsRepository 7 | 8 | @Module 9 | interface RepositoryModule { 10 | @Binds 11 | fun bindRepository(repository: NewsRepositoryImpl): NewsRepository 12 | } -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/di/ViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.di 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import dagger.Binds 6 | import dagger.Module 7 | import dagger.multibindings.IntoMap 8 | import io.fajarca.core.di.ViewModelFactory 9 | import io.fajarca.core.di.ViewModelKey 10 | import io.fajarca.news.presentation.viewmodel.HomeViewModel 11 | 12 | @Module 13 | abstract class ViewModelModule { 14 | @Binds 15 | abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory 16 | 17 | @Binds 18 | @IntoMap 19 | @ViewModelKey(HomeViewModel::class) 20 | abstract fun providesHomeViewModel(viewModel: HomeViewModel): ViewModel 21 | } -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/domain/entities/News.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.domain.entities 2 | 3 | data class News(val title: String, val url : String, val imageUrl: String, val publishedAt : String, val category : String, val sourceId : String, val sourceName : String) -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/domain/entities/SearchQuery.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.domain.entities 2 | 3 | data class SearchQuery(val country : String?, val category : String?) -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/domain/repository/NewsBoundaryCallback.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.domain.repository 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.paging.PagedList 6 | import io.fajarca.core.network.HttpResult 7 | import io.fajarca.core.vo.Result 8 | import io.fajarca.news.domain.entities.News 9 | import io.fajarca.news.domain.usecase.InsertNewsUseCase 10 | import io.fajarca.news.presentation.viewmodel.HomeViewModel 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.launch 13 | 14 | class NewsBoundaryCallback(private val country : String?, private val category : String?, private val insertNewsUseCase: InsertNewsUseCase, private val scope : CoroutineScope) : PagedList.BoundaryCallback() { 15 | 16 | private val _newsState = MutableLiveData>>() 17 | val newsState : LiveData>> = _newsState 18 | 19 | // avoid triggering multiple requests in the same time 20 | private var isRequestInProgress = false 21 | private var lastRequestedPage = 1 22 | 23 | override fun onItemAtEndLoaded(itemAtEnd: News) { 24 | requestAndSaveData() 25 | } 26 | 27 | private fun requestAndSaveData() { 28 | if (isRequestInProgress) return 29 | 30 | setState(Result.Loading) 31 | 32 | scope.launch { 33 | 34 | insertNewsUseCase(country, 35 | category, 36 | lastRequestedPage, 37 | HomeViewModel.PAGE_SIZE, 38 | onSuccessAction = {onFetchSuccess()} , 39 | onErrorAction = { cause, code, errorMessage -> onFetchNewsError(cause, code, errorMessage)}) 40 | } 41 | 42 | } 43 | 44 | private fun onFetchSuccess() { 45 | isRequestInProgress = false 46 | lastRequestedPage++ 47 | setState(Result.Success(emptyList())) 48 | } 49 | private fun onFetchNewsError(cause : HttpResult, code : Int, errorMessage : String) { 50 | isRequestInProgress = false 51 | setState(Result.Error(cause, code, errorMessage)) 52 | } 53 | 54 | private fun setState(result : Result>) { 55 | _newsState.postValue(result) 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/domain/repository/NewsRepository.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.domain.repository 2 | 3 | import androidx.paging.DataSource 4 | import io.fajarca.core.network.HttpResult 5 | import io.fajarca.core.vo.Result 6 | import io.fajarca.news.domain.entities.News 7 | 8 | interface NewsRepository { 9 | suspend fun refreshNews(country: String?, category : String?, page: Int, pageSize: Int) : Result> 10 | suspend fun findAllNews(country: String?, category : String?, page: Int, pageSize: Int, onSuccessAction : () -> Unit, onErrorAction: (cause : HttpResult, code : Int, errorMessage : String) -> Unit) 11 | fun findByCountry(country: String?) : DataSource.Factory 12 | fun findByCategory(category: String?) : DataSource.Factory 13 | fun findAll() : DataSource.Factory 14 | } 15 | 16 | 17 | -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/domain/usecase/GetCachedNewsUseCase.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.domain.usecase 2 | 3 | import androidx.paging.DataSource 4 | import io.fajarca.news.domain.entities.News 5 | import io.fajarca.news.domain.repository.NewsRepository 6 | import javax.inject.Inject 7 | 8 | class GetCachedNewsUseCase @Inject constructor(private val repository: NewsRepository) { 9 | 10 | operator fun invoke(country : String?, category : String?): DataSource.Factory { 11 | return if (!country.isNullOrEmpty()) { 12 | repository.findByCountry(country) 13 | } else if (!category.isNullOrEmpty()) { 14 | repository.findByCategory(category) 15 | } else { 16 | repository.findAll() 17 | } 18 | } 19 | 20 | } -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/domain/usecase/InsertNewsUseCase.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.domain.usecase 2 | 3 | import io.fajarca.core.network.HttpResult 4 | import io.fajarca.news.domain.repository.NewsRepository 5 | import javax.inject.Inject 6 | 7 | class InsertNewsUseCase @Inject constructor(private val repository: NewsRepository) { 8 | 9 | suspend operator fun invoke(country : String?, category : String?, page : Int, pageSize : Int, onSuccessAction : () -> Unit, onErrorAction: (cause : HttpResult, code : Int, errorMessage : String) -> Unit) { 10 | repository.findAllNews(country, category, page, pageSize, onSuccessAction, onErrorAction) 11 | } 12 | 13 | } -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/domain/usecase/RefreshNewsUseCase.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.domain.usecase 2 | 3 | import io.fajarca.core.vo.Result 4 | import io.fajarca.news.domain.entities.News 5 | import io.fajarca.news.domain.repository.NewsRepository 6 | import javax.inject.Inject 7 | 8 | class RefreshNewsUseCase @Inject constructor(private val repository: NewsRepository) { 9 | 10 | suspend operator fun invoke(country : String?, category : String?): Result> { 11 | return repository.refreshNews(country, category, 1, 10) 12 | } 13 | 14 | fun get() : List = emptyList() 15 | 16 | } -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/presentation/mapper/NewsPresentationMapper.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.presentation.mapper 2 | 3 | import io.fajarca.news.domain.entities.News 4 | import io.fajarca.presentation.extension.toLocalTime 5 | import java.util.* 6 | import javax.inject.Inject 7 | 8 | class NewsPresentationMapper @Inject constructor(){ 9 | 10 | fun map(input: News, locale: Locale) : News { 11 | return News(input.title, input.url, input.imageUrl, input.publishedAt.toLocalTime(locale), input.category, input.sourceId, input.sourceName) 12 | } 13 | 14 | } -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/presentation/model/SearchResult.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.presentation.model 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.paging.PagedList 5 | import io.fajarca.core.vo.Result 6 | import io.fajarca.news.domain.entities.News 7 | 8 | data class SearchResult(val searchState : LiveData>>, val news : LiveData> ) -------------------------------------------------------------------------------- /feature_news/src/main/java/io/fajarca/news/presentation/viewmodel/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.presentation.viewmodel 2 | 3 | import androidx.lifecycle.* 4 | import androidx.paging.LivePagedListBuilder 5 | import androidx.paging.PagedList 6 | import io.fajarca.core.vo.Result 7 | import io.fajarca.news.domain.entities.News 8 | import io.fajarca.news.domain.entities.SearchQuery 9 | import io.fajarca.news.domain.repository.NewsBoundaryCallback 10 | import io.fajarca.news.domain.usecase.GetCachedNewsUseCase 11 | import io.fajarca.news.domain.usecase.InsertNewsUseCase 12 | import io.fajarca.news.domain.usecase.RefreshNewsUseCase 13 | import io.fajarca.news.presentation.mapper.NewsPresentationMapper 14 | import io.fajarca.news.presentation.model.SearchResult 15 | import kotlinx.coroutines.launch 16 | import kotlinx.coroutines.runBlocking 17 | import java.util.* 18 | import javax.inject.Inject 19 | 20 | class HomeViewModel @Inject constructor( 21 | private val getCachedNewsUseCase: GetCachedNewsUseCase, 22 | private val insertNewsUseCase: InsertNewsUseCase, 23 | private val refreshNewsUseCase: RefreshNewsUseCase, 24 | private val mapper: NewsPresentationMapper 25 | ) : ViewModel() { 26 | 27 | companion object { 28 | const val PAGE_SIZE = 10 29 | } 30 | 31 | private val _query = MutableLiveData() 32 | 33 | private val searchResult = Transformations.map(_query) { 34 | search(it.country, it.category) 35 | } 36 | 37 | val news = Transformations.switchMap(searchResult) { it.news } 38 | val searchState : LiveData>> = Transformations.switchMap(searchResult) { it.searchState } 39 | 40 | private val _refreshNews = MutableLiveData>>() 41 | val refreshNews: LiveData>> 42 | get() = _refreshNews 43 | 44 | fun setSearchQuery(country: String?, category: String?) { 45 | _query.postValue(SearchQuery(country, category)) 46 | } 47 | 48 | private fun search(country: String?, category: String?): SearchResult = runBlocking { 49 | val factory = getCachedNewsUseCase(country, category).map { mapper.map(it, Locale.getDefault()) } 50 | val boundaryCallback = NewsBoundaryCallback(country, category, insertNewsUseCase, viewModelScope) 51 | 52 | val config = PagedList.Config.Builder() 53 | .setEnablePlaceholders(false) 54 | .setInitialLoadSizeHint(2 * PAGE_SIZE) 55 | .setPageSize(PAGE_SIZE) 56 | .build() 57 | 58 | val newsSourceState = boundaryCallback.newsState 59 | val newsSource = LivePagedListBuilder(factory, config) 60 | .setBoundaryCallback(boundaryCallback) 61 | .build() 62 | 63 | SearchResult(newsSourceState, newsSource) 64 | } 65 | 66 | fun refreshNews(country: String?, category: String?) { 67 | _refreshNews.value = Result.Loading 68 | viewModelScope.launch { 69 | when(val result = refreshNewsUseCase(country, category)) { 70 | is Result.Success -> { 71 | _refreshNews.value = Result.Success(emptyList()) 72 | } 73 | is Result.Error -> { 74 | _refreshNews.value = Result.Error(result.cause, result.code, result.errorMessage) 75 | } 76 | } 77 | } 78 | } 79 | 80 | 81 | } -------------------------------------------------------------------------------- /feature_news/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /feature_news/src/main/res/drawable/ic_placeholder.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /feature_news/src/main/res/layout/fragment_home.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 10 | 11 | 15 | 16 | 24 | 25 | 33 | 34 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /feature_news/src/main/res/layout/item_footer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /feature_news/src/main/res/layout/placeholder_item_news.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 18 | 19 | 30 | 31 | 42 | 43 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /feature_news/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /feature_news/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /feature_news/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_news/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /feature_news/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_news/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /feature_news/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_news/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /feature_news/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_news/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /feature_news/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_news/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /feature_news/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_news/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /feature_news/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_news/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /feature_news/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_news/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /feature_news/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_news/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /feature_news/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_news/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /feature_news/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FF7043 4 | #EEEEEE 5 | #BDBDBD 6 | -------------------------------------------------------------------------------- /feature_news/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Character Detail 3 | image 4 | 5 | -------------------------------------------------------------------------------- /feature_news/src/test/java/io/fajarca/news/data/mapper/NewsMapperTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.data.mapper 2 | 3 | import io.fajarca.core.database.entity.NewsEntity 4 | import io.fajarca.news.data.response.NewsDto 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Before 7 | import org.junit.Test 8 | import org.mockito.MockitoAnnotations 9 | 10 | class NewsMapperTest { 11 | 12 | private val fakeNewsTitle = "Teknologi AI Tidak Terlalu Cocok Digunakan untuk Mencari Alien - SINDOnews.com" 13 | private val fakeNewsImageUrl = "https://pict.sindonews.net/dyn/620/content/2020/02/11/124/1523392.jpg" 14 | private val fakeCountry = "id" 15 | private val fakeCategory = "technology" 16 | private lateinit var sut : NewsMapper 17 | 18 | 19 | @Before 20 | fun setUp() { 21 | MockitoAnnotations.initMocks(this) 22 | sut = NewsMapper() 23 | } 24 | 25 | @Test 26 | fun testTransformNews() { 27 | //Given 28 | val response = createFakeNewsResponse() 29 | 30 | //When 31 | val output = sut.map(fakeCountry, fakeCategory, response) 32 | 33 | //Then 34 | assertEquals(output[0].country, fakeCountry) 35 | assertEquals(output[0].category, fakeCategory) 36 | assertEquals(output[0].title, fakeNewsTitle) 37 | assertEquals(output[0].imageUrl, fakeNewsImageUrl) 38 | } 39 | 40 | @Test 41 | fun `test transform news when given null values should convert all null values to empty string`() { 42 | //Given 43 | val response = createNullFakeNewsResponse() 44 | 45 | //When 46 | val output = sut.map(null, null, response) 47 | 48 | //Then 49 | assertEquals(output[0].country, "") 50 | assertEquals(output[0].category, "") 51 | assertEquals(output[0].title, "") 52 | assertEquals(output[0].imageUrl, "") 53 | } 54 | 55 | @Test 56 | fun testTransformNewsToDomain() { 57 | //Given 58 | val fakeNews = createFakeNewsEntity() 59 | 60 | //When 61 | val output = sut.mapToDomain(fakeNews) 62 | 63 | //Then 64 | assertEquals(output.title, fakeNewsTitle) 65 | assertEquals(output.imageUrl, fakeNewsImageUrl) 66 | } 67 | 68 | private fun createFakeNewsEntity(): NewsEntity { 69 | return NewsEntity( 70 | fakeNewsTitle, 71 | "https://autotekno.sindonews.com/read/1523392/124/teknologi-ai-tidak-terlalu-cocok-digunakan-untuk-mencari-alien-1581400925", 72 | fakeNewsImageUrl, 73 | "id", 74 | "technology", 75 | "2020-02-11T12:30:49Z", 76 | "", 77 | "cnn-indonesia" 78 | ) 79 | } 80 | 81 | private fun createFakeNewsResponse(): NewsDto { 82 | val source = NewsDto.Article.Source("","") 83 | val articles = NewsDto.Article("", fakeNewsTitle,"", fakeNewsImageUrl, source) 84 | return NewsDto(arrayListOf(articles)) 85 | } 86 | 87 | private fun createNullFakeNewsResponse(): NewsDto { 88 | val source = NewsDto.Article.Source(null,null) 89 | val articles = NewsDto.Article(null,null,null,null, source) 90 | return NewsDto(arrayListOf(articles)) 91 | } 92 | } -------------------------------------------------------------------------------- /feature_news/src/test/java/io/fajarca/news/data/source/NewsRemoteDataSourceTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.data.source 2 | 3 | import io.fajarca.news.data.NewsService 4 | import io.fajarca.testutil.extension.runBlockingTest 5 | import io.fajarca.testutil.rule.CoroutineTestRule 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import kotlinx.coroutines.test.TestCoroutineDispatcher 8 | import org.junit.Before 9 | import org.junit.Rule 10 | import org.junit.Test 11 | import org.mockito.Mock 12 | import org.mockito.Mockito.verify 13 | import org.mockito.MockitoAnnotations 14 | 15 | @ExperimentalCoroutinesApi 16 | class NewsRemoteDataSourceTest { 17 | 18 | @Mock 19 | lateinit var newsService : NewsService 20 | private lateinit var sut : NewsRemoteDataSource 21 | private val testCoroutineDispatcher = TestCoroutineDispatcher() 22 | 23 | private val country = "id" 24 | private val category = "technology" 25 | private val page = 1 26 | private val pageSize = 25 27 | 28 | @get:Rule 29 | val coroutineTestRule = CoroutineTestRule() 30 | 31 | @Before 32 | fun setUp() { 33 | MockitoAnnotations.initMocks(this) 34 | sut = NewsRemoteDataSource(newsService) 35 | } 36 | 37 | @Test 38 | fun `when get news, should fetch from network`() = coroutineTestRule.runBlockingTest{ 39 | //Given 40 | 41 | //When 42 | sut.getNews(testCoroutineDispatcher, country, category, page, pageSize) 43 | 44 | //Then 45 | verify(newsService).getNews( country, category, page, pageSize) 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /feature_news/src/test/java/io/fajarca/news/domain/usecase/GetCachedNewsUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.domain.usecase 2 | 3 | import io.fajarca.news.domain.repository.NewsRepository 4 | import io.fajarca.testutil.extension.runBlockingTest 5 | import io.fajarca.testutil.rule.CoroutineTestRule 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import org.junit.Before 8 | import org.junit.Rule 9 | import org.junit.Test 10 | import org.mockito.Mock 11 | import org.mockito.Mockito.verify 12 | import org.mockito.MockitoAnnotations 13 | 14 | @ExperimentalCoroutinesApi 15 | class GetCachedNewsUseCaseTest { 16 | 17 | private lateinit var sut: GetCachedNewsUseCase 18 | 19 | @Mock 20 | lateinit var repository: NewsRepository 21 | 22 | @get:Rule 23 | val coroutineTestRule = CoroutineTestRule() 24 | 25 | 26 | @Before 27 | fun setUp() { 28 | MockitoAnnotations.initMocks(this) 29 | sut = GetCachedNewsUseCase(repository) 30 | } 31 | 32 | @Test 33 | fun `when country is not null, should get news from specified country` () = coroutineTestRule.runBlockingTest { 34 | //Given 35 | val country = "id" 36 | val category = null 37 | 38 | //When 39 | sut(country, category) 40 | 41 | //Then 42 | verify(repository).findByCountry(country) 43 | } 44 | 45 | @Test 46 | fun `when category is not null, should get news from specified category` () = coroutineTestRule.runBlockingTest { 47 | //Given 48 | val country = null 49 | val category = "technology" 50 | 51 | //When 52 | sut(country, category) 53 | 54 | //Then 55 | verify(repository).findByCategory(category) 56 | } 57 | 58 | 59 | @Test 60 | fun `when country and category is null, should get all news` () = coroutineTestRule.runBlockingTest { 61 | //Given 62 | val country = null 63 | val category = null 64 | 65 | //When 66 | sut(country, category) 67 | 68 | //Then 69 | verify(repository).findAll() 70 | } 71 | } -------------------------------------------------------------------------------- /feature_news/src/test/java/io/fajarca/news/domain/usecase/InsertNewsUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.domain.usecase 2 | 3 | import io.fajarca.core.network.HttpResult 4 | import io.fajarca.news.domain.repository.NewsRepository 5 | import io.fajarca.testutil.extension.runBlockingTest 6 | import io.fajarca.testutil.rule.CoroutineTestRule 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import org.junit.Before 9 | import org.junit.Rule 10 | import org.junit.Test 11 | import org.mockito.Mock 12 | import org.mockito.Mockito.verify 13 | import org.mockito.MockitoAnnotations 14 | 15 | @ExperimentalCoroutinesApi 16 | class InsertNewsUseCaseTest { 17 | 18 | private lateinit var sut: InsertNewsUseCase 19 | 20 | @Mock 21 | lateinit var repository: NewsRepository 22 | 23 | @get:Rule 24 | val coroutineTestRule = CoroutineTestRule() 25 | 26 | 27 | @Before 28 | fun setUp() { 29 | MockitoAnnotations.initMocks(this) 30 | sut = InsertNewsUseCase(repository) 31 | } 32 | 33 | @Test 34 | fun `when use case is invoked, should get news from repository` () = coroutineTestRule.runBlockingTest { 35 | //Given 36 | val country = "id" 37 | val category = null 38 | val page = 1 39 | val pageSize = 25 40 | val onSuccessAction = { } 41 | val onErrorAction = { httpResult : HttpResult, code : Int, errorMessage : String -> } 42 | 43 | //When 44 | sut(country, category, page, pageSize, onSuccessAction, onErrorAction) 45 | 46 | //Then 47 | verify(repository).findAllNews(country, category, page, pageSize, onSuccessAction, onErrorAction) 48 | } 49 | } -------------------------------------------------------------------------------- /feature_news/src/test/java/io/fajarca/news/presentation/CharactersViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.presentation 2 | 3 | import kotlinx.coroutines.ExperimentalCoroutinesApi 4 | 5 | @ExperimentalCoroutinesApi 6 | class CharactersViewModelTest { 7 | 8 | /*// Run tasks synchronously 9 | @Rule 10 | @JvmField 11 | val instantExecutorRule = InstantTaskExecutorRule() 12 | 13 | @get:Rule 14 | val testCoroutineRule = TestCoroutineRule() 15 | 16 | private lateinit var viewModel: HomeViewModel 17 | 18 | 19 | @Mock 20 | private lateinit var observer : Observer>> 21 | 22 | 23 | @Mock 24 | private lateinit var useCase: GetTopHeadlinesUseCase 25 | 26 | @Mock 27 | lateinit var lifeCycleOwner: LifecycleOwner 28 | lateinit var lifeCycle: LifecycleRegistry 29 | 30 | @Before 31 | fun setup() { 32 | MockitoAnnotations.initMocks(this) 33 | viewModel = HomeViewModel(useCase) 34 | 35 | lifeCycle = LifecycleRegistry(lifeCycleOwner) 36 | `when` (lifeCycleOwner.lifecycle).thenReturn(lifeCycle) 37 | lifeCycle.handleLifecycleEvent(Lifecycle.Event.ON_START) 38 | 39 | viewModel.characters.observe(lifeCycleOwner, observer) 40 | 41 | 42 | } 43 | 44 | @Test 45 | fun `when get all all character is success, observer should receive success result`() = testCoroutineRule.runBlockingTest { 46 | //Given 47 | val marvelCharacters = mutableListOf() 48 | marvelCharacters.add( 49 | News( 50 | 1, 51 | "Marvel", 52 | "image-url" 53 | ) 54 | ) 55 | 56 | `when`(useCase.execute()).thenReturn(marvelCharacters) 57 | 58 | //When 59 | viewModel.getAllCharacters() 60 | 61 | //Then 62 | verify(observer).onChanged(HomeViewModel.CharacterState.Loading) 63 | verify(observer).onChanged(HomeViewModel.CharacterState.Success(marvelCharacters)) 64 | 65 | } 66 | 67 | @Test 68 | fun `when get all all character is empty, observer should receive empty result`() = testCoroutineRule.runBlockingTest { 69 | //Given 70 | val marvelCharacters = emptyList() 71 | 72 | `when`(useCase.execute()).thenReturn(marvelCharacters) 73 | 74 | //When 75 | viewModel.getAllCharacters() 76 | 77 | //Then 78 | verify(observer).onChanged(HomeViewModel.CharacterState.Loading) 79 | verify(observer).onChanged(HomeViewModel.CharacterState.Empty) 80 | 81 | }*/ 82 | 83 | } -------------------------------------------------------------------------------- /feature_news/src/test/java/io/fajarca/news/presentation/mapper/NewsPresentationMapperTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.presentation.mapper 2 | 3 | import io.fajarca.news.domain.entities.News 4 | import org.junit.Assert.assertEquals 5 | import org.junit.Before 6 | import org.junit.Test 7 | import java.util.* 8 | 9 | class NewsPresentationMapperTest { 10 | 11 | private lateinit var sut: NewsPresentationMapper 12 | 13 | @Before 14 | fun setUp() { 15 | sut = NewsPresentationMapper() 16 | } 17 | 18 | @Test 19 | fun `when given news publishedAt then convert it to local time`() { 20 | //Given 21 | val input = News( 22 | "Teknologi AI Tidak Terlalu Cocok Digunakan untuk Mencari Alien - SINDOnews.com", 23 | "https://autotekno.sindonews.com/read/1523392/124/teknologi-ai-tidak-terlalu-cocok-digunakan-untuk-mencari-alien-1581400925", 24 | "https://pict.sindonews.net/dyn/620/content/2020/02/11/124/1523392.jpg", 25 | "2020-02-11T12:30:49Z", 26 | "technology", 27 | "cnn", 28 | "CNN" 29 | ) 30 | 31 | val expected = News( 32 | "Teknologi AI Tidak Terlalu Cocok Digunakan untuk Mencari Alien - SINDOnews.com", 33 | "https://autotekno.sindonews.com/read/1523392/124/teknologi-ai-tidak-terlalu-cocok-digunakan-untuk-mencari-alien-1581400925", 34 | "https://pict.sindonews.net/dyn/620/content/2020/02/11/124/1523392.jpg", 35 | "7:30 PM", 36 | "technology", 37 | "cnn", 38 | "CNN" 39 | ) 40 | 41 | val locale = Locale.getDefault() 42 | 43 | //When 44 | val actual = sut.map(input, locale) 45 | 46 | //Then 47 | assertEquals(expected, actual) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /feature_news/src/test/java/io/fajarca/news/presentation/viewmodel/HomeViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news.presentation.viewmodel 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import androidx.lifecycle.Observer 5 | import io.fajarca.news.domain.entities.SearchQuery 6 | import io.fajarca.news.domain.usecase.GetCachedNewsUseCase 7 | import io.fajarca.news.domain.usecase.InsertNewsUseCase 8 | import io.fajarca.news.presentation.mapper.NewsPresentationMapper 9 | import io.fajarca.testutil.LifeCycleTestOwner 10 | import io.fajarca.testutil.rule.CoroutineTestRule 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import org.junit.Before 13 | 14 | import org.junit.Rule 15 | import org.junit.rules.TestRule 16 | import org.mockito.Mock 17 | import org.mockito.MockitoAnnotations 18 | 19 | @ExperimentalCoroutinesApi 20 | class HomeViewModelTest { 21 | 22 | @get:Rule 23 | val coroutineTestRule = CoroutineTestRule() 24 | @get:Rule 25 | var rule: TestRule = InstantTaskExecutorRule() 26 | 27 | private lateinit var lifecycleOwner: LifeCycleTestOwner 28 | private lateinit var sut : HomeViewModel 29 | 30 | @Mock private lateinit var useCaseCached : GetCachedNewsUseCase 31 | @Mock private lateinit var insertNewsUseCase: InsertNewsUseCase 32 | @Mock private lateinit var mapper : NewsPresentationMapper 33 | @Mock private lateinit var observer : Observer> 34 | 35 | @Before 36 | fun setUp() { 37 | MockitoAnnotations.initMocks(this) 38 | lifecycleOwner = LifeCycleTestOwner() 39 | lifecycleOwner.onCreate() 40 | 41 | sut = HomeViewModel(useCaseCached, insertNewsUseCase, mapper) 42 | } 43 | } -------------------------------------------------------------------------------- /feature_news/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /feature_news_category/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /feature_news_category/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_news_category/consumer-rules.pro -------------------------------------------------------------------------------- /feature_news_category/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 22 | -------------------------------------------------------------------------------- /feature_news_category/src/androidTest/java/io/fajarca/news_category/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_category 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("io.fajarca.news_category.test", appContext.packageName) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /feature_news_category/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /feature_news_category/src/main/java/io/fajarca/news_category/presentation/adapter/NewsCategoryRecyclerAdapter.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_category.presentation.adapter 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.DiffUtil 6 | import androidx.recyclerview.widget.ListAdapter 7 | import androidx.recyclerview.widget.RecyclerView 8 | import io.fajarca.news_category.R 9 | import io.fajarca.news_category.databinding.ItemNewsCategoryBinding 10 | import io.fajarca.news_category.presentation.model.NewsCategory 11 | 12 | class NewsCategoryRecyclerAdapter(private val listener: NewsCategoryClickListener) : ListAdapter( 13 | diffCallback 14 | ) { 15 | 16 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NewsChannelViewHolder { 17 | return NewsChannelViewHolder.create( 18 | parent 19 | ) 20 | } 21 | 22 | override fun onBindViewHolder(holder: NewsChannelViewHolder, position: Int) { 23 | holder.bind(getItem(position) ?: NewsCategory( 24 | "", 25 | "", 26 | R.drawable.ic_business 27 | ), listener) 28 | } 29 | 30 | class NewsChannelViewHolder(private val binding: ItemNewsCategoryBinding) : RecyclerView.ViewHolder(binding.root) { 31 | 32 | fun bind(category: NewsCategory, listener: NewsCategoryClickListener) { 33 | binding.category = category 34 | binding.imageView.setImageResource(category.resourceId) 35 | binding.root.setOnClickListener { listener.onNewsCategoryPressed(category) } 36 | binding.executePendingBindings() 37 | } 38 | 39 | companion object { 40 | fun create(parent: ViewGroup): NewsChannelViewHolder { 41 | val layoutInflater = LayoutInflater.from(parent.context) 42 | val binding = ItemNewsCategoryBinding.inflate(layoutInflater, parent,false) 43 | return NewsChannelViewHolder( 44 | binding 45 | ) 46 | } 47 | } 48 | } 49 | 50 | 51 | interface NewsCategoryClickListener { 52 | fun onNewsCategoryPressed(category: NewsCategory) 53 | } 54 | 55 | 56 | companion object { 57 | val diffCallback = object : DiffUtil.ItemCallback() { 58 | override fun areItemsTheSame(oldItem: NewsCategory, newItem: NewsCategory): Boolean { 59 | return oldItem.id == newItem.id 60 | } 61 | 62 | override fun areContentsTheSame(oldItem: NewsCategory, newItem: NewsCategory): Boolean { 63 | return oldItem == newItem 64 | } 65 | 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /feature_news_category/src/main/java/io/fajarca/news_category/presentation/model/NewsCategory.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_category.presentation.model 2 | 3 | data class NewsCategory(val id : String, val name : String, val resourceId : Int) -------------------------------------------------------------------------------- /feature_news_category/src/main/res/drawable/ic_business.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /feature_news_category/src/main/res/drawable/ic_entertainment.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 19 | -------------------------------------------------------------------------------- /feature_news_category/src/main/res/drawable/ic_health.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /feature_news_category/src/main/res/drawable/ic_science.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /feature_news_category/src/main/res/drawable/ic_sports.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /feature_news_category/src/main/res/layout/fragment_news_category.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 9 | 10 | 18 | 19 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /feature_news_category/src/main/res/layout/item_news_category.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 17 | 18 | 21 | 22 | 23 | 36 | 37 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /feature_news_category/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Feature News Category 3 | image 4 | 5 | -------------------------------------------------------------------------------- /feature_news_category/src/test/java/io/fajarca/news_category/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_category 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Test 5 | 6 | /** 7 | * Example local unit test, which will execute on the development machine (host). 8 | * 9 | * See [testing documentation](http://d.android.com/tools/testing). 10 | */ 11 | class ExampleUnitTest { 12 | @Test 13 | fun addition_isCorrect() { 14 | assertEquals(4, 2 + 2) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /feature_news_channel/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /feature_news_channel/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_news_channel/consumer-rules.pro -------------------------------------------------------------------------------- /feature_news_channel/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 22 | -------------------------------------------------------------------------------- /feature_news_channel/src/androidTest/java/io/fajarca/news_channel/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("io.fajarca.news_channel.test", appContext.packageName) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /feature_news_channel/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/data/ChannelService.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.data 2 | 3 | import io.fajarca.news_channel.data.response.SourcesDto 4 | import retrofit2.http.GET 5 | 6 | 7 | interface ChannelService { 8 | 9 | @GET("v2/sources") 10 | suspend fun getNewsChannels(): SourcesDto 11 | 12 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/data/NewsChannelRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.data 2 | 3 | import io.fajarca.core.database.dao.NewsChannelDao 4 | import io.fajarca.core.database.entity.NewsChannelEntity 5 | import io.fajarca.core.dispatcher.CoroutineDispatcherProvider 6 | import io.fajarca.core.vo.Result 7 | import io.fajarca.news_channel.data.mapper.NewsChannelMapper 8 | import io.fajarca.news_channel.data.response.SourcesDto 9 | import io.fajarca.news_channel.data.source.NewsChannelRemoteDataSource 10 | import io.fajarca.news_channel.domain.entities.NewsChannel 11 | import io.fajarca.news_channel.domain.repository.NewsChannelRepository 12 | import kotlinx.coroutines.flow.Flow 13 | import kotlinx.coroutines.flow.flow 14 | import javax.inject.Inject 15 | 16 | class NewsChannelRepositoryImpl @Inject constructor( 17 | private val dispatcher: CoroutineDispatcherProvider, 18 | private val mapper: NewsChannelMapper, 19 | private val dao: NewsChannelDao, 20 | private val remoteDataSource: NewsChannelRemoteDataSource 21 | ) : NewsChannelRepository { 22 | 23 | 24 | override suspend fun getNewsChannelFromApi(): Result { 25 | return remoteDataSource.getNewsChannel(dispatcher.io) 26 | } 27 | 28 | override suspend fun getNewsChannelFromDb(): List { 29 | val savedChannel = dao.findAll() 30 | return mapper.mapToDomain(dispatcher.default, savedChannel) 31 | } 32 | 33 | override suspend fun insertNewsChannel(newsChannel: List) { 34 | dao.insertAll(newsChannel) 35 | } 36 | 37 | override suspend fun findAllNewsChannel(): Flow> = flow { 38 | emit(getNewsChannelFromDb()) 39 | val apiResult = getNewsChannelFromApi() 40 | if (apiResult is Result.Success) { 41 | val newsChannel = mapper.map(dispatcher.default, apiResult.data) 42 | insertNewsChannel(newsChannel) 43 | } 44 | emit(getNewsChannelFromDb()) 45 | 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/data/mapper/NewsChannelMapper.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.data.mapper 2 | 3 | import io.fajarca.core.database.entity.NewsChannelEntity 4 | import io.fajarca.core.mapper.AsyncMapper 5 | import io.fajarca.news_channel.data.response.SourcesDto 6 | import io.fajarca.news_channel.domain.entities.NewsChannel 7 | import kotlinx.coroutines.CoroutineDispatcher 8 | import kotlinx.coroutines.withContext 9 | import javax.inject.Inject 10 | 11 | class NewsChannelMapper @Inject constructor() : AsyncMapper>(){ 12 | 13 | override suspend fun map(dispatcher : CoroutineDispatcher, input: SourcesDto): List { 14 | return withContext(dispatcher) { 15 | val newsChannel = mutableListOf() 16 | input.sources?.map { 17 | newsChannel.add(NewsChannelEntity(it?.id ?: "", it?.name ?: "", it?.country ?: "", it?.url ?: "")) 18 | } 19 | newsChannel 20 | } 21 | } 22 | 23 | suspend fun mapToDomain(dispatcher: CoroutineDispatcher, input : List): List { 24 | return withContext(dispatcher) { 25 | val newsChannel = mutableListOf() 26 | input.map { 27 | newsChannel.add(NewsChannel(it.id, it.country, it.name, it.url, getChannelInitial(it.name))) 28 | } 29 | newsChannel 30 | } 31 | } 32 | 33 | private fun getChannelInitial(channelName : String) : String { 34 | val splittedChannelName = channelName.split(" ") 35 | val size = splittedChannelName.size 36 | 37 | if (splittedChannelName.isNotEmpty()) { 38 | return when(size) { 39 | 1 -> getFirstCharacter(splittedChannelName[0]) 40 | 2 -> getFirstCharacter(splittedChannelName[0]) + getFirstCharacter(splittedChannelName[1]) 41 | 3 -> getFirstCharacter(splittedChannelName[0]) + getFirstCharacter(splittedChannelName[2]) 42 | 4 -> getFirstCharacter(splittedChannelName[0]) + getFirstCharacter(splittedChannelName[3]) 43 | else -> "" 44 | } 45 | } 46 | 47 | return "" 48 | } 49 | 50 | private fun getFirstCharacter(input : String) : String { 51 | if (input.length > 1) { 52 | return input.substring(0, 1) 53 | } 54 | return "" 55 | } 56 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/data/response/SourcesDto.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.data.response 2 | 3 | 4 | import com.squareup.moshi.Json 5 | 6 | data class SourcesDto( 7 | @Json(name = "sources") 8 | val sources: List? = null 9 | ) { 10 | data class Source( 11 | @Json(name = "country") 12 | val country: String? = null, 13 | @Json(name = "id") 14 | val id: String? = null, 15 | @Json(name = "name") 16 | val name: String? = null, 17 | @Json(name = "url") 18 | val url: String? = null 19 | ) 20 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/data/source/NewsChannelRemoteDataSource.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.data.source 2 | 3 | 4 | import io.fajarca.core.network.RemoteDataSource 5 | import io.fajarca.core.vo.Result 6 | import io.fajarca.news_channel.data.ChannelService 7 | import io.fajarca.news_channel.data.response.SourcesDto 8 | import kotlinx.coroutines.CoroutineDispatcher 9 | 10 | class NewsChannelRemoteDataSource (private val channelService: ChannelService) : RemoteDataSource() { 11 | 12 | suspend fun getNewsChannel(dispatcher: CoroutineDispatcher): Result { 13 | return safeApiCall(dispatcher) { channelService.getNewsChannels() } 14 | } 15 | 16 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/di/NewsChannelComponent.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.di 2 | 3 | import dagger.Component 4 | import io.fajarca.core.di.CoreComponent 5 | import io.fajarca.core.di.scope.FeatureScope 6 | import io.fajarca.news_channel.presentation.NewsChannelFragment 7 | 8 | @FeatureScope 9 | @Component( 10 | dependencies = [CoreComponent::class], 11 | modules = [ 12 | NewsChannelModule::class, 13 | RepositoryModule::class, 14 | ViewModelModule::class 15 | ] 16 | ) 17 | interface NewsChannelComponent { 18 | fun inject(homeFragment: NewsChannelFragment) 19 | } 20 | -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/di/NewsChannelModule.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import io.fajarca.core.database.NewsDatabase 6 | import io.fajarca.core.database.dao.NewsChannelDao 7 | import io.fajarca.core.di.scope.FeatureScope 8 | import io.fajarca.news_channel.data.ChannelService 9 | import io.fajarca.news_channel.data.source.NewsChannelRemoteDataSource 10 | import retrofit2.Retrofit 11 | 12 | 13 | @Module 14 | class NewsChannelModule { 15 | 16 | @Provides 17 | @FeatureScope 18 | fun provideNewsChannelDao(db: NewsDatabase) : NewsChannelDao = db.newsChannelDao() 19 | 20 | @Provides 21 | @FeatureScope 22 | fun provideNewsChannelService(retrofit: Retrofit) : ChannelService = retrofit.create(ChannelService::class.java) 23 | 24 | @Provides 25 | @FeatureScope 26 | fun provideRemoteDataSource(channelService: ChannelService) = NewsChannelRemoteDataSource(channelService) 27 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/di/RepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import io.fajarca.news_channel.data.NewsChannelRepositoryImpl 6 | import io.fajarca.news_channel.domain.repository.NewsChannelRepository 7 | 8 | @Module 9 | interface RepositoryModule { 10 | @Binds 11 | fun bindProjectRepository(projectRepository: NewsChannelRepositoryImpl): NewsChannelRepository 12 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/di/ViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.di 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import dagger.Binds 6 | import dagger.Module 7 | import dagger.multibindings.IntoMap 8 | import io.fajarca.core.di.ViewModelFactory 9 | import io.fajarca.core.di.ViewModelKey 10 | import io.fajarca.news_channel.presentation.NewsChannelViewModel 11 | 12 | @Module 13 | abstract class ViewModelModule { 14 | @Binds 15 | abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory 16 | 17 | @Binds 18 | @IntoMap 19 | @ViewModelKey(NewsChannelViewModel::class) 20 | abstract fun providesNewsChannelViewModel(viewModel: NewsChannelViewModel): ViewModel 21 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/domain/entities/ChannelContent.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.domain.entities 2 | 3 | data class ChannelContent(val newsChannel: NewsChannel) : NewsChannelItem() { 4 | override fun getType() : Int { 5 | return CONTENT_TYPE 6 | } 7 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/domain/entities/ChannelHeader.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.domain.entities 2 | 3 | class ChannelHeader(val name : String) : NewsChannelItem() { 4 | override fun getType() : Int { 5 | return HEADER_TYPE 6 | } 7 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/domain/entities/NewsChannel.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.domain.entities 2 | 3 | data class NewsChannel(val id : String, val country : String, val name : String, val url : String, val newsInitial : String) -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/domain/entities/NewsChannelItem.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.domain.entities 2 | 3 | abstract class NewsChannelItem { 4 | companion object { 5 | const val HEADER_TYPE = 1 6 | const val CONTENT_TYPE = 2 7 | } 8 | abstract fun getType() : Int 9 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/domain/repository/NewsChannelRepository.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.domain.repository 2 | 3 | import io.fajarca.core.database.entity.NewsChannelEntity 4 | import io.fajarca.core.vo.Result 5 | import io.fajarca.news_channel.data.response.SourcesDto 6 | import io.fajarca.news_channel.domain.entities.NewsChannel 7 | import kotlinx.coroutines.flow.Flow 8 | 9 | interface NewsChannelRepository { 10 | suspend fun getNewsChannelFromApi() : Result 11 | suspend fun getNewsChannelFromDb() : List 12 | suspend fun insertNewsChannel(newsChannel : List) 13 | suspend fun findAllNewsChannel() : Flow> 14 | } 15 | 16 | 17 | -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/domain/usecase/GetNewsChannelUseCase.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.domain.usecase 2 | 3 | import io.fajarca.news_channel.domain.entities.ChannelContent 4 | import io.fajarca.news_channel.domain.entities.ChannelHeader 5 | import io.fajarca.news_channel.domain.entities.NewsChannel 6 | import io.fajarca.news_channel.domain.entities.NewsChannelItem 7 | import io.fajarca.news_channel.domain.repository.NewsChannelRepository 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.map 10 | import java.util.* 11 | import javax.inject.Inject 12 | 13 | class GetNewsChannelUseCase @Inject constructor(private val repository: NewsChannelRepository) { 14 | 15 | suspend operator fun invoke(): Flow> { 16 | return repository.findAllNewsChannel() 17 | .map { newsChannel -> 18 | val uniqueCountryNames = getUniqueCountryNames(newsChannel) 19 | groupData(uniqueCountryNames, newsChannel) 20 | } 21 | } 22 | 23 | private fun getUniqueCountryNames(newsChannel: List): List { 24 | return newsChannel 25 | .distinctBy { it.country } 26 | .sortedBy { it.country } 27 | .map { it.country } 28 | } 29 | 30 | private fun groupData(countryNames : List, newsChannel: List): MutableList { 31 | val treeMap = TreeMap>() 32 | 33 | val recyclerViewItem = mutableListOf() 34 | 35 | countryNames.forEach { countryName -> 36 | treeMap[countryName] = emptyList() 37 | } 38 | 39 | for (key in treeMap.keys) { 40 | //Add header 41 | recyclerViewItem.add(ChannelHeader(key)) 42 | 43 | for (i in newsChannel) { 44 | //Add content 45 | if (i.country == key) { 46 | recyclerViewItem.add(ChannelContent(i)) 47 | } 48 | } 49 | 50 | } 51 | return recyclerViewItem 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/presentation/NewsChannelFragment.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.presentation 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.lifecycle.Observer 6 | import androidx.navigation.fragment.findNavController 7 | import androidx.navigation.ui.AppBarConfiguration 8 | import androidx.recyclerview.widget.DefaultItemAnimator 9 | import androidx.recyclerview.widget.LinearLayoutManager 10 | import io.fajarca.core.BuzzNewsApp 11 | import io.fajarca.navigation.Origin 12 | import io.fajarca.news_channel.R 13 | import io.fajarca.news_channel.databinding.FragmentNewsChannelBinding 14 | import io.fajarca.news_channel.di.DaggerNewsChannelComponent 15 | import io.fajarca.news_channel.domain.entities.NewsChannel 16 | import io.fajarca.news_channel.presentation.adapter.NewsChannelRecyclerAdapter 17 | import io.fajarca.presentation.BaseFragment 18 | 19 | class NewsChannelFragment : BaseFragment(), 20 | NewsChannelRecyclerAdapter.NewsChannelClickListener { 21 | 22 | private val appBarConfiguration by lazy { AppBarConfiguration.Builder(R.id.fragmentNewsChannel).build() } 23 | private val adapter by lazy { NewsChannelRecyclerAdapter(this) } 24 | override fun getLayoutResourceId() = R.layout.fragment_news_channel 25 | override fun getViewModelClass() = NewsChannelViewModel::class.java 26 | 27 | override fun initDaggerComponent() { 28 | DaggerNewsChannelComponent 29 | .builder() 30 | .coreComponent(BuzzNewsApp.coreComponent(requireContext())) 31 | .build() 32 | .inject(this) 33 | } 34 | 35 | 36 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 37 | super.onViewCreated(view, savedInstanceState) 38 | initToolbar() 39 | initRecyclerView() 40 | vm.getNewsChannel() 41 | vm.newsChannel.observe(viewLifecycleOwner, Observer { subscribeNewsChannel(it) }) 42 | } 43 | 44 | 45 | private fun initRecyclerView() { 46 | val layoutManager = LinearLayoutManager(requireActivity(), LinearLayoutManager.VERTICAL, false) 47 | binding.recyclerView.layoutManager = layoutManager 48 | binding.recyclerView.itemAnimator = DefaultItemAnimator() 49 | binding.recyclerView.adapter = adapter 50 | } 51 | 52 | 53 | private fun subscribeNewsChannel(it: NewsChannelViewModel.NewsChannelState) { 54 | when(it) { 55 | is NewsChannelViewModel.NewsChannelState.Loading -> { 56 | binding.uiStateView.showLoading() 57 | } 58 | is NewsChannelViewModel.NewsChannelState.Success -> { 59 | binding.uiStateView.dismiss() 60 | adapter.submitList(it.channels) 61 | } 62 | is NewsChannelViewModel.NewsChannelState.Empty -> { 63 | binding.uiStateView.showEmptyData("No channel found") 64 | } 65 | } 66 | 67 | } 68 | 69 | private fun initToolbar() { 70 | /*(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar.toolbar) 71 | binding.toolbar.appsetupWithNavController(findNavController(), appBarConfiguration)*/ 72 | } 73 | 74 | override fun onNewsChannelPressed(channel: NewsChannel) { 75 | val action = NewsChannelFragmentDirections.actionFragmentNewsChannelToNavWebBrowser(channel.url, channel.name, Origin.CHANNEL) 76 | findNavController().navigate(action) 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/presentation/NewsChannelViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.presentation 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import io.fajarca.core.dispatcher.CoroutineDispatcherProvider 8 | import io.fajarca.news_channel.domain.entities.NewsChannelItem 9 | import io.fajarca.news_channel.domain.usecase.GetNewsChannelUseCase 10 | import io.fajarca.news_channel.presentation.mapper.NewsChannelPresentationMapper 11 | import kotlinx.coroutines.flow.collect 12 | import kotlinx.coroutines.flow.flowOn 13 | import kotlinx.coroutines.flow.map 14 | import kotlinx.coroutines.flow.onStart 15 | import kotlinx.coroutines.launch 16 | import javax.inject.Inject 17 | 18 | class NewsChannelViewModel @Inject constructor(private val getNewsChannelUseCase: GetNewsChannelUseCase, 19 | private val mapper : NewsChannelPresentationMapper, 20 | private val dispatcherProvider: CoroutineDispatcherProvider) : ViewModel() { 21 | 22 | private val _newsChannel = MutableLiveData() 23 | val newsChannel : LiveData = _newsChannel 24 | 25 | sealed class NewsChannelState { 26 | object Loading: NewsChannelState() 27 | data class Success(val channels : List) : NewsChannelState() 28 | object Empty : NewsChannelState() 29 | } 30 | 31 | fun getNewsChannel() { 32 | viewModelScope.launch { 33 | getNewsChannelUseCase() 34 | .onStart { setResult(NewsChannelState.Loading) } 35 | .map { mapper.map(it) } 36 | .flowOn(dispatcherProvider.default) 37 | .collect { 38 | if (it.isEmpty()) { 39 | setResult(NewsChannelState.Empty) 40 | } else { 41 | setResult(NewsChannelState.Success(it)) 42 | } 43 | } 44 | 45 | } 46 | } 47 | 48 | private fun setResult(result : NewsChannelState) { 49 | _newsChannel.postValue(result) 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/java/io/fajarca/news_channel/presentation/mapper/NewsChannelPresentationMapper.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.presentation.mapper 2 | 3 | import io.fajarca.core.mapper.Mapper 4 | import io.fajarca.news_channel.domain.entities.ChannelContent 5 | import io.fajarca.news_channel.domain.entities.ChannelHeader 6 | import io.fajarca.news_channel.domain.entities.NewsChannel 7 | import io.fajarca.news_channel.domain.entities.NewsChannelItem 8 | import java.util.* 9 | import javax.inject.Inject 10 | 11 | class NewsChannelPresentationMapper @Inject constructor() : Mapper, List>() { 12 | 13 | override fun map(input: List): List { 14 | val newsChannel = mutableListOf() 15 | input.map { 16 | when (it) { 17 | is ChannelHeader -> { 18 | val countryName = getReadableCountryName(it.name) 19 | newsChannel.add(ChannelHeader(countryName)) 20 | } 21 | is ChannelContent -> { 22 | val channel = NewsChannel(it.newsChannel.id, getReadableCountryName(it.newsChannel.country), it.newsChannel.name, it.newsChannel.url, it.newsChannel.newsInitial) 23 | val content = ChannelContent(channel) 24 | newsChannel.add(content) 25 | } 26 | else -> {} 27 | } 28 | } 29 | return newsChannel 30 | } 31 | 32 | private fun getReadableCountryName(countryName : String) : String { 33 | val locale = Locale("en", countryName) 34 | return locale.displayCountry 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /feature_news_channel/src/main/res/drawable/rounded_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 9 | 11 | 16 | -------------------------------------------------------------------------------- /feature_news_channel/src/main/res/layout/fragment_news_channel.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 9 | 10 | 18 | 19 | 28 | 29 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /feature_news_channel/src/main/res/layout/item_news_channel.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 20 | 21 | 36 | 37 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /feature_news_channel/src/main/res/layout/item_news_channel_header.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /feature_news_channel/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | News Channel 3 | 4 | -------------------------------------------------------------------------------- /feature_news_channel/src/test/java/io/fajarca/news_channel/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Test 5 | 6 | /** 7 | * Example local unit test, which will execute on the development machine (host). 8 | * 9 | * See [testing documentation](http://d.android.com/tools/testing). 10 | */ 11 | class ExampleUnitTest { 12 | @Test 13 | fun addition_isCorrect() { 14 | assertEquals(4, 2 + 2) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /feature_news_channel/src/test/java/io/fajarca/news_channel/data/source/NewsChannelRemoteDataSourceTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.data.source 2 | 3 | import io.fajarca.news_channel.data.ChannelService 4 | import io.fajarca.testutil.extension.runBlockingTest 5 | import io.fajarca.testutil.rule.CoroutineTestRule 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import org.junit.Before 8 | import org.junit.Rule 9 | import org.junit.Test 10 | import org.mockito.Mock 11 | import org.mockito.Mockito.verify 12 | import org.mockito.MockitoAnnotations 13 | 14 | @UseExperimental(ExperimentalCoroutinesApi::class) 15 | class NewsChannelRemoteDataSourceTest { 16 | 17 | @get:Rule 18 | val coroutineTestRule = CoroutineTestRule() 19 | 20 | private lateinit var sut : NewsChannelRemoteDataSource 21 | 22 | @Mock 23 | private lateinit var channelService: ChannelService 24 | 25 | @Before 26 | fun setUp() { 27 | MockitoAnnotations.initMocks(this) 28 | sut = NewsChannelRemoteDataSource(channelService) 29 | } 30 | 31 | @Test 32 | fun `when get channel, should fetch from network`() = coroutineTestRule.runBlockingTest { 33 | //Given 34 | 35 | //When 36 | sut.getNewsChannel(coroutineTestRule.testDispatcher) 37 | 38 | //Then 39 | verify(channelService).getNewsChannels() 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /feature_news_channel/src/test/java/io/fajarca/news_channel/domain/usecase/GetNewsChannelUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.domain.usecase 2 | 3 | import io.fajarca.news_channel.domain.repository.NewsChannelRepository 4 | import io.fajarca.testutil.extension.runBlockingTest 5 | import io.fajarca.testutil.rule.CoroutineTestRule 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import org.junit.Before 8 | import org.junit.Rule 9 | import org.junit.Test 10 | import org.mockito.Mock 11 | import org.mockito.Mockito.verify 12 | import org.mockito.MockitoAnnotations 13 | 14 | @ExperimentalCoroutinesApi 15 | class GetNewsChannelUseCaseTest { 16 | 17 | @get:Rule 18 | val coroutineTestRule = CoroutineTestRule() 19 | 20 | @Mock 21 | private lateinit var repository: NewsChannelRepository 22 | private lateinit var sut : GetNewsChannelUseCase 23 | 24 | @Before 25 | fun setUp() { 26 | MockitoAnnotations.initMocks(this) 27 | sut = GetNewsChannelUseCase(repository) 28 | } 29 | 30 | @Test 31 | fun `when use case is invoked, should find all news channel from repository`() = coroutineTestRule.runBlockingTest { 32 | //Given 33 | 34 | //When 35 | sut.invoke() 36 | 37 | //Then 38 | verify(repository).findAllNewsChannel() 39 | } 40 | } -------------------------------------------------------------------------------- /feature_news_channel/src/test/java/io/fajarca/news_channel/presentation/NewsChannelViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.news_channel.presentation 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import androidx.lifecycle.Observer 5 | import io.fajarca.news_channel.domain.entities.NewsChannel 6 | import io.fajarca.news_channel.domain.usecase.GetNewsChannelUseCase 7 | import io.fajarca.news_channel.presentation.mapper.NewsChannelPresentationMapper 8 | import io.fajarca.testutil.LifeCycleTestOwner 9 | import io.fajarca.testutil.extension.runBlockingTest 10 | import io.fajarca.testutil.rule.CoroutineTestRule 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import org.junit.After 13 | import org.junit.Before 14 | import org.junit.Rule 15 | import org.junit.Test 16 | import org.junit.rules.TestRule 17 | import org.mockito.Mock 18 | import org.mockito.Mockito.`when` 19 | import org.mockito.Mockito.verify 20 | import org.mockito.MockitoAnnotations 21 | 22 | @UseExperimental(ExperimentalCoroutinesApi::class) 23 | class NewsChannelViewModelTest { 24 | 25 | @get:Rule 26 | val coroutineTestRule = CoroutineTestRule() 27 | 28 | @get:Rule 29 | var rule: TestRule = InstantTaskExecutorRule() 30 | 31 | private lateinit var lifecycleOwner: LifeCycleTestOwner 32 | private lateinit var sut: NewsChannelViewModel 33 | @Mock 34 | private lateinit var mapper: NewsChannelPresentationMapper 35 | @Mock 36 | private lateinit var useCase: GetNewsChannelUseCase 37 | @Mock 38 | private lateinit var observer: Observer 39 | 40 | @Before 41 | fun setUp() { 42 | MockitoAnnotations.initMocks(this) 43 | 44 | lifecycleOwner = LifeCycleTestOwner() 45 | lifecycleOwner.onCreate() 46 | 47 | sut = NewsChannelViewModel(useCase, mapper) 48 | sut.newsChannel.observe(lifecycleOwner, observer) 49 | } 50 | 51 | @After 52 | fun tearDown() { 53 | lifecycleOwner.onDestroy() 54 | } 55 | 56 | @Test 57 | fun `when given non empty data, should emit success state to observer`() = coroutineTestRule.runBlockingTest { 58 | //Given 59 | lifecycleOwner.onResume() 60 | 61 | val channels = arrayListOf(NewsChannel("cnn-indonesia", "id", "CNN Indonesia", "cnn-indonesia.com", "CI")) 62 | 63 | `when`(useCase.invoke()).thenReturn(channels) 64 | `when`(mapper.map(channels)).thenReturn(channels) 65 | 66 | //When 67 | sut.getNewsChannel() 68 | 69 | 70 | //Then 71 | verify(observer).onChanged(NewsChannelViewModel.NewsChannelState.Loading) 72 | verify(observer).onChanged(NewsChannelViewModel.NewsChannelState.Success(channels)) 73 | } 74 | 75 | @Test 76 | fun `when given empty data, should emit empty state to observer`() = coroutineTestRule.runBlockingTest { 77 | //Given 78 | lifecycleOwner.onResume() 79 | 80 | val channels = emptyList() 81 | 82 | `when`(useCase.invoke()).thenReturn(channels) 83 | 84 | //When 85 | sut.getNewsChannel() 86 | 87 | 88 | //Then 89 | verify(observer).onChanged(NewsChannelViewModel.NewsChannelState.Loading) 90 | verify(observer).onChanged(NewsChannelViewModel.NewsChannelState.Empty) 91 | } 92 | } -------------------------------------------------------------------------------- /feature_news_channel/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /feature_web_browser/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /feature_web_browser/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | apply plugin: "androidx.navigation.safeargs.kotlin" 6 | 7 | android { 8 | compileSdkVersion ApplicationConfig.compileSdk 9 | buildToolsVersion "29.0.2" 10 | 11 | dataBinding { 12 | enabled = true 13 | } 14 | 15 | defaultConfig { 16 | minSdkVersion ApplicationConfig.minSdk 17 | targetSdkVersion ApplicationConfig.targetSdk 18 | versionCode ApplicationConfig.versionCode 19 | versionName ApplicationConfig.versionName 20 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 21 | consumerProguardFiles 'consumer-rules.pro' 22 | } 23 | 24 | buildTypes { 25 | debug { 26 | testCoverageEnabled true 27 | } 28 | release { 29 | minifyEnabled false 30 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 31 | } 32 | } 33 | 34 | testOptions { 35 | execution 'ANDROID_TEST_ORCHESTRATOR' 36 | animationsDisabled true 37 | unitTests { 38 | includeAndroidResources = true 39 | } 40 | } 41 | compileOptions { 42 | sourceCompatibility JavaVersion.VERSION_1_8 43 | targetCompatibility JavaVersion.VERSION_1_8 44 | } 45 | 46 | kotlinOptions { 47 | jvmTarget = JavaVersion.VERSION_1_8.toString() 48 | } 49 | } 50 | dependencies { 51 | implementation fileTree(dir: 'libs', include: ['*.jar']) 52 | implementation project(Modules.presentation) 53 | implementation project(Modules.navigation) 54 | } 55 | -------------------------------------------------------------------------------- /feature_web_browser/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/feature_web_browser/consumer-rules.pro -------------------------------------------------------------------------------- /feature_web_browser/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 22 | -------------------------------------------------------------------------------- /feature_web_browser/src/androidTest/java/io/fajarca/web_browser/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.web_browser 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.* 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("io.fajarca.web_browser.test", appContext.packageName) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /feature_web_browser/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /feature_web_browser/src/main/res/layout/fragment_web_browser.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 11 | 12 | 20 | 21 | 26 | 27 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /feature_web_browser/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Feature Web Browser 3 | 4 | -------------------------------------------------------------------------------- /feature_web_browser/src/test/java/io/fajarca/web_browser/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.web_browser 2 | 3 | import org.junit.Assert.* 4 | import org.junit.Test 5 | 6 | /** 7 | * Example local unit test, which will execute on the development machine (host). 8 | * 9 | * See [testing documentation](http://d.android.com/tools/testing). 10 | */ 11 | class ExampleUnitTest { 12 | @Test 13 | fun addition_isCorrect() { 14 | assertEquals(4, 2 + 2) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /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=-Xmx1536m 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 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | org.gradle.parallel=true 23 | 24 | BASE_URL = "https://newsapi.org/" 25 | API_KEY = "c7acc244e5884787b21010cd475495cb" -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Nov 24 17:25:11 WIB 2019 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip 7 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /navigation/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /navigation/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | apply plugin: "androidx.navigation.safeargs.kotlin" 6 | 7 | android { 8 | compileSdkVersion ApplicationConfig.compileSdk 9 | buildToolsVersion "29.0.2" 10 | 11 | dataBinding { 12 | enabled = true 13 | } 14 | 15 | defaultConfig { 16 | minSdkVersion ApplicationConfig.minSdk 17 | targetSdkVersion ApplicationConfig.targetSdk 18 | versionCode ApplicationConfig.versionCode 19 | versionName ApplicationConfig.versionName 20 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 21 | consumerProguardFiles 'consumer-rules.pro' 22 | } 23 | 24 | buildTypes { 25 | debug { 26 | testCoverageEnabled true 27 | } 28 | release { 29 | minifyEnabled false 30 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 31 | } 32 | } 33 | 34 | testOptions { 35 | execution 'ANDROID_TEST_ORCHESTRATOR' 36 | animationsDisabled true 37 | unitTests { 38 | includeAndroidResources = true 39 | } 40 | } 41 | compileOptions { 42 | sourceCompatibility JavaVersion.VERSION_1_8 43 | targetCompatibility JavaVersion.VERSION_1_8 44 | } 45 | 46 | kotlinOptions { 47 | jvmTarget = JavaVersion.VERSION_1_8.toString() 48 | } 49 | } 50 | 51 | dependencies { 52 | implementation fileTree(dir: 'libs', include: ['*.jar']) 53 | 54 | implementation AndroidXLibraries.navigation_fragment_ktx 55 | implementation AndroidXLibraries.navigation 56 | implementation AndroidXLibraries.navigation_fragment 57 | } 58 | -------------------------------------------------------------------------------- /navigation/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/navigation/consumer-rules.pro -------------------------------------------------------------------------------- /navigation/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 22 | -------------------------------------------------------------------------------- /navigation/src/androidTest/java/io/fajarca/navigation/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.navigation 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.* 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("io.fajarca.navigation.test", appContext.packageName) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /navigation/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /navigation/src/main/java/io/fajarca/navigation/Origin.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.navigation 2 | 3 | enum class Origin { 4 | NEWS, 5 | CATEGORY, 6 | CHANNEL 7 | } -------------------------------------------------------------------------------- /navigation/src/main/res/navigation/nav_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 14 | 17 | 20 | 23 | 24 | 25 | 26 | 31 | 34 | 38 | 42 | 43 | 44 | 45 | 50 | 54 | 57 | 60 | 63 | 64 | 65 | 70 | 73 | 76 | 77 | 78 | 83 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /navigation/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Navigation 3 | 4 | -------------------------------------------------------------------------------- /navigation/src/test/java/io/fajarca/navigation/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.navigation 2 | 3 | import org.junit.Assert.* 4 | import org.junit.Test 5 | 6 | /** 7 | * Example local unit test, which will execute on the development machine (host). 8 | * 9 | * See [testing documentation](http://d.android.com/tools/testing). 10 | */ 11 | class ExampleUnitTest { 12 | @Test 13 | fun addition_isCorrect() { 14 | assertEquals(4, 2 + 2) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /presentation/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /presentation/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | android { 6 | compileSdkVersion 29 7 | buildToolsVersion "29.0.2" 8 | 9 | dataBinding { 10 | enabled = true 11 | } 12 | 13 | 14 | defaultConfig { 15 | minSdkVersion 21 16 | targetSdkVersion 29 17 | versionCode 1 18 | versionName "1.0" 19 | 20 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 21 | consumerProguardFiles 'consumer-rules.pro' 22 | } 23 | 24 | buildTypes { 25 | release { 26 | minifyEnabled false 27 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 28 | } 29 | } 30 | 31 | } 32 | 33 | dependencies { 34 | implementation fileTree(dir: 'libs', include: ['*.jar']) 35 | 36 | api Libraries.glide 37 | kapt Libraries.glideCompiler 38 | 39 | api Libraries.lifecycleExtensions 40 | kapt Libraries.lifecycleCompiler 41 | 42 | implementation Libraries.dagger 43 | kapt Libraries.daggerCompiler 44 | 45 | 46 | 47 | api AndroidXLibraries.design 48 | api AndroidXLibraries.cardview 49 | api AndroidXLibraries.recyclerview 50 | api AndroidXLibraries.navigation_fragment_ktx 51 | api AndroidXLibraries.navigation_ktx 52 | api AndroidXLibraries.navigation 53 | api AndroidXLibraries.navigation_fragment 54 | api AndroidXLibraries.paging 55 | api AndroidXLibraries.constraintLayout 56 | 57 | implementation Libraries.shimmer 58 | } 59 | -------------------------------------------------------------------------------- /presentation/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/presentation/consumer-rules.pro -------------------------------------------------------------------------------- /presentation/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 22 | -------------------------------------------------------------------------------- /presentation/src/androidTest/java/io/fajarca/presentation/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.presentation 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.* 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("io.fajarca.presentation.test", appContext.packageName) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /presentation/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /presentation/src/main/java/io/fajarca/presentation/BaseFragment.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.presentation 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.annotation.LayoutRes 9 | import androidx.databinding.DataBindingUtil 10 | import androidx.databinding.ViewDataBinding 11 | import androidx.fragment.app.Fragment 12 | import androidx.lifecycle.ViewModel 13 | import androidx.lifecycle.ViewModelProvider 14 | import androidx.lifecycle.ViewModelProviders 15 | import javax.inject.Inject 16 | 17 | abstract class BaseFragment : Fragment() { 18 | val binding: B 19 | get() = mViewDataBinding 20 | 21 | lateinit var vm : V 22 | 23 | @Inject 24 | lateinit var factory : ViewModelProvider.Factory 25 | 26 | private lateinit var mViewDataBinding: B 27 | 28 | override fun onAttach(context: Context) { 29 | initDaggerComponent() 30 | super.onAttach(context) 31 | } 32 | 33 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 34 | mViewDataBinding = DataBindingUtil.inflate(inflater, getLayoutResourceId(), container, false) 35 | vm = ViewModelProviders.of(this, factory).get(getViewModelClass()) 36 | mViewDataBinding.lifecycleOwner = this 37 | mViewDataBinding.executePendingBindings() 38 | return mViewDataBinding.root 39 | } 40 | 41 | @LayoutRes 42 | abstract fun getLayoutResourceId(): Int 43 | abstract fun initDaggerComponent() 44 | abstract fun getViewModelClass(): Class 45 | } -------------------------------------------------------------------------------- /presentation/src/main/java/io/fajarca/presentation/adapter/BindingAdapter.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.presentation.adapter 2 | 3 | import android.widget.ImageView 4 | import androidx.databinding.BindingAdapter 5 | import com.bumptech.glide.Glide 6 | import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions 7 | import com.bumptech.glide.request.RequestOptions 8 | import io.fajarca.presentation.R 9 | 10 | 11 | @BindingAdapter("loadPortraitImage") 12 | fun loadPortraitImage(view: ImageView, imageUrl: String?) { 13 | if (imageUrl.isNullOrEmpty()) return 14 | 15 | val requestOptions = RequestOptions() 16 | .placeholder(R.drawable.ic_placeholder) 17 | .centerCrop() 18 | 19 | Glide.with(view.context) 20 | .load(imageUrl) 21 | .transition(DrawableTransitionOptions.withCrossFade()) 22 | .thumbnail(0.2f) 23 | .apply(requestOptions) 24 | .into(view) 25 | } 26 | 27 | 28 | -------------------------------------------------------------------------------- /presentation/src/main/java/io/fajarca/presentation/customview/ShimmerView.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.presentation.customview 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.view.View 6 | import android.widget.LinearLayout 7 | import androidx.annotation.LayoutRes 8 | import com.facebook.shimmer.ShimmerFrameLayout 9 | import io.fajarca.presentation.R 10 | import io.fajarca.presentation.extension.gone 11 | import io.fajarca.presentation.extension.visible 12 | 13 | 14 | class ShimmerView : ShimmerFrameLayout { 15 | 16 | companion object { 17 | const val DEFAULT_PLACEHOLDER_ITEM_COUNT = 5 18 | } 19 | 20 | private lateinit var container : LinearLayout 21 | 22 | constructor(context: Context) : super(context) { 23 | init(context) 24 | } 25 | 26 | constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { 27 | init(context) 28 | initAttributeSet(attrs) 29 | } 30 | 31 | constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { 32 | init(context) 33 | initAttributeSet(attrs) 34 | } 35 | 36 | 37 | private fun init(context: Context) { 38 | val view = View.inflate(context, R.layout.shimmer_placeholder, this) 39 | container = view.findViewById(R.id.container) as LinearLayout 40 | } 41 | 42 | private fun initAttributeSet(attrs: AttributeSet) { 43 | val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ShimmerView) 44 | val placeHolderItemCount = typedArray.getInt(R.styleable.ShimmerView_sv_placeholder_item_count, DEFAULT_PLACEHOLDER_ITEM_COUNT) 45 | start(placeHolderItemCount) 46 | typedArray.recycle() 47 | } 48 | 49 | 50 | private fun start(numberOfPlaceholderItem : Int) { 51 | createPlaceholderItem(numberOfPlaceholderItem) 52 | visible() 53 | startShimmer() 54 | invalidate() 55 | } 56 | 57 | fun start(numberOfPlaceholderItem : Int, @LayoutRes layoutResId : Int) { 58 | createPlaceholderItem(numberOfPlaceholderItem, layoutResId) 59 | visible() 60 | startShimmer() 61 | invalidate() 62 | } 63 | 64 | fun stop() { 65 | gone() 66 | stopShimmer() 67 | } 68 | 69 | private fun createPlaceholderItem(numberOfPlaceholderItem : Int, layoutResId: Int = R.layout.default_placeholder) { 70 | repeat(numberOfPlaceholderItem) { 71 | val placeholder = inflate(context, layoutResId, null ) 72 | container.addView(placeholder) 73 | } 74 | invalidate() 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /presentation/src/main/java/io/fajarca/presentation/extension/Extensions.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.presentation.extension 2 | 3 | import java.text.DateFormat 4 | import java.text.ParseException 5 | import java.text.SimpleDateFormat 6 | import java.util.* 7 | 8 | fun String.toLocalTime(locale: Locale): String { 9 | if (this.isNotEmpty()) { 10 | val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) 11 | dateFormat.timeZone = TimeZone.getTimeZone("UTC") 12 | 13 | return try { 14 | val date = dateFormat.parse(this) 15 | val formattedDate = DateFormat.getTimeInstance(DateFormat.SHORT, locale).format(date ?: Date()) 16 | formattedDate 17 | } catch (e: ParseException) { 18 | throw IllegalArgumentException("Not a valid datetime format") 19 | } 20 | } 21 | 22 | return "" 23 | } -------------------------------------------------------------------------------- /presentation/src/main/java/io/fajarca/presentation/extension/ViewExtension.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.presentation.extension 2 | 3 | import android.view.View 4 | 5 | fun View.visible() { 6 | this.visibility = View.VISIBLE 7 | } 8 | 9 | fun View.invisible() { 10 | this.visibility = View.INVISIBLE 11 | } 12 | 13 | fun View.gone() { 14 | this.visibility = View.GONE 15 | } -------------------------------------------------------------------------------- /presentation/src/main/res/drawable/ic_error.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /presentation/src/main/res/drawable/ic_no_connection.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /presentation/src/main/res/drawable/ic_no_data.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 8 | 10 | 12 | 14 | 15 | 17 | 19 | 21 | 22 | -------------------------------------------------------------------------------- /presentation/src/main/res/drawable/ic_placeholder.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /presentation/src/main/res/font/googlesans.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 11 | -------------------------------------------------------------------------------- /presentation/src/main/res/font/googlesans_italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/presentation/src/main/res/font/googlesans_italic.ttf -------------------------------------------------------------------------------- /presentation/src/main/res/font/googlesans_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/presentation/src/main/res/font/googlesans_regular.ttf -------------------------------------------------------------------------------- /presentation/src/main/res/layout/default_placeholder.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 18 | 19 | 30 | 31 | 43 | 44 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /presentation/src/main/res/layout/layout_ui_state_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 18 | 19 | 30 | 31 | 41 | 42 | 53 | 54 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /presentation/src/main/res/layout/shimmer_placeholder.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /presentation/src/main/res/layout/toolbar.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /presentation/src/main/res/values/attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /presentation/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Presentation 3 | image 4 | Loading 5 | Retry 6 | 7 | -------------------------------------------------------------------------------- /presentation/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | -------------------------------------------------------------------------------- /presentation/src/test/java/io/fajarca/presentation/ExampleUnitTest.kt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/presentation/src/test/java/io/fajarca/presentation/ExampleUnitTest.kt -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app', 2 | ':test_util', 3 | ':core', 4 | ':navigation', 5 | ':presentation', 6 | 'feature_news', 7 | ':feature_news_channel', 8 | ':feature_web_browser', 9 | ':feature_news_category' 10 | 11 | 12 | rootProject.name = 'BuzzNews' 13 | -------------------------------------------------------------------------------- /test_util/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /test_util/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | android { 5 | compileSdkVersion 29 6 | buildToolsVersion "29.0.2" 7 | 8 | 9 | defaultConfig { 10 | minSdkVersion 21 11 | targetSdkVersion 29 12 | versionCode 1 13 | versionName "1.0" 14 | 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | consumerProguardFiles 'consumer-rules.pro' 17 | } 18 | 19 | buildTypes { 20 | release { 21 | minifyEnabled false 22 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 23 | } 24 | } 25 | 26 | } 27 | 28 | dependencies { 29 | implementation fileTree(dir: 'libs', include: ['*.jar']) 30 | 31 | implementation TestLibraries.junit 32 | implementation TestLibraries.lifecycleTesting 33 | implementation TestLibraries.coroutine 34 | implementation TestLibraries.mockito 35 | implementation project(Modules.core) 36 | } 37 | -------------------------------------------------------------------------------- /test_util/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fajarca/android-clean-architecture-coroutine/96398ccfebb3a89e169306b1a4eb33338b42193c/test_util/consumer-rules.pro -------------------------------------------------------------------------------- /test_util/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 22 | -------------------------------------------------------------------------------- /test_util/src/androidTest/java/io/fajarca/testutil/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.testutil 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("io.fajarca.testutil.test", appContext.packageName) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test_util/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test_util/src/main/java/io/fajarca/testutil/LifeCycleTestOwner.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.testutil 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import androidx.lifecycle.LifecycleOwner 5 | import androidx.lifecycle.LifecycleRegistry 6 | 7 | /** 8 | * We’ll use this to replicate the presence of an activity 9 | * or fragment in our test class. 10 | */ 11 | class LifeCycleTestOwner : LifecycleOwner { 12 | 13 | private val registry = LifecycleRegistry(this) 14 | 15 | override fun getLifecycle(): Lifecycle { 16 | return registry 17 | } 18 | 19 | fun onCreate() { 20 | registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) 21 | } 22 | 23 | fun onResume() { 24 | registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) 25 | } 26 | 27 | fun onDestroy() { 28 | registry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) 29 | } 30 | } -------------------------------------------------------------------------------- /test_util/src/main/java/io/fajarca/testutil/extension/TestExtensions.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.testutil.extension 2 | 3 | import io.fajarca.testutil.rule.CoroutineTestRule 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.test.TestCoroutineScope 6 | import kotlinx.coroutines.test.runBlockingTest 7 | 8 | @UseExperimental(ExperimentalCoroutinesApi::class) 9 | fun CoroutineTestRule.runBlockingTest(block : suspend TestCoroutineScope.() -> Unit) { 10 | testDispatcher.runBlockingTest(block) 11 | } -------------------------------------------------------------------------------- /test_util/src/main/java/io/fajarca/testutil/rule/CoroutineTestRule.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.testutil.rule 2 | 3 | import io.fajarca.core.dispatcher.DispatcherProvider 4 | import kotlinx.coroutines.CoroutineDispatcher 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import kotlinx.coroutines.test.TestCoroutineDispatcher 8 | import kotlinx.coroutines.test.resetMain 9 | import kotlinx.coroutines.test.setMain 10 | import org.junit.rules.TestWatcher 11 | import org.junit.runner.Description 12 | 13 | @ExperimentalCoroutinesApi 14 | class CoroutineTestRule( 15 | val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() 16 | ) : TestWatcher() { 17 | 18 | val testDispatcherProvider = object : DispatcherProvider { 19 | override val io: CoroutineDispatcher = testDispatcher 20 | override val ui: CoroutineDispatcher = testDispatcher 21 | override val default: CoroutineDispatcher = testDispatcher 22 | override val unconfined: CoroutineDispatcher = testDispatcher 23 | } 24 | 25 | override fun starting(description: Description?) { 26 | super.starting(description) 27 | Dispatchers.setMain(testDispatcher) 28 | } 29 | 30 | override fun finished(description: Description?) { 31 | super.finished(description) 32 | Dispatchers.resetMain() 33 | testDispatcher.cleanupTestCoroutines() 34 | } 35 | } -------------------------------------------------------------------------------- /test_util/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Test Util 3 | 4 | -------------------------------------------------------------------------------- /test_util/src/test/java/io/fajarca/testutil/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package io.fajarca.testutil 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Test 5 | 6 | /** 7 | * Example local unit test, which will execute on the development machine (host). 8 | * 9 | * See [testing documentation](http://d.android.com/tools/testing). 10 | */ 11 | class ExampleUnitTest { 12 | @Test 13 | fun addition_isCorrect() { 14 | assertEquals(4, 2 + 2) 15 | } 16 | } 17 | --------------------------------------------------------------------------------