├── .gitignore ├── AndroidArchitecture.iml ├── README.md ├── app ├── .gitignore ├── app.iml ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── kotlin │ │ └── news │ │ └── ta │ │ └── com │ │ └── news │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── kotlin │ │ └── news │ │ │ └── ta │ │ │ └── com │ │ │ └── news │ │ │ ├── common │ │ │ ├── BindingAdapters.kt │ │ │ ├── FragmentManagerExtension.kt │ │ │ ├── ObservableExtension.kt │ │ │ ├── livedata │ │ │ │ └── SingleLiveEvent.java │ │ │ └── view │ │ │ │ └── SquareImageView.kt │ │ │ ├── di │ │ │ ├── NetworkModule.kt │ │ │ ├── RepositoryModule.kt │ │ │ └── ServicesModule.kt │ │ │ ├── feature │ │ │ ├── NewsApplication.kt │ │ │ ├── main │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── MainBinder.kt │ │ │ │ ├── MainModule.kt │ │ │ │ └── MainView.kt │ │ │ ├── newsdetail │ │ │ │ ├── NewsDetailActivity.kt │ │ │ │ ├── NewsDetailActivityBinder.kt │ │ │ │ ├── NewsDetailActivityView.kt │ │ │ │ ├── NewsDetailBinder.kt │ │ │ │ ├── NewsDetailFragment.kt │ │ │ │ ├── NewsDetailModule.kt │ │ │ │ ├── NewsDetailRouter.kt │ │ │ │ └── NewsDetailsViewModel.kt │ │ │ └── newslist │ │ │ │ ├── NewsListAdapter.kt │ │ │ │ ├── NewsListBinder.kt │ │ │ │ ├── NewsListFragment.kt │ │ │ │ ├── NewsListModule.kt │ │ │ │ ├── NewsListRouter.kt │ │ │ │ ├── NewsListView.kt │ │ │ │ ├── NewsListViewModel.kt │ │ │ │ └── NewsRepository.kt │ │ │ ├── model │ │ │ ├── ArticleDTO.kt │ │ │ ├── NewsDTO.kt │ │ │ └── SourceDTO.kt │ │ │ └── services │ │ │ ├── DataTransferCallback.kt │ │ │ ├── NewsService.kt │ │ │ └── ResponseType.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable-xxhdpi │ │ ├── ic_divider.png │ │ ├── ic_globe_16.png │ │ ├── ic_home.png │ │ ├── ic_image_default.png │ │ ├── ic_news.png │ │ └── ic_up.png │ │ ├── drawable │ │ ├── bg_selection.xml │ │ ├── decor_m.xml │ │ ├── headline_bg.xml │ │ ├── ic_launcher_background.xml │ │ └── title_bg.xml │ │ ├── font │ │ └── averia_serif_libre_light.xml │ │ ├── layout-land │ │ ├── activity_main.xml │ │ ├── fragment_news_list.xml │ │ └── item_news.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── activity_news_details.xml │ │ ├── fragment_news_details.xml │ │ ├── fragment_news_list.xml │ │ └── item_news.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxxhdpi │ │ └── ic_launcher.png │ │ ├── values-land │ │ └── dimens.xml │ │ └── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── font_certs.xml │ │ ├── preloaded_fonts.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── news │ └── ta │ └── com │ └── news │ └── feature │ ├── newsdetail │ └── NewsDetailsViewModelTest.kt │ └── newslist │ ├── NewsListViewModelTest.kt │ └── NewsRepositoryTest.kt ├── build.gradle ├── build ├── intermediates │ └── lint-cache │ │ ├── api-versions-0-29.0.5.bin │ │ ├── maven.google │ │ ├── androidx │ │ │ ├── activity │ │ │ │ └── group-index.xml │ │ │ ├── annotation │ │ │ │ └── group-index.xml │ │ │ ├── appcompat │ │ │ │ └── group-index.xml │ │ │ ├── arch │ │ │ │ └── core │ │ │ │ │ └── group-index.xml │ │ │ ├── cardview │ │ │ │ └── group-index.xml │ │ │ ├── constraintlayout │ │ │ │ └── group-index.xml │ │ │ ├── core │ │ │ │ └── group-index.xml │ │ │ ├── lifecycle │ │ │ │ └── group-index.xml │ │ │ ├── multidex │ │ │ │ └── group-index.xml │ │ │ ├── recyclerview │ │ │ │ └── group-index.xml │ │ │ ├── slice │ │ │ │ └── group-index.xml │ │ │ └── test │ │ │ │ ├── espresso │ │ │ │ └── group-index.xml │ │ │ │ └── group-index.xml │ │ ├── com │ │ │ └── google │ │ │ │ └── android │ │ │ │ └── material │ │ │ │ └── group-index.xml │ │ └── master-index.xml │ │ ├── sdk-registry.xml │ │ └── sdk-registry.xml │ │ └── typos-en.txt-2.bin ├── kotlin-build │ └── version.txt ├── kotlin │ └── AndroidArchitecturejar-classes.txt ├── libs │ └── AndroidArchitecture.jar └── tmp │ └── jar │ └── MANIFEST.MF ├── config └── quality │ └── lint │ └── report │ └── consumer-lint.html ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── ktlint └── ktlint.gradle ├── local.properties └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | .gradle/ 3 | .idea/ 4 | -------------------------------------------------------------------------------- /AndroidArchitecture.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 32 | 44 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AndroidArchitecture 2 | This project is to demonstrate the Android architecture with Pattern MVVM-BR + Repository. Using Kotlin and ACC. 3 | 4 | For more infomation 5 | https://medium.com/ta-tonthongkam/android-code-architecture-with-mix-pattern-ffe396dbab6f 6 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/app.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 26 | 27 | 28 | 29 | 30 | 32 | 33 | 48 | 61 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | apply from: "$project.rootDir/ktlint/ktlint.gradle" 6 | 7 | android { 8 | compileSdkVersion 29 9 | defaultConfig { 10 | applicationId "news.ta.com.news" 11 | minSdkVersion 19 12 | targetSdkVersion 29 13 | versionCode 1 14 | versionName "1.0" 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | multiDexEnabled true 17 | buildConfigField "String", "BASE_URL", "\"https://newsapi.org/\"" 18 | buildConfigField "String", "NEWS_API_KEY", "\"78580b7358ae4289b085aa2d25374c1e\"" 19 | } 20 | buildTypes { 21 | release { 22 | minifyEnabled false 23 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 24 | } 25 | } 26 | sourceSets { 27 | main.java.srcDirs += 'src/main/kotlin' 28 | main.jniLibs.srcDirs = ['libs'] 29 | androidTest.java.srcDirs += 'src/androidTest/kotlin' 30 | test.java.srcDirs += 'src/test/kotlin' 31 | } 32 | dataBinding { 33 | dataBinding.enabled true 34 | } 35 | dexOptions { 36 | jumboMode true 37 | } 38 | lintOptions { 39 | abortOnError true 40 | lintConfig file("${project.rootDir}/config/quality/lint/lint.xml") 41 | htmlOutput file("${project.rootDir}/config/quality/lint/report/consumer-lint.html") 42 | htmlReport true 43 | textReport true 44 | } 45 | testOptions { 46 | unitTests.returnDefaultValues = true 47 | } 48 | compileOptions { 49 | sourceCompatibility JavaVersion.VERSION_1_8 50 | targetCompatibility JavaVersion.VERSION_1_8 51 | } 52 | 53 | kotlinOptions { 54 | jvmTarget = JavaVersion.VERSION_1_8.toString() 55 | } 56 | } 57 | 58 | dependencies { 59 | implementation fileTree(dir: 'libs', include: ['*.jar']) 60 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 61 | implementation 'androidx.appcompat:appcompat:1.2.0-alpha02' 62 | implementation 'com.google.android.material:material:1.2.0-alpha04' 63 | implementation 'androidx.constraintlayout:constraintlayout:1.1.3' 64 | implementation 'androidx.multidex:multidex:2.0.1' 65 | 66 | // Android Architecture 67 | implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' 68 | 69 | // Support view 70 | implementation 'androidx.cardview:cardview:1.0.0' 71 | implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha01' 72 | implementation 'androidx.recyclerview:recyclerview-selection:1.1.0-rc01' 73 | 74 | // Slice 75 | implementation 'androidx.slice:slice-core:1.1.0-alpha01' 76 | implementation 'androidx.slice:slice-builders:1.1.0-alpha01' 77 | 78 | // KTX 79 | implementation "androidx.core:core-ktx:1.2.0" 80 | implementation "androidx.activity:activity-ktx:1.1.0" 81 | 82 | // Kotlin binding 83 | implementation 'androidx.annotation:annotation:1.1.0' 84 | implementation "org.koin:koin-core:$koin_version" 85 | testImplementation "org.koin:koin-test:$koin_version" 86 | implementation "org.koin:koin-android-scope:$koin_version" 87 | implementation "org.koin:koin-android-viewmodel:$koin_version" 88 | 89 | // Image loader 90 | implementation 'com.github.bumptech.glide:glide:4.9.0' 91 | kapt 'com.github.bumptech.glide:compiler:4.9.0' 92 | 93 | // Service call 94 | implementation "com.squareup.retrofit2:retrofit:$retrofit_version" 95 | implementation "com.squareup.retrofit2:converter-gson:$retrofit_version" 96 | implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version" 97 | 98 | // Dependency injection 99 | implementation "com.google.dagger:dagger:$dagger_version" 100 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 101 | compileOnly 'javax.annotation:jsr250-api:1.0' 102 | implementation 'javax.inject:javax.inject:1' 103 | kaptTest "com.google.dagger:dagger-compiler:$dagger_version" 104 | testImplementation "com.google.dagger:dagger-compiler:$dagger_version" 105 | 106 | //coroutines 107 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" 108 | 109 | // Other Library 110 | implementation 'com.bignerdranch.android:simple-item-decoration:1.0.0' 111 | testImplementation 'junit:junit:4.12' 112 | testImplementation 'com.nhaarman:mockito-kotlin:1.5.0' 113 | testImplementation 'org.amshove.kluent:kluent:1.30' 114 | testImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 115 | testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" 116 | testImplementation "de.jodamob.kotlin:kotlin-runner-junit4:$kotlin_test_runner_version" 117 | androidTestImplementation 'androidx.test:runner:1.3.0-alpha03' 118 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0-alpha03' 119 | testImplementation 'androidx.arch.core:core-testing:2.1.0' 120 | testImplementation "com.squareup.okhttp3:mockwebserver:$okhttp_version" 121 | } 122 | 123 | afterEvaluate { 124 | android.sourceSets.all { sourceSet -> 125 | if (!sourceSet.name.startsWith('test') || !sourceSet.name.startsWith('androidTest')) { 126 | sourceSet.kotlin.setSrcDirs([]) 127 | } 128 | } 129 | } 130 | 131 | kotlin { 132 | experimental { 133 | coroutines "enable" 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /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/src/androidTest/kotlin/news/ta/com/news/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news 2 | 3 | import androidx.test.InstrumentationRegistry 4 | import androidx.test.runner.AndroidJUnit4 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.getTargetContext() 20 | assertEquals("news.ta.com.news", appContext.packageName) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 17 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 30 | 31 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/common/BindingAdapters.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.common 2 | 3 | import androidx.databinding.BindingAdapter 4 | import android.view.View 5 | import android.widget.ImageView 6 | import com.bumptech.glide.Glide 7 | 8 | @BindingAdapter("url") 9 | fun bindImageToImageView(view: ImageView, url: String?) { 10 | when (url) { 11 | is String -> { 12 | Glide.with(view.context) 13 | .load(url) 14 | .into(view) 15 | } 16 | } 17 | } 18 | 19 | @BindingAdapter("show") 20 | fun showOrHideView(view: View, show: Boolean) { 21 | when (show) { 22 | true -> view.visibility = View.VISIBLE 23 | false -> view.visibility = View.GONE 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/common/FragmentManagerExtension.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.common 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.fragment.app.FragmentManager 5 | import androidx.fragment.app.FragmentTransaction 6 | 7 | fun FragmentManager.replaceWith(id: Int?, fragment: Fragment, tag: String? = null) { 8 | if (id == null) return 9 | this.beginTransaction().replace(id, fragment, tag).setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE).commit() 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/common/ObservableExtension.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.common 2 | 3 | import androidx.databinding.Observable 4 | import androidx.databinding.ObservableField 5 | 6 | fun ObservableField.onValueChange(callback: (T) -> Unit) { 7 | val that = this 8 | this.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() { 9 | override fun onPropertyChanged(p0: Observable?, p1: Int) { 10 | that.get()?.let { callback.invoke(it) } 11 | } 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/common/livedata/SingleLiveEvent.java: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.common.livedata; 2 | 3 | 4 | /* 5 | * Copyright 2017 Google Inc. 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | */ 19 | 20 | import androidx.lifecycle.LifecycleOwner; 21 | import androidx.lifecycle.MutableLiveData; 22 | import androidx.lifecycle.Observer; 23 | import androidx.annotation.MainThread; 24 | import androidx.annotation.Nullable; 25 | import android.util.Log; 26 | 27 | import java.util.concurrent.atomic.AtomicBoolean; 28 | 29 | /** 30 | * A lifecycle-aware observable that sends only new updates after subscription, used for events like 31 | * navigation and Snackbar messages. 32 | *

33 | * This avoids a common problem with events: on configuration change (like rotation) an update 34 | * can be emitted if the observer is active. This LiveData only calls the observable if there's an 35 | * explicit call to setValue() or call(). 36 | *

37 | * Note that only one observer is going to be notified of changes. 38 | */ 39 | public class SingleLiveEvent extends MutableLiveData { 40 | 41 | private static final String TAG = "SingleLiveEvent"; 42 | 43 | private final AtomicBoolean mPending = new AtomicBoolean(false); 44 | 45 | @MainThread 46 | public void observe(LifecycleOwner owner, final Observer observer) { 47 | 48 | if (hasActiveObservers()) { 49 | Log.w(TAG, "Multiple observers registered but only one will be notified of changes."); 50 | } 51 | 52 | // Observe the internal MutableLiveData 53 | super.observe(owner, new Observer() { 54 | @Override 55 | public void onChanged(@Nullable T t) { 56 | if (mPending.compareAndSet(true, false)) { 57 | observer.onChanged(t); 58 | } 59 | } 60 | }); 61 | } 62 | 63 | @MainThread 64 | public void setValue(@Nullable T t) { 65 | mPending.set(true); 66 | super.setValue(t); 67 | } 68 | 69 | /** 70 | * Used for cases where T is Void, to make calls cleaner. 71 | */ 72 | @MainThread 73 | public void call() { 74 | setValue(null); 75 | } 76 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/common/view/SquareImageView.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.common.view 2 | 3 | import android.content.Context 4 | import androidx.annotation.Nullable 5 | import androidx.appcompat.widget.AppCompatImageView 6 | import android.util.AttributeSet 7 | 8 | class SquareImageView : AppCompatImageView { 9 | 10 | constructor(context: Context) : super(context) 11 | 12 | constructor(context: Context, @Nullable attrs: AttributeSet) : this(context, attrs, 0) 13 | 14 | constructor(context: Context, @Nullable attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) 15 | 16 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 17 | super.onMeasure(widthMeasureSpec, widthMeasureSpec) 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.di 2 | 3 | import android.content.Context 4 | import com.google.gson.FieldNamingPolicy 5 | import com.google.gson.GsonBuilder 6 | import news.ta.com.news.BuildConfig 7 | import okhttp3.Cache 8 | import okhttp3.Interceptor 9 | import okhttp3.OkHttpClient 10 | import okhttp3.logging.HttpLoggingInterceptor 11 | import okhttp3.logging.HttpLoggingInterceptor.Level.BODY 12 | import okhttp3.logging.HttpLoggingInterceptor.Level.NONE 13 | import org.koin.android.ext.koin.androidContext 14 | import org.koin.dsl.module 15 | import retrofit2.Retrofit 16 | import retrofit2.converter.gson.GsonConverterFactory 17 | import java.io.File 18 | 19 | open class NetworkModule { 20 | fun getModule() = module { 21 | single { 22 | GsonBuilder() 23 | .setFieldNamingPolicy(FieldNamingPolicy.IDENTITY) 24 | .setDateFormat("dd-MM-yyyy") 25 | .create() 26 | } 27 | single { 28 | Retrofit 29 | .Builder() 30 | .baseUrl(BuildConfig.BASE_URL) 31 | .addConverterFactory(GsonConverterFactory.create(get())) 32 | .client(createClient(androidContext())) 33 | .build() 34 | } 35 | } 36 | 37 | private fun createClient(context: Context): OkHttpClient { 38 | val cacheDir = File(context.cacheDir, "responses") 39 | 40 | return OkHttpClient.Builder() 41 | .cache(Cache(cacheDir, 10 * 1024 * 1024)) //10Mb 42 | .addInterceptor(log(BuildConfig.DEBUG)) 43 | .addInterceptor(addHeaderKey()) 44 | .build() 45 | } 46 | 47 | private fun log(enabled: Boolean): Interceptor { 48 | val logging = HttpLoggingInterceptor() 49 | logging.level = if (enabled) BODY else NONE 50 | return logging 51 | } 52 | 53 | private fun addHeaderKey() = Interceptor { chain -> 54 | var request = chain.request() 55 | 56 | request = request.newBuilder() 57 | .header("Authorization", "Bearer " + BuildConfig.NEWS_API_KEY) 58 | .build() 59 | 60 | chain.proceed(request) 61 | } 62 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/di/RepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.di 2 | 3 | import news.ta.com.news.feature.newslist.NewsRepository 4 | import news.ta.com.news.feature.newslist.NewsRepositoryImpl 5 | import org.koin.dsl.module 6 | 7 | open class RepositoryModule { 8 | 9 | fun getModule() = module { 10 | single { NewsRepositoryImpl(get()) } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/di/ServicesModule.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.di 2 | 3 | import news.ta.com.news.services.NewsService 4 | import org.koin.dsl.module 5 | import retrofit2.Retrofit 6 | 7 | class ServicesModule { 8 | 9 | fun getModule() = module { 10 | single { provideNewsService(get()) } 11 | } 12 | 13 | private fun provideNewsService(retrofit: Retrofit): NewsService = retrofit.create(NewsService::class.java) 14 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/NewsApplication.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature 2 | 3 | import androidx.multidex.MultiDexApplication 4 | import news.ta.com.news.di.NetworkModule 5 | import news.ta.com.news.di.RepositoryModule 6 | import news.ta.com.news.di.ServicesModule 7 | import news.ta.com.news.feature.newslist.NewsItem 8 | import org.koin.android.ext.koin.androidContext 9 | import org.koin.android.ext.koin.androidLogger 10 | import org.koin.core.context.startKoin 11 | 12 | class NewsApplication : MultiDexApplication() { 13 | 14 | companion object { 15 | var news: NewsItem? = null 16 | } 17 | 18 | override fun onCreate() { 19 | super.onCreate() 20 | startKoin { 21 | androidContext(this@NewsApplication) 22 | androidLogger() 23 | modules( 24 | listOf(NetworkModule().getModule(), 25 | RepositoryModule().getModule(), 26 | ServicesModule().getModule() 27 | ) 28 | ) 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/main/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.main 2 | 3 | import androidx.databinding.DataBindingUtil 4 | import android.os.Bundle 5 | import android.util.Log 6 | import androidx.appcompat.app.AppCompatActivity 7 | import news.ta.com.news.R 8 | import news.ta.com.news.databinding.ActivityMainBinding 9 | import org.koin.android.scope.currentScope 10 | import org.koin.core.context.loadKoinModules 11 | import org.koin.core.context.unloadKoinModules 12 | import org.koin.core.module.Module 13 | 14 | class MainActivity : AppCompatActivity() { 15 | 16 | var module: Module? = null 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | val binding = DataBindingUtil.setContentView(this, R.layout.activity_main) 20 | Log.d("ROTATE", "Create") 21 | module = MainModule().getModule(this, binding) 22 | module?.let { 23 | loadKoinModules(it) 24 | } 25 | val binder: MainBinder = currentScope.get() 26 | binder.bindTo(this) 27 | } 28 | 29 | override fun onDetachedFromWindow() { 30 | super.onDetachedFromWindow() 31 | module?.let { 32 | unloadKoinModules(it) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/main/MainBinder.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.main 2 | 3 | import androidx.lifecycle.LifecycleOwner 4 | import androidx.lifecycle.Observer 5 | import news.ta.com.news.feature.newsdetail.NewsDetailsViewModel 6 | 7 | class MainBinder(val viewModel: NewsDetailsViewModel, val view: MainView) { 8 | init { 9 | view.showList() 10 | view.showDetail() 11 | } 12 | 13 | fun bindTo(owner: LifecycleOwner) { 14 | viewModel.selectedEvent.observe(owner, Observer { view.scrollDetailToTop() }) 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/main/MainModule.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.main 2 | 3 | import news.ta.com.news.databinding.ActivityMainBinding 4 | import news.ta.com.news.feature.newsdetail.NewsDetailsViewModel 5 | import org.koin.android.viewmodel.dsl.viewModel 6 | import org.koin.core.qualifier.named 7 | import org.koin.dsl.module 8 | 9 | class MainModule { 10 | fun getModule(activity: MainActivity, binding: ActivityMainBinding) = module { 11 | scope(named()) { 12 | factory { MainBinder(get(), get()) } 13 | factory { MainViewImpl(binding, activity.supportFragmentManager) } 14 | viewModel { NewsDetailsViewModel() } 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/main/MainView.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.main 2 | 3 | import androidx.core.widget.NestedScrollView 4 | import androidx.fragment.app.FragmentManager 5 | import news.ta.com.news.R 6 | import news.ta.com.news.common.replaceWith 7 | import news.ta.com.news.databinding.ActivityMainBinding 8 | import news.ta.com.news.feature.newsdetail.NewsDetailFragment 9 | import news.ta.com.news.feature.newslist.NewsListFragment 10 | 11 | interface MainView { 12 | fun scrollDetailToTop() 13 | fun showList() 14 | fun showDetail() 15 | } 16 | 17 | class MainViewImpl(val binding: ActivityMainBinding?, private val fm: FragmentManager) : MainView { 18 | override fun scrollDetailToTop() { 19 | val scrollView = binding?.detail?.findViewById(R.id.scrollContainer) 20 | scrollView?.scrollTo(0, 0) 21 | } 22 | 23 | override fun showList() { 24 | fm.replaceWith(binding?.listItem?.id, NewsListFragment.newInstance(binding?.detail != null)) 25 | } 26 | 27 | override fun showDetail() { 28 | fm.replaceWith(binding?.detail?.id, NewsDetailFragment.newInstance()) 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newsdetail/NewsDetailActivity.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newsdetail 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import androidx.databinding.DataBindingUtil 6 | import android.os.Bundle 7 | import androidx.appcompat.app.AppCompatActivity 8 | import android.view.Window 9 | import news.ta.com.news.R 10 | import news.ta.com.news.databinding.ActivityNewsDetailsBinding 11 | import news.ta.com.news.feature.newslist.NewsItem 12 | import org.koin.android.scope.currentScope 13 | import org.koin.core.context.loadKoinModules 14 | import org.koin.core.context.unloadKoinModules 15 | import org.koin.core.module.Module 16 | 17 | class NewsDetailActivity : AppCompatActivity() { 18 | var binding: ActivityNewsDetailsBinding? = null 19 | var binder: NewsDetailActivityBinder? = null 20 | var module: Module? = null 21 | 22 | companion object { 23 | 24 | val EXTRA_NEWS_ITEM = "news.ta.com.news.feature.newsdetail.EXTRA_NEWS_ITEM" 25 | 26 | fun route(context: Context, item: NewsItem) { 27 | val i = Intent(context, NewsDetailActivity::class.java) 28 | i.putExtra(EXTRA_NEWS_ITEM, item) 29 | context.startActivity(i) 30 | } 31 | } 32 | 33 | override fun onCreate(savedInstanceState: Bundle?) { 34 | super.onCreate(savedInstanceState) 35 | requestWindowFeature(Window.FEATURE_ACTION_BAR) 36 | binding = DataBindingUtil.setContentView(this, R.layout.activity_news_details) 37 | module = NewsDetailModule().getModule(this, binding) 38 | module?.let { 39 | loadKoinModules(it) 40 | } 41 | binder = currentScope.get() 42 | } 43 | 44 | override fun onSupportNavigateUp(): Boolean { 45 | onBackPressed() 46 | return true 47 | } 48 | 49 | override fun onDetachedFromWindow() { 50 | super.onDetachedFromWindow() 51 | module?.let { 52 | unloadKoinModules(it) 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newsdetail/NewsDetailActivityBinder.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newsdetail 2 | 3 | import androidx.appcompat.app.AppCompatActivity 4 | import news.ta.com.news.databinding.ActivityNewsDetailsBinding 5 | 6 | class NewsDetailActivityBinder(activity: AppCompatActivity, binding: ActivityNewsDetailsBinding?, viewModel: NewsDetailsViewModel) { 7 | 8 | val view: NewsDetailActivityView 9 | init { 10 | view = NewsDetailActivityViewImpl(activity, binding, viewModel) 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newsdetail/NewsDetailActivityView.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newsdetail 2 | 3 | import androidx.appcompat.app.AppCompatActivity 4 | import news.ta.com.news.common.replaceWith 5 | import news.ta.com.news.databinding.ActivityNewsDetailsBinding 6 | import news.ta.com.news.feature.newslist.NewsItem 7 | 8 | interface NewsDetailActivityView 9 | 10 | class NewsDetailActivityViewImpl(activity: AppCompatActivity, binding: ActivityNewsDetailsBinding?, viewModel: NewsDetailsViewModel) : NewsDetailActivityView { 11 | private val newsItem = activity.intent?.getSerializableExtra(NewsDetailActivity.EXTRA_NEWS_ITEM) as NewsItem 12 | 13 | init { 14 | with(activity) { 15 | supportFragmentManager.replaceWith(binding?.detail?.id, NewsDetailFragment.newInstance()) 16 | setSupportActionBar(binding?.toolbar) 17 | with(this.supportActionBar) { 18 | this?.setDisplayHomeAsUpEnabled(true) 19 | this?.setDisplayShowHomeEnabled(true) 20 | } 21 | } 22 | 23 | viewModel.item.set(newsItem) 24 | binding?.title = newsItem.headline 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newsdetail/NewsDetailBinder.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newsdetail 2 | 3 | import androidx.lifecycle.LifecycleOwner 4 | import androidx.lifecycle.Observer 5 | import news.ta.com.news.databinding.FragmentNewsDetailsBinding 6 | 7 | class NewsDetailBinder(binding: FragmentNewsDetailsBinding?, 8 | private val router: NewsDetailRouter, 9 | private val viewModel: NewsDetailsViewModel) { 10 | 11 | init { 12 | binding?.viewModel = viewModel 13 | } 14 | 15 | fun bindTo(owner: LifecycleOwner) { 16 | viewModel.clickReadMoreEvent.observe(owner, Observer { router.openWebBrowser(it) }) 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newsdetail/NewsDetailFragment.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newsdetail 2 | 3 | import androidx.databinding.DataBindingUtil 4 | import android.os.Bundle 5 | import androidx.fragment.app.Fragment 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import news.ta.com.news.R 10 | import news.ta.com.news.databinding.FragmentNewsDetailsBinding 11 | import org.koin.android.scope.currentScope 12 | import org.koin.core.context.loadKoinModules 13 | import org.koin.core.context.unloadKoinModules 14 | 15 | class NewsDetailFragment : Fragment() { 16 | private lateinit var binder: NewsDetailBinder 17 | companion object { 18 | fun newInstance(): Fragment = NewsDetailFragment() 19 | } 20 | 21 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 22 | val binding: FragmentNewsDetailsBinding = DataBindingUtil.inflate(inflater, R.layout.fragment_news_details, container, false) 23 | val module = NewsDetailModule().getModule(this.activity!!, binding) 24 | unloadKoinModules(module) 25 | loadKoinModules(module) 26 | binder = currentScope.get() 27 | return binding.root 28 | } 29 | 30 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 31 | binder.bindTo(this) 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newsdetail/NewsDetailModule.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newsdetail 2 | 3 | import androidx.activity.viewModels 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.fragment.app.FragmentActivity 6 | import news.ta.com.news.databinding.ActivityNewsDetailsBinding 7 | import news.ta.com.news.databinding.FragmentNewsDetailsBinding 8 | import org.koin.android.viewmodel.dsl.viewModel 9 | import org.koin.core.qualifier.named 10 | import org.koin.dsl.module 11 | 12 | class NewsDetailModule { 13 | fun getModule(activity: FragmentActivity, binding: FragmentNewsDetailsBinding?) = module { 14 | scope(named()) { 15 | factory { NewsDetailBinder(binding, get(), get()) } 16 | factory { NewsDetailRouterImpl(activity) } 17 | viewModel { 18 | val viewModel: NewsDetailsViewModel by activity.viewModels() 19 | viewModel 20 | } 21 | } 22 | } 23 | 24 | fun getModule(activity: AppCompatActivity, binding: ActivityNewsDetailsBinding?) = module { 25 | scope(named()) { 26 | factory { NewsDetailActivityBinder(activity, binding, get()) } 27 | viewModel { 28 | val viewModel: NewsDetailsViewModel by activity.viewModels() 29 | viewModel 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newsdetail/NewsDetailRouter.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newsdetail 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | 7 | interface NewsDetailRouter { 8 | fun openWebBrowser(url: String?) 9 | } 10 | 11 | class NewsDetailRouterImpl(val context: Context) : NewsDetailRouter { 12 | 13 | override fun openWebBrowser(url: String?) { 14 | val i = Intent(Intent.ACTION_VIEW) 15 | i.data = Uri.parse(url) 16 | context.startActivity(i) 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newsdetail/NewsDetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newsdetail 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.databinding.ObservableField 5 | import android.view.View 6 | import news.ta.com.news.common.livedata.SingleLiveEvent 7 | import news.ta.com.news.common.onValueChange 8 | import news.ta.com.news.feature.newslist.NewsItem 9 | 10 | class NewsDetailsViewModel : ViewModel() { 11 | val item = ObservableField() 12 | val clickReadMoreEvent = SingleLiveEvent() 13 | val onClickReadMore = View.OnClickListener { clickReadMoreEvent.value = item.get()?.link ?: "" } 14 | val selectedEvent = SingleLiveEvent() 15 | val blankContent = ObservableField(true) 16 | 17 | init { 18 | item.onValueChange { selectedEvent.value = true; blankContent.set(false) } 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newslist/NewsListAdapter.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newslist 2 | 3 | import androidx.databinding.DataBindingUtil 4 | import androidx.recyclerview.widget.RecyclerView 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.recyclerview.selection.SelectionTracker 9 | import news.ta.com.news.R 10 | import news.ta.com.news.databinding.ItemNewsBinding 11 | 12 | class NewsListAdapter(val viewModel: NewsListViewModel, var selectionTracker: SelectionTracker? = null) : RecyclerView.Adapter() { 13 | 14 | var items: List = emptyList() 15 | set(value) { 16 | field = value 17 | notifyDataSetChanged() 18 | } 19 | 20 | init { 21 | setHasStableIds(true) 22 | } 23 | 24 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NewsViewHolder { 25 | val view = LayoutInflater.from(parent.context).inflate(R.layout.item_news, parent, false) 26 | return NewsViewHolder(view) 27 | } 28 | 29 | override fun getItemCount(): Int = items.size 30 | 31 | override fun onBindViewHolder(holder: NewsViewHolder, position: Int) { 32 | holder.bind(selectionTracker, items[position]) 33 | } 34 | 35 | override fun getItemId(position: Int): Long = items[position].id.toLong() 36 | 37 | inner class NewsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 38 | val binding: ItemNewsBinding? = itemView.let { DataBindingUtil.bind(it) } 39 | 40 | fun bind(selectionTracker: SelectionTracker?, item: NewsItem) { 41 | binding?.item = item 42 | binding?.listener = View.OnClickListener { 43 | viewModel.itemClickEvent.value = item 44 | } 45 | 46 | binding?.wrapper?.isSelected = selectionTracker?.isSelected(item.id.toLong()) ?: false 47 | viewModel.selectedCount.set(selectionTracker?.selection?.size().toString()) 48 | } 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newslist/NewsListBinder.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newslist 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.lifecycle.LifecycleOwner 5 | import androidx.lifecycle.Observer 6 | import news.ta.com.news.databinding.FragmentNewsListBinding 7 | import news.ta.com.news.feature.newsdetail.NewsDetailsViewModel 8 | 9 | class NewsListBinder(val fragment: Fragment, 10 | val binding: FragmentNewsListBinding, 11 | val viewModel: NewsListViewModel, 12 | private val detailViewModel: NewsDetailsViewModel, 13 | private val router: NewsListRouter) { 14 | 15 | var view: NewsListView 16 | 17 | init { 18 | binding.viewModel = viewModel 19 | view = NewsListViewImpl(fragment, binding) 20 | } 21 | 22 | fun bindTo(owner: LifecycleOwner) { 23 | viewModel.items.observe(owner, Observer { view.setItems(it); viewModel.setStatic(it) }) 24 | viewModel.showDetailMediator.observe(owner, Observer { view.setSelectedItem(it!!.id.toLong()); router.showDetail(detailViewModel, it) }) 25 | viewModel.gotoDetailMediator.observe(owner, Observer { router.gotoDetail(it) }) 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newslist/NewsListFragment.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newslist 2 | 3 | import androidx.databinding.DataBindingUtil 4 | import android.os.Bundle 5 | import androidx.fragment.app.Fragment 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import news.ta.com.news.R 10 | import news.ta.com.news.databinding.FragmentNewsListBinding 11 | import org.koin.android.scope.currentScope 12 | import org.koin.core.context.loadKoinModules 13 | import org.koin.core.context.unloadKoinModules 14 | import org.koin.core.module.Module 15 | 16 | class NewsListFragment : Fragment() { 17 | 18 | private lateinit var binder: NewsListBinder 19 | var module: Module? = null 20 | 21 | companion object { 22 | 23 | val HAS_DETAIL = "news.ta.com.news.feature.newslist.HAS_DETAIL" 24 | 25 | fun newInstance(hasDetail: Boolean): Fragment { 26 | val fragment = NewsListFragment() 27 | val bundle = Bundle() 28 | bundle.putSerializable(HAS_DETAIL, hasDetail) 29 | fragment.arguments = bundle 30 | return fragment 31 | } 32 | } 33 | 34 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { 35 | val binding: FragmentNewsListBinding = DataBindingUtil.inflate(inflater, R.layout.fragment_news_list, container, false) 36 | module = NewsListModule().getModule(this, binding) 37 | module?.let { 38 | unloadKoinModules(it) 39 | loadKoinModules(it) 40 | } 41 | binder = currentScope.get() 42 | return binding.root 43 | } 44 | 45 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 46 | binder.bindTo(this) 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newslist/NewsListModule.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newslist 2 | 3 | import androidx.activity.viewModels 4 | import androidx.fragment.app.Fragment 5 | import news.ta.com.news.databinding.FragmentNewsListBinding 6 | import news.ta.com.news.feature.newsdetail.NewsDetailsViewModel 7 | import org.koin.android.viewmodel.dsl.viewModel 8 | import org.koin.core.module.Module 9 | import org.koin.core.qualifier.named 10 | import org.koin.dsl.module 11 | 12 | class NewsListModule { 13 | fun getModule(fragment: Fragment, binding: FragmentNewsListBinding): Module = module { 14 | scope(named()) { 15 | factory { binding } 16 | factory { NewsListBinder(fragment, binding, get(), get(), get()) } 17 | viewModel { 18 | val viewModel: NewsDetailsViewModel by fragment.activity!!.viewModels() 19 | viewModel 20 | } 21 | viewModel { NewsListViewModel(get()) } 22 | factory { NewsListRouterImpl(fragment.context!!) } 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newslist/NewsListRouter.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newslist 2 | 3 | import android.content.Context 4 | import news.ta.com.news.feature.newsdetail.NewsDetailActivity 5 | import news.ta.com.news.feature.newsdetail.NewsDetailsViewModel 6 | 7 | interface NewsListRouter { 8 | fun showDetail(detailViewModel: NewsDetailsViewModel, item: NewsItem?) 9 | fun gotoDetail(item: NewsItem?) 10 | } 11 | 12 | class NewsListRouterImpl(val context: Context) : NewsListRouter { 13 | override fun showDetail(detailViewModel: NewsDetailsViewModel, item: NewsItem?) { 14 | detailViewModel.item.set(item) 15 | } 16 | 17 | override fun gotoDetail(item: NewsItem?) { 18 | if (item == null) return 19 | NewsDetailActivity.route(context, item) 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newslist/NewsListView.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newslist 2 | 3 | import android.view.MotionEvent 4 | import android.view.MotionEvent.ACTION_UP 5 | import androidx.fragment.app.Fragment 6 | import androidx.core.content.ContextCompat 7 | import androidx.recyclerview.selection.ItemDetailsLookup 8 | import androidx.recyclerview.selection.SelectionTracker 9 | import androidx.recyclerview.selection.StableIdKeyProvider 10 | import androidx.recyclerview.selection.StorageStrategy 11 | import androidx.recyclerview.widget.GridLayoutManager 12 | import androidx.recyclerview.widget.LinearLayoutManager 13 | import androidx.recyclerview.widget.RecyclerView 14 | import com.dgreenhalgh.android.simpleitemdecoration.linear.DividerItemDecoration 15 | import com.dgreenhalgh.android.simpleitemdecoration.linear.EndOffsetItemDecoration 16 | import com.dgreenhalgh.android.simpleitemdecoration.linear.StartOffsetItemDecoration 17 | import news.ta.com.news.R 18 | import news.ta.com.news.databinding.FragmentNewsListBinding 19 | import news.ta.com.news.feature.newslist.NewsListFragment.Companion.HAS_DETAIL 20 | 21 | interface NewsListView { 22 | fun setItems(items: List?) 23 | fun setSelectedItem(id: Long) 24 | } 25 | 26 | class NewsListViewImpl(fragment: Fragment, val binding: FragmentNewsListBinding) : NewsListView { 27 | 28 | init { 29 | var hasDetailView = false 30 | fragment.arguments?.let { 31 | hasDetailView = it.getBoolean(HAS_DETAIL, false) 32 | } 33 | 34 | with(binding.list) { 35 | isNestedScrollingEnabled = false 36 | 37 | val pixelSize = context.resources.getDimensionPixelSize(R.dimen.gap_m) 38 | addItemDecoration(StartOffsetItemDecoration(pixelSize)) 39 | addItemDecoration(EndOffsetItemDecoration(pixelSize)) 40 | 41 | val drawable = ContextCompat.getDrawable(context, R.drawable.decor_m) 42 | addItemDecoration(DividerItemDecoration(drawable)) 43 | setHasFixedSize(false) 44 | 45 | layoutManager = when (hasDetailView) { 46 | true -> GridLayoutManager(context, 2) 47 | else -> LinearLayoutManager(context) 48 | } 49 | 50 | adapter = NewsListAdapter(binding.viewModel!!) 51 | 52 | if (hasDetailView) { 53 | val stableIdKeyProvider = StableIdKeyProvider(this) 54 | val selectionTracker = SelectionTracker.Builder( 55 | "news-selection", 56 | this, 57 | stableIdKeyProvider, 58 | MyItemsLookUp(this, binding.viewModel!!), 59 | StorageStrategy.createLongStorage()) 60 | .build() 61 | 62 | (adapter as NewsListAdapter).selectionTracker = selectionTracker 63 | } 64 | } 65 | 66 | binding.viewModel?.hasViewDetail = hasDetailView 67 | } 68 | 69 | override fun setItems(items: List?) { 70 | (binding.list.adapter as NewsListAdapter).items = items ?: emptyList() 71 | } 72 | 73 | override fun setSelectedItem(id: Long) { 74 | (binding.list.adapter as NewsListAdapter).selectionTracker?.select(id) 75 | } 76 | } 77 | 78 | class MyItemsLookUp(private val recyclerView: RecyclerView, val viewModel: NewsListViewModel) : ItemDetailsLookup() { 79 | override fun getItemDetails(event: MotionEvent): ItemDetails? { 80 | val view = recyclerView.findChildViewUnder(event.x, event.y) 81 | 82 | if (view != null) { 83 | val viewHolder = recyclerView.getChildViewHolder(view) 84 | 85 | if (event.action == ACTION_UP) { 86 | val newsItem = (recyclerView.adapter as NewsListAdapter).items[viewHolder.adapterPosition] 87 | if (viewModel.itemClickEvent.value != newsItem) { 88 | viewModel.itemClickEvent.value = newsItem 89 | } 90 | } 91 | 92 | if (viewHolder is NewsListAdapter.NewsViewHolder) { 93 | return object : ItemDetails() { 94 | override fun getSelectionKey(): Long? = viewHolder.itemId 95 | override fun getPosition(): Int = viewHolder.adapterPosition 96 | } 97 | } 98 | } 99 | 100 | return null 101 | } 102 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newslist/NewsListViewModel.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newslist 2 | 3 | import androidx.annotation.VisibleForTesting 4 | import androidx.databinding.ObservableField 5 | import androidx.lifecycle.LiveData 6 | import androidx.lifecycle.MediatorLiveData 7 | import androidx.lifecycle.ViewModel 8 | import news.ta.com.news.common.livedata.SingleLiveEvent 9 | import news.ta.com.news.feature.NewsApplication 10 | import java.io.Serializable 11 | class NewsItem(val id: Int = 0, 12 | val thumbnail: String = "", 13 | val headline: String = "--", 14 | val description: String = "--", 15 | val link: String = "", 16 | val source: String = "--") : Serializable 17 | 18 | class NewsListViewModel(val repository: NewsRepository) : ViewModel() { 19 | 20 | val itemClickEvent = SingleLiveEvent() 21 | 22 | var hasViewDetail = false 23 | 24 | val items: LiveData> 25 | get() = repository.getNews() 26 | 27 | val showDetailMediator = MediatorLiveData() 28 | val gotoDetailMediator = MediatorLiveData() 29 | 30 | val selectedCount = ObservableField("0") 31 | 32 | init { 33 | showDetailMediator.addSource(itemClickEvent) { if (hasViewDetail) showDetailMediator.value = it } 34 | gotoDetailMediator.addSource(itemClickEvent) { if (!hasViewDetail) gotoDetailMediator.value = it; afterGotoDetail() } 35 | } 36 | 37 | @VisibleForTesting 38 | fun afterGotoDetail() { 39 | gotoDetailMediator.value = null 40 | } 41 | 42 | fun setStatic(list: List?) { 43 | NewsApplication.news = list?.get(0) 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/feature/newslist/NewsRepository.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.feature.newslist 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import news.ta.com.news.model.ArticleDTO 6 | import news.ta.com.news.services.NewsService 7 | import news.ta.com.news.services.enqueueWithProcessing 8 | 9 | interface NewsRepository { 10 | fun getNews(): LiveData> 11 | } 12 | 13 | class NewsRepositoryImpl(val service: NewsService) : NewsRepository { 14 | 15 | var items = MutableLiveData>() 16 | 17 | override fun getNews(): LiveData> { 18 | service.getTopNewsList("us").enqueueWithProcessing( 19 | preProcessing = { 20 | it?.articles.convertToNewsItem() 21 | }, 22 | success = { 23 | items.value = it 24 | }, 25 | fail = { _, _ -> } 26 | ) 27 | 28 | return items 29 | } 30 | } 31 | 32 | fun List?.convertToNewsItem(): List { 33 | if (this == null) return emptyList() 34 | return this.asSequence().map { item -> 35 | NewsItem(id = item.hashCode(), 36 | thumbnail = item.urlToImage ?: "", 37 | headline = item.title ?: "--", 38 | description = item.description ?: "--", 39 | link = item.url ?: "", 40 | source = item.source?.name ?: "--") 41 | }.toList() 42 | } 43 | 44 | -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/model/ArticleDTO.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.model 2 | 3 | import com.google.gson.annotations.Expose 4 | import com.google.gson.annotations.SerializedName 5 | 6 | class ArticleDTO { 7 | 8 | @SerializedName("source") 9 | @Expose 10 | var source: SourceDTO? = null 11 | @SerializedName("author") 12 | @Expose 13 | var author: String? = null 14 | @SerializedName("title") 15 | @Expose 16 | var title: String? = null 17 | @SerializedName("description") 18 | @Expose 19 | var description: String? = null 20 | @SerializedName("url") 21 | @Expose 22 | var url: String? = null 23 | @SerializedName("urlToImage") 24 | @Expose 25 | var urlToImage: String? = null 26 | @SerializedName("publishedAt") 27 | @Expose 28 | var publishedAt: String? = null 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/model/NewsDTO.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.model 2 | 3 | import com.google.gson.annotations.Expose 4 | import com.google.gson.annotations.SerializedName 5 | 6 | class NewsDTO { 7 | 8 | @SerializedName("status") 9 | @Expose 10 | var status: String? = null 11 | @SerializedName("totalResults") 12 | @Expose 13 | var totalResults: Int? = null 14 | @SerializedName("articles") 15 | @Expose 16 | var articles: List? = null 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/model/SourceDTO.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.model 2 | 3 | import com.google.gson.annotations.Expose 4 | import com.google.gson.annotations.SerializedName 5 | 6 | class SourceDTO { 7 | 8 | @SerializedName("id") 9 | @Expose 10 | var id: Any? = null 11 | @SerializedName("name") 12 | @Expose 13 | var name: String? = null 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/services/DataTransferCallback.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.services 2 | 3 | import com.google.gson.FieldNamingPolicy 4 | import com.google.gson.Gson 5 | import com.google.gson.GsonBuilder 6 | import com.google.gson.JsonSyntaxException 7 | import com.google.gson.annotations.SerializedName 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Deferred 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.async 12 | import kotlinx.coroutines.launch 13 | import news.ta.com.news.services.DataTransferCallback.Companion.ioScope 14 | import news.ta.com.news.services.DataTransferCallback.Companion.mainScope 15 | import okhttp3.Headers 16 | import org.koin.core.KoinComponent 17 | import retrofit2.Call 18 | import retrofit2.Callback 19 | import retrofit2.Response 20 | import java.io.IOException 21 | import java.io.InterruptedIOException 22 | import java.lang.Exception 23 | import java.net.SocketTimeoutException 24 | import java.net.UnknownHostException 25 | 26 | data class ErrorBody( 27 | @SerializedName("error") 28 | val error: String 29 | ) 30 | 31 | data class ErrorItem( 32 | @SerializedName("code") 33 | val code: String, 34 | @SerializedName("text") 35 | val message: String 36 | ) 37 | 38 | class DataTransferCallback( 39 | private val success: (T?) -> Unit, 40 | private val headers: ((Headers?) -> Unit)? = null, 41 | private val fail: ((ResponseType, Throwable?) -> Unit)? 42 | ) : Callback, KoinComponent { 43 | 44 | companion object { 45 | val ioScope = CoroutineScope(Dispatchers.IO) 46 | val mainScope = CoroutineScope(Dispatchers.Main) 47 | } 48 | 49 | private val gson: Gson = GsonBuilder() 50 | .setFieldNamingPolicy(FieldNamingPolicy.IDENTITY) 51 | .setDateFormat("dd-MM-yyyy") 52 | .create() 53 | 54 | override fun onResponse(call: Call?, response: Response?) { 55 | headers?.invoke(response?.headers()) 56 | 57 | when (response?.code()) { 58 | in 200..399 -> success.invoke(response?.body()) 59 | 401 -> { 60 | val errorBody = getErrorBody(response) 61 | val throwable = getThrowable(errorBody) 62 | fail?.invoke(ResponseType.UNAUTHORIZED, throwable) 63 | } 64 | 503 -> fail?.invoke(ResponseType.EMPTY, null) 65 | 502, 504 -> fail?.invoke(ResponseType.TIMEOUT, null) 66 | 404 -> { 67 | val errorBody = getErrorBody(response) 68 | val throwable = getThrowable(errorBody) 69 | fail?.invoke(ResponseType.NOT_FOUND, throwable) 70 | } 71 | else -> { 72 | handleError(response) 73 | fail?.invoke(ResponseType.GENERAL_ERROR, null) 74 | } 75 | } 76 | } 77 | 78 | private fun handleError(response: Response?) { 79 | try { 80 | val errorBody = getErrorBody(response) 81 | val throwable = getThrowable(errorBody) 82 | fail?.invoke(ResponseType.GENERAL_ERROR, throwable) 83 | } catch (e: JsonSyntaxException) { 84 | e.printStackTrace() 85 | fail?.invoke(ResponseType.NOT_FOUND, null) 86 | } 87 | } 88 | 89 | private fun getErrorBody(response: Response?): ErrorBody? { 90 | return try { 91 | val errorResponse = response?.errorBody()?.string() 92 | gson.fromJson(errorResponse, ErrorBody::class.java) 93 | } catch (e: Exception) { 94 | null 95 | } 96 | } 97 | 98 | private fun getThrowable(errorBody: ErrorBody?): Throwable? { 99 | val message = errorBody?.error 100 | return Throwable(message) 101 | } 102 | 103 | override fun onFailure(call: Call?, throwable: Throwable?) { 104 | if (throwable == null) { 105 | fail?.invoke(ResponseType.GENERAL_ERROR, throwable) 106 | return 107 | } 108 | when (throwable::class.java) { 109 | UnknownHostException::class.java -> fail?.invoke(ResponseType.NO_INTERNET, throwable) 110 | IOException::class.java -> fail?.invoke(ResponseType.NO_INTERNET, throwable) 111 | InterruptedIOException::class.java -> fail?.invoke(ResponseType.NO_INTERNET, throwable) 112 | SocketTimeoutException::class.java -> fail?.invoke(ResponseType.TIMEOUT, throwable) 113 | else -> fail?.invoke(ResponseType.GENERAL_ERROR, throwable) 114 | } 115 | } 116 | } 117 | 118 | fun Call.processEnqueue( 119 | success: (T?) -> Unit, 120 | headers: ((Headers?) -> Unit)? = null, 121 | fail: ((ResponseType, Throwable?) -> Unit)? = null 122 | ) { 123 | try { 124 | enqueue(DataTransferCallback(success, headers, fail)) 125 | } catch (e: IOException) { 126 | } 127 | } 128 | 129 | fun Call.enqueueNow() { 130 | this.enqueueWithProcessing({}, {}) 131 | } 132 | 133 | fun Call.enqueueWithProcessing( 134 | preProcessing: (T?) -> O, 135 | success: (O?) -> Unit, 136 | headers: ((Headers?) -> Unit)? = null, 137 | fail: ((ResponseType, Throwable?) -> Unit)? = null 138 | ) { 139 | 140 | fun backgroundProcessingAsync(obj: T?): Deferred { 141 | return ioScope.async { 142 | return@async preProcessing(obj) 143 | } 144 | } 145 | 146 | val wrappedSuccess: (T?) -> Unit = { 147 | mainScope.launch { 148 | val obj = backgroundProcessingAsync(it).await() 149 | success.invoke(obj) 150 | } 151 | } 152 | 153 | processEnqueue(wrappedSuccess, headers, fail) 154 | } 155 | -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/services/NewsService.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.services 2 | 3 | import news.ta.com.news.model.NewsDTO 4 | import retrofit2.Call 5 | import retrofit2.http.GET 6 | import retrofit2.http.Query 7 | 8 | interface NewsService { 9 | @GET("v2/top-headlines") 10 | fun getTopNewsList(@Query("country") country: String? = "th"): Call 11 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/news/ta/com/news/services/ResponseType.kt: -------------------------------------------------------------------------------- 1 | package news.ta.com.news.services 2 | 3 | enum class ResponseType { 4 | SUCCESS, EMPTY, NO_INTERNET, GENERAL_ERROR, UNAUTHORIZED, TIMEOUT, NOT_FOUND 5 | } 6 | -------------------------------------------------------------------------------- /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-xxhdpi/ic_divider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theerasan/AndroidArchitecture/2d046a8f0ac142e1a220686b2bea50edbf2deeb4/app/src/main/res/drawable-xxhdpi/ic_divider.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_globe_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theerasan/AndroidArchitecture/2d046a8f0ac142e1a220686b2bea50edbf2deeb4/app/src/main/res/drawable-xxhdpi/ic_globe_16.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theerasan/AndroidArchitecture/2d046a8f0ac142e1a220686b2bea50edbf2deeb4/app/src/main/res/drawable-xxhdpi/ic_home.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_image_default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theerasan/AndroidArchitecture/2d046a8f0ac142e1a220686b2bea50edbf2deeb4/app/src/main/res/drawable-xxhdpi/ic_image_default.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_news.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theerasan/AndroidArchitecture/2d046a8f0ac142e1a220686b2bea50edbf2deeb4/app/src/main/res/drawable-xxhdpi/ic_news.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theerasan/AndroidArchitecture/2d046a8f0ac142e1a220686b2bea50edbf2deeb4/app/src/main/res/drawable-xxhdpi/ic_up.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/bg_selection.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/decor_m.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/headline_bg.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | 16 | 21 | 26 | 31 | 36 | 41 | 46 | 51 | 56 | 61 | 66 | 71 | 76 | 81 | 86 | 91 | 96 | 101 | 106 | 111 | 116 | 121 | 126 | 131 | 136 | 141 | 146 | 151 | 156 | 161 | 166 | 171 | 172 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/title_bg.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/font/averia_serif_libre_light.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/layout-land/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 11 | 12 | 17 | 18 | 21 | 22 | 29 | 30 | 31 | 32 | 37 | 38 | 39 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /app/src/main/res/layout-land/fragment_news_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | 16 | 17 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/layout-land/item_news.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 11 | 12 | 15 | 16 | 17 | 29 | 30 | 39 | 40 | 44 | 45 | 54 | 55 | 63 | 64 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 9 | 10 | 13 | 14 | 21 | 22 | 23 | 24 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_news_details.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 10 | 11 | 12 | 15 | 16 | 19 | 20 | 28 | 29 | 30 | 31 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_news_details.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 16 | 17 | 22 | 23 | 28 | 29 | 32 | 33 | 40 | 41 | 42 | 56 | 57 | 58 | 59 | 69 | 70 | 77 | 78 | 85 | 86 |