├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── 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 │ │ │ ├── anim │ │ │ │ ├── slide_in_right.xml │ │ │ │ ├── slide_out_left.xml │ │ │ │ ├── fade_in.xml │ │ │ │ └── fade_out.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values │ │ │ │ ├── dimens.xml │ │ │ │ ├── strings.xml │ │ │ │ ├── styles.xml │ │ │ │ └── colors.xml │ │ │ ├── layout │ │ │ │ ├── activity_quakes.xml │ │ │ │ ├── fragment_quakes.xml │ │ │ │ └── quake_item.xml │ │ │ ├── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ │ └── drawable │ │ │ │ └── ic_launcher_background.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── inspiringteam │ │ │ │ └── xchange │ │ │ │ ├── BaseView.kt │ │ │ │ ├── util │ │ │ │ ├── Constants.kt │ │ │ │ ├── ConnectivityUtils │ │ │ │ │ ├── OnlineChecker.kt │ │ │ │ │ └── DefaultOnlineChecker.kt │ │ │ │ ├── schedulers │ │ │ │ │ ├── BaseSchedulerProvider.kt │ │ │ │ │ ├── ImmediateSchedulerProvider.kt │ │ │ │ │ └── SchedulerProvider.kt │ │ │ │ ├── DisplayUtils │ │ │ │ │ ├── SortUtils.java │ │ │ │ │ ├── GravityUtils.kt │ │ │ │ │ └── TimeUtils.kt │ │ │ │ ├── chromeTabsUtils │ │ │ │ │ ├── ServiceConnectionCallback.kt │ │ │ │ │ ├── ServiceConnection.kt │ │ │ │ │ └── ChromeTabsWrapper.kt │ │ │ │ ├── providers │ │ │ │ │ ├── ResourceProvider.kt │ │ │ │ │ └── BaseResourceProvider.kt │ │ │ │ └── ActivityUtils.kt │ │ │ │ ├── data │ │ │ │ ├── models │ │ │ │ │ ├── QuakeWrapper.kt │ │ │ │ │ ├── QuakesResponse.kt │ │ │ │ │ └── Quake.kt │ │ │ │ └── source │ │ │ │ │ ├── remote │ │ │ │ │ ├── QuakesApiService.kt │ │ │ │ │ ├── QuakesRemoteDataModule.kt │ │ │ │ │ └── QuakesRemoteDataSource.kt │ │ │ │ │ ├── scopes │ │ │ │ │ ├── Local.kt │ │ │ │ │ └── Remote.kt │ │ │ │ │ ├── QuakesDataSource.kt │ │ │ │ │ ├── local │ │ │ │ │ ├── QuakesDatabase.kt │ │ │ │ │ ├── QuakesLocalDataModule.kt │ │ │ │ │ ├── QuakesDao.kt │ │ │ │ │ └── QuakesLocalDataSource.kt │ │ │ │ │ ├── QuakesRepositoryModule.kt │ │ │ │ │ └── QuakesRepository.kt │ │ │ │ ├── ui │ │ │ │ └── quakes │ │ │ │ │ ├── QuakesUiModel.kt │ │ │ │ │ ├── NoQuakesModel.kt │ │ │ │ │ ├── QuakeItem.kt │ │ │ │ │ ├── QuakesActivity.kt │ │ │ │ │ ├── QuakesModule.kt │ │ │ │ │ ├── QuakesAdapter.kt │ │ │ │ │ ├── QuakeItemViewHolder.kt │ │ │ │ │ ├── QuakesViewModel.kt │ │ │ │ │ └── QuakesFragment.kt │ │ │ │ ├── di │ │ │ │ ├── scopes │ │ │ │ │ ├── AppScoped.kt │ │ │ │ │ ├── FragmentScoped.kt │ │ │ │ │ └── ActivityScoped.kt │ │ │ │ ├── ViewModelKey.kt │ │ │ │ ├── AppModule.kt │ │ │ │ ├── ViewModelModule.kt │ │ │ │ ├── ActivityBindingModule.kt │ │ │ │ ├── quakes │ │ │ │ │ └── QuakesViewModelFactory.kt │ │ │ │ ├── AppComponent.kt │ │ │ │ └── UtilsModule.kt │ │ │ │ ├── App.kt │ │ │ │ └── ScrollChildSwipeRefreshLayout.kt │ │ └── AndroidManifest.xml │ ├── androidTest │ │ └── java │ │ │ └── com │ │ │ └── inspiringteam │ │ │ └── xchange │ │ │ ├── quakes │ │ │ └── QuakesScreenTest.kt │ │ │ └── data │ │ │ └── source │ │ │ └── local │ │ │ ├── QuakesDaoTest.kt │ │ │ └── QuakesLocalDataSourceTest.kt │ └── test │ │ └── java │ │ └── com │ │ └── inspiringteam │ │ └── xchange │ │ ├── data │ │ └── source │ │ │ ├── remote │ │ │ └── QuakesRemoteDataSourceTest.kt │ │ │ └── QuakesRepositoryTest.kt │ │ └── quakes │ │ └── QuakesViewModelTest.java ├── proguard-rules.pro └── build.gradle ├── settings.gradle ├── readme_pics ├── open_tab.gif ├── scrolling.gif ├── forcing_update.gif ├── mvvm_diagram.png └── mvvm_dagger_dependency.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .idea ├── caches │ └── build_file_checksums.ser ├── vcs.xml ├── misc.xml ├── runConfigurations.xml ├── gradle.xml ├── jarRepositories.xml └── codeStyles │ └── Project.xml ├── .gitignore ├── gradle.properties ├── gradlew.bat ├── gradlew └── README.md /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | -------------------------------------------------------------------------------- /readme_pics/open_tab.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/readme_pics/open_tab.gif -------------------------------------------------------------------------------- /readme_pics/scrolling.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/readme_pics/scrolling.gif -------------------------------------------------------------------------------- /readme_pics/forcing_update.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/readme_pics/forcing_update.gif -------------------------------------------------------------------------------- /readme_pics/mvvm_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/readme_pics/mvvm_diagram.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.idea/caches/build_file_checksums.ser: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/.idea/caches/build_file_checksums.ser -------------------------------------------------------------------------------- /readme_pics/mvvm_dagger_dependency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/readme_pics/mvvm_dagger_dependency.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/BaseView.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange 2 | 3 | interface BaseView { 4 | fun bindViewModel() 5 | fun unbindViewModel() 6 | } -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinghita8/android-mvvm-rxjava2-dagger2/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/libraries 5 | /.idea/modules.xml 6 | /.idea/workspace.xml 7 | .DS_Store 8 | /build 9 | /captures 10 | .externalNativeBuild 11 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/util/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util 2 | 3 | object Constants { 4 | const val QUAKES_API_BASE_URL = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/" 5 | const val QUAKES_ROOM_DB_STRING = "Quakes.db" 6 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/util/ConnectivityUtils/OnlineChecker.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util.ConnectivityUtils 2 | 3 | /** 4 | * Simple interface that contains online/offline state indicator 5 | */ 6 | interface OnlineChecker { 7 | fun isOnline(): Boolean 8 | } -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_in_right.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_out_left.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Mar 09 17:11:34 EET 2021 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-6.7.1-all.zip 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/models/QuakeWrapper.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.models 2 | 3 | import com.google.gson.annotations.Expose 4 | import com.google.gson.annotations.SerializedName 5 | 6 | class QuakeWrapper(@field:Expose @field:SerializedName("properties") var quake: Quake) -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/ui/quakes/QuakesUiModel.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.ui.quakes 2 | 3 | class QuakesUiModel( 4 | val isQuakesListVisible: Boolean, 5 | val quakes: List, 6 | val isNoQuakesViewVisible: Boolean, 7 | val noQuakesModel: NoQuakesModel? 8 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/ui/quakes/NoQuakesModel.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.ui.quakes 2 | 3 | import androidx.annotation.StringRes 4 | 5 | /** 6 | * The string that should be displayed when there are no quakes. 7 | */ 8 | class NoQuakesModel(@field:StringRes @get:StringRes var text: Int) -------------------------------------------------------------------------------- /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/anim/fade_in.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/anim/fade_out.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/di/scopes/AppScoped.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.di.scopes 2 | 3 | import javax.inject.Scope 4 | 5 | /** 6 | * Replacement scope for @Singleton to improve readability 7 | */ 8 | @MustBeDocumented 9 | @Scope 10 | @kotlin.annotation.Retention(AnnotationRetention.RUNTIME) 11 | annotation class AppScoped -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/models/QuakesResponse.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.models 2 | 3 | import com.google.gson.annotations.Expose 4 | import com.google.gson.annotations.SerializedName 5 | import java.util.* 6 | 7 | data class QuakesResponse(@field:Expose @field:SerializedName("features") var quakeWrapperList: MutableList) -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16dp 5 | 4dp 6 | 7 | 4dp 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/source/remote/QuakesApiService.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source.remote 2 | 3 | import retrofit2.http.GET 4 | import com.inspiringteam.xchange.data.models.QuakesResponse 5 | import io.reactivex.Single 6 | 7 | interface QuakesApiService { 8 | @get:GET("1.0_day.geojson") 9 | val quakes: Single 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/util/schedulers/BaseSchedulerProvider.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util.schedulers 2 | 3 | import io.reactivex.Scheduler 4 | 5 | /** 6 | * Allow providing different types of [Scheduler]s. 7 | */ 8 | interface BaseSchedulerProvider { 9 | fun computation(): Scheduler 10 | fun io(): Scheduler 11 | fun ui(): Scheduler 12 | } -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/source/scopes/Local.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source.scopes 2 | 3 | import javax.inject.Qualifier 4 | 5 | /** 6 | * This scope has been created for Dagger to differentiate between types of Data Sources 7 | */ 8 | @Qualifier 9 | @MustBeDocumented 10 | @kotlin.annotation.Retention(AnnotationRetention.RUNTIME) 11 | annotation class Local -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/source/scopes/Remote.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source.scopes 2 | 3 | import javax.inject.Qualifier 4 | 5 | /** 6 | * This scope has been created for Dagger to differentiate between types of Data Sources 7 | */ 8 | @Qualifier 9 | @MustBeDocumented 10 | @kotlin.annotation.Retention(AnnotationRetention.RUNTIME) 11 | annotation class Remote -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | xChange 3 | Hello fragment 4 | Error while loading quakes 5 | The Quakes were lost! Our best detectives are working on it.. 6 | Risk: 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/di/scopes/FragmentScoped.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.di.scopes 2 | 3 | import javax.inject.Scope 4 | 5 | @Scope 6 | @kotlin.annotation.Retention(AnnotationRetention.RUNTIME) 7 | @Target( 8 | AnnotationTarget.ANNOTATION_CLASS, 9 | AnnotationTarget.CLASS, 10 | AnnotationTarget.FUNCTION, 11 | AnnotationTarget.PROPERTY_GETTER, 12 | AnnotationTarget.PROPERTY_SETTER 13 | ) 14 | annotation class FragmentScoped -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/source/QuakesDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source 2 | 3 | import com.inspiringteam.xchange.data.models.Quake 4 | import io.reactivex.Single 5 | 6 | interface QuakesDataSource { 7 | fun getQuakes(): Single> 8 | fun getQuake(quakeId: String): Single 9 | fun saveQuakes(quakes: List) 10 | fun saveQuake(quake: Quake) 11 | fun deleteAllQuakes() 12 | fun deleteQuake(quakeId: String) 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/source/local/QuakesDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source.local 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import com.inspiringteam.xchange.data.models.Quake 6 | 7 | /** 8 | * The Room Database that contains the Quakes table. 9 | */ 10 | @Database(entities = [Quake::class], version = 2, exportSchema = false) 11 | abstract class QuakesDatabase : RoomDatabase() { 12 | abstract fun quakesDao(): QuakesDao 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/di/ViewModelKey.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.di 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.MapKey 5 | import kotlin.reflect.KClass 6 | 7 | @MustBeDocumented 8 | @Target( 9 | AnnotationTarget.FUNCTION, 10 | AnnotationTarget.PROPERTY_GETTER, 11 | AnnotationTarget.PROPERTY_SETTER 12 | ) 13 | @kotlin.annotation.Retention(AnnotationRetention.RUNTIME) 14 | @MapKey 15 | internal annotation class ViewModelKey(val value: KClass) -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/util/ConnectivityUtils/DefaultOnlineChecker.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util.ConnectivityUtils 2 | 3 | import android.net.ConnectivityManager 4 | 5 | /** 6 | * Custom OnlineChecker 7 | */ 8 | class DefaultOnlineChecker(private val connectivityManager: ConnectivityManager) : OnlineChecker { 9 | override fun isOnline(): Boolean { 10 | val netInfo = connectivityManager.activeNetworkInfo 11 | return netInfo != null && netInfo.isConnectedOrConnecting 12 | } 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/ui/quakes/QuakeItem.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.ui.quakes 2 | 3 | import com.inspiringteam.xchange.data.models.Quake 4 | import rx.functions.Action0 5 | 6 | /** 7 | * A quake that should be displayed as an item in a list of quake. 8 | * Contains the quake and the action that should be triggered when taping on the item a 9 | */ 10 | class QuakeItem( 11 | val quake: Quake, 12 | /** 13 | * @return the action to be triggered on click events 14 | */ 15 | val onClickAction: Action0 16 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/util/DisplayUtils/SortUtils.java: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util.DisplayUtils; 2 | 3 | import com.inspiringteam.xchange.data.models.Quake; 4 | 5 | import java.util.Collections; 6 | import java.util.List; 7 | 8 | public final class SortUtils { 9 | 10 | 11 | public static List sortByNewest(List list) { 12 | Collections.sort(list, (e1, e2) -> e1.getTimeStamp().compareTo(e2.getTimeStamp())); 13 | Collections.reverse(list); 14 | 15 | return list; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/util/chromeTabsUtils/ServiceConnectionCallback.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util.chromeTabsUtils 2 | 3 | import androidx.browser.customtabs.CustomTabsClient 4 | 5 | interface ServiceConnectionCallback { 6 | /** 7 | * Called when the service is connected. 8 | * 9 | * @param client a CustomTabsClient 10 | */ 11 | fun onServiceConnected(client: CustomTabsClient) 12 | 13 | /** 14 | * Called when the service is disconnected. 15 | */ 16 | fun onServiceDisconnected() 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/di/scopes/ActivityScoped.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.di.scopes 2 | 3 | import javax.inject.Scope 4 | 5 | /** 6 | * In Dagger, an unscoped component cannot depend on a scoped component. As 7 | * [AppComponent] is a scoped component @AppScoped, we create a custom 8 | * scope to be used by all fragment components. Additionally, a component with a specific scope 9 | * cannot have a sub component with the same scope. 10 | */ 11 | @MustBeDocumented 12 | @Scope 13 | @kotlin.annotation.Retention(AnnotationRetention.RUNTIME) 14 | annotation class ActivityScoped -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_quakes.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/util/schedulers/ImmediateSchedulerProvider.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util.schedulers 2 | 3 | import io.reactivex.Scheduler 4 | import io.reactivex.schedulers.Schedulers 5 | 6 | /** 7 | * Implementation of the [BaseSchedulerProvider] making all [Scheduler]s immediate. 8 | */ 9 | class ImmediateSchedulerProvider : BaseSchedulerProvider { 10 | override fun computation(): Scheduler { 11 | return Schedulers.trampoline() 12 | } 13 | 14 | override fun io(): Scheduler { 15 | return Schedulers.trampoline() 16 | } 17 | 18 | override fun ui(): Scheduler { 19 | return Schedulers.trampoline() 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/App.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange 2 | 3 | import com.inspiringteam.xchange.di.DaggerAppComponent 4 | import dagger.android.AndroidInjector 5 | import dagger.android.DaggerApplication 6 | 7 | /** 8 | * We create a custom Application class that extends [DaggerApplication]. 9 | * We then override applicationInjector() which tells Dagger how to make our @AppScoped Component 10 | * We never have to call `component.inject(this)` as [DaggerApplication] will do that for us. 11 | */ 12 | class App : DaggerApplication() { 13 | override fun applicationInjector(): AndroidInjector { 14 | return DaggerAppComponent.builder().application(this).build() 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.di 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import dagger.Binds 6 | import dagger.Module 7 | 8 | /** 9 | * This is the app's Dagger module. We use this to bind our Application class as a Context in the AppComponent. 10 | * By using Dagger Android we do not need to pass our Application instance to any module, 11 | * we simply need to expose our Application as Context. 12 | * through Dagger.Android our Application & Activities are provided into your graph for us. 13 | */ 14 | @Module 15 | abstract class AppModule { 16 | // Expose Application as an injectable context 17 | @Binds 18 | abstract fun bindContext(application: Application?): Context? 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/util/providers/ResourceProvider.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util.providers 2 | 3 | import android.content.Context 4 | import androidx.annotation.StringRes 5 | import com.google.common.base.Preconditions 6 | 7 | /** 8 | * Concrete implementation of the [BaseResourceProvider] interface. 9 | */ 10 | class ResourceProvider(context: Context) : BaseResourceProvider { 11 | private val mContext: Context = Preconditions.checkNotNull(context, "context cannot be null") 12 | 13 | override fun getString(@StringRes id: Int): String { 14 | return mContext.getString(id) 15 | } 16 | 17 | override fun getString(@StringRes id: Int, vararg formatArgs: Any?): String { 18 | return mContext.getString(id, *formatArgs) 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/di/ViewModelModule.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.di 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.inspiringteam.xchange.di.quakes.QuakesViewModelFactory 6 | import com.inspiringteam.xchange.di.scopes.AppScoped 7 | import com.inspiringteam.xchange.ui.quakes.QuakesViewModel 8 | import dagger.Binds 9 | import dagger.Module 10 | import dagger.multibindings.IntoMap 11 | 12 | @Module 13 | abstract class ViewModelModule { 14 | @Binds 15 | @IntoMap 16 | @ViewModelKey(QuakesViewModel::class) 17 | abstract fun bindQuakesViewModel(quakesViewModel: QuakesViewModel?): ViewModel? 18 | @Binds 19 | @AppScoped 20 | abstract fun bindViewModelFactory(factory: QuakesViewModelFactory?): ViewModelProvider.Factory? 21 | } -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /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 | android.enableJetifier=true 10 | android.useAndroidX=true 11 | org.gradle.jvmargs=-Xmx1536m 12 | # When configured, Gradle will run in incubating parallel mode. 13 | # This option should only be used with decoupled projects. More details, visit 14 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 15 | # org.gradle.parallel=true 16 | -------------------------------------------------------------------------------- /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/main/java/com/inspiringteam/xchange/util/providers/BaseResourceProvider.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util.providers 2 | 3 | import androidx.annotation.StringRes 4 | 5 | /** 6 | * Resolves application's resources. 7 | */ 8 | interface BaseResourceProvider { 9 | /** 10 | * Resolves text's id to String. 11 | * 12 | * @param id to be fetched from the resources 13 | * @return String representation of the {@param id} 14 | */ 15 | fun getString(@StringRes id: Int): String 16 | 17 | /** 18 | * Resolves text's id to String and formats it. 19 | * 20 | * @param resId to be fetched from the resources 21 | * @param formatArgs format arguments 22 | * @return String representation of the {@param resId} 23 | */ 24 | fun getString(@StringRes resId: Int, vararg formatArgs: Any?): String 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/util/ActivityUtils.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.fragment.app.FragmentManager 5 | import androidx.fragment.app.FragmentTransaction 6 | 7 | import com.google.common.base.Preconditions.checkNotNull 8 | 9 | object ActivityUtils { 10 | /** 11 | * The fragment is added to the container view with id frameId. The operation is 12 | * performed by the fragmentManager. 13 | */ 14 | fun addFragmentToActivity(fragmentManager: FragmentManager, 15 | fragment: Fragment, frameId: Int) { 16 | checkNotNull(fragmentManager) 17 | checkNotNull(fragment) 18 | val transaction = fragmentManager.beginTransaction() 19 | transaction.add(frameId, fragment) 20 | transaction.commit() 21 | } 22 | } 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/source/local/QuakesLocalDataModule.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source.local 2 | 3 | import android.app.Application 4 | import androidx.room.Room 5 | import com.inspiringteam.xchange.di.scopes.AppScoped 6 | import com.inspiringteam.xchange.util.Constants 7 | import dagger.Module 8 | import dagger.Provides 9 | 10 | @Module 11 | class QuakesLocalDataModule { 12 | @AppScoped 13 | @Provides 14 | fun provideDb(context: Application): QuakesDatabase { 15 | return Room.databaseBuilder( 16 | context.applicationContext, 17 | QuakesDatabase::class.java, 18 | Constants.QUAKES_ROOM_DB_STRING 19 | ) 20 | .fallbackToDestructiveMigration() 21 | .build() 22 | } 23 | 24 | @AppScoped 25 | @Provides 26 | fun provideQuakesDao(db: QuakesDatabase): QuakesDao { 27 | return db.quakesDao() 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/ui/quakes/QuakesActivity.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.ui.quakes 2 | 3 | import android.os.Bundle 4 | import com.inspiringteam.xchange.R 5 | import com.inspiringteam.xchange.util.ActivityUtils.addFragmentToActivity 6 | import dagger.android.support.DaggerAppCompatActivity 7 | import javax.inject.Inject 8 | 9 | class QuakesActivity : DaggerAppCompatActivity() { 10 | @JvmField 11 | @Inject 12 | var injectedFragment: QuakesFragment? = null 13 | 14 | override fun onCreate(savedInstanceState: Bundle?) { 15 | super.onCreate(savedInstanceState) 16 | setContentView(R.layout.activity_quakes) 17 | 18 | // Set up fragment 19 | var fragment = supportFragmentManager.findFragmentById(R.id.contentFrame) as QuakesFragment? 20 | if (fragment == null) { 21 | fragment = injectedFragment 22 | addFragmentToActivity(supportFragmentManager, fragment!!, R.id.contentFrame) 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/ui/quakes/QuakesModule.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.ui.quakes 2 | 3 | import com.inspiringteam.xchange.di.scopes.ActivityScoped 4 | import com.inspiringteam.xchange.di.scopes.FragmentScoped 5 | import com.inspiringteam.xchange.ui.quakes.QuakesModule.QuakesAbstractModule 6 | import com.inspiringteam.xchange.util.providers.BaseResourceProvider 7 | import com.inspiringteam.xchange.util.providers.ResourceProvider 8 | import dagger.Module 9 | import dagger.Provides 10 | import dagger.android.ContributesAndroidInjector 11 | 12 | @Module(includes = [QuakesAbstractModule::class]) 13 | class QuakesModule { 14 | @ActivityScoped 15 | @Provides 16 | fun provideResourceProvider(context: QuakesActivity?): BaseResourceProvider { 17 | return ResourceProvider(context!!) 18 | } 19 | 20 | @Module 21 | interface QuakesAbstractModule { 22 | @FragmentScoped 23 | @ContributesAndroidInjector 24 | fun quakesFragment(): QuakesFragment? 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/util/DisplayUtils/GravityUtils.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util.DisplayUtils 2 | 3 | import java.util.* 4 | 5 | object GravityUtils { 6 | private val gravityStrings: MutableList = 7 | Arrays.asList(" Very low", " Low", " Medium", " High", " Very High") 8 | 9 | fun toGravityString(g: Int): String { 10 | var index: Int = 0 11 | if (0 < g && g < 151) index = 0 else if (g < 251) index = 1 else if (g < 451) index = 12 | 2 else if (g < 751) index = 3 else if (g < 1001) index = 4 13 | return gravityStrings.get(index) 14 | } 15 | 16 | fun toMagnitudeColor(m: Double): Int { 17 | var index: Int = 0 18 | if (0 < m && m < 1.2) index = 0 else if (m < 1.5) index = 1 else if (m < 2) index = 19 | 2 else if (m < 2.5) index = 3 else if (m < 3) index = 4 else if (m < 4) index = 20 | 5 else if (m < 4.5) index = 6 else if (m < 5) index = 7 else if (m < 5.5) index = 21 | 8 else if (m < 6) index = 9 else if (m < 6.5) index = 10 else if (m < 100) index = 11 22 | return index 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/util/schedulers/SchedulerProvider.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util.schedulers 2 | 3 | import io.reactivex.Scheduler 4 | import io.reactivex.android.schedulers.AndroidSchedulers 5 | import io.reactivex.schedulers.Schedulers 6 | 7 | /** 8 | * Provides different types of schedulers. 9 | */ 10 | class SchedulerProvider // Prevent direct instantiation. 11 | private constructor() : BaseSchedulerProvider { 12 | override fun computation(): Scheduler { 13 | return Schedulers.computation() 14 | } 15 | 16 | override fun io(): Scheduler { 17 | return Schedulers.io() 18 | } 19 | 20 | override fun ui(): Scheduler { 21 | return AndroidSchedulers.mainThread() 22 | } 23 | 24 | companion object { 25 | private var INSTANCE: SchedulerProvider? = null 26 | 27 | @get:Synchronized 28 | val instance: SchedulerProvider 29 | get() { 30 | if (INSTANCE == null) { 31 | INSTANCE = SchedulerProvider() 32 | } 33 | return INSTANCE!! 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/util/chromeTabsUtils/ServiceConnection.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util.chromeTabsUtils 2 | 3 | import android.content.ComponentName 4 | import androidx.browser.customtabs.CustomTabsClient 5 | import androidx.browser.customtabs.CustomTabsServiceConnection 6 | import java.lang.ref.WeakReference 7 | 8 | class ServiceConnection(connectionCallback: ServiceConnectionCallback) : 9 | CustomTabsServiceConnection() { 10 | // A weak reference to the ServiceConnectionCallback to avoid leaking it. 11 | private val mConnectionCallback: WeakReference 12 | 13 | override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) { 14 | val connectionCallback = mConnectionCallback.get() 15 | connectionCallback?.onServiceConnected(client) 16 | } 17 | 18 | override fun onServiceDisconnected(name: ComponentName) { 19 | val connectionCallback = mConnectionCallback.get() 20 | connectionCallback?.onServiceDisconnected() 21 | } 22 | 23 | init { 24 | mConnectionCallback = WeakReference(connectionCallback) 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/di/ActivityBindingModule.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.di 2 | 3 | import com.inspiringteam.xchange.di.scopes.ActivityScoped 4 | import com.inspiringteam.xchange.ui.quakes.QuakesActivity 5 | import com.inspiringteam.xchange.ui.quakes.QuakesModule 6 | import dagger.Module 7 | import dagger.android.ContributesAndroidInjector 8 | 9 | /** 10 | * We want Dagger.Android to create a Subcomponent which has a parent Component of 11 | * whichever module ActivityBindingModule is on (AppComponent, here). 12 | * we never need to tell AppComponent that it is going to have all or any of these subcomponents 13 | * nor do we need to tell these subcomponents that AppComponent exists. 14 | * We are also telling Dagger.Android that this generated SubComponent needs to include the specified modules and 15 | * be aware of a scope annotation @ActivityScoped 16 | * In this case, when Dagger.Android annotation processor runs it will create 1 subcomponent for us 17 | */ 18 | @Module 19 | abstract class ActivityBindingModule { 20 | @ActivityScoped 21 | @ContributesAndroidInjector(modules = [QuakesModule::class]) 22 | abstract fun quakesActivity(): QuakesActivity? 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/ScrollChildSwipeRefreshLayout.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.view.View 6 | import androidx.swiperefreshlayout.widget.SwipeRefreshLayout 7 | 8 | /** 9 | * Extends [SwipeRefreshLayout] to support non-direct descendant scrolling views. 10 | * 11 | * 12 | * [SwipeRefreshLayout] works as expected when a scroll view is a direct child: it triggers 13 | * the refresh only when the view is on top. This class adds a way (@link #setScrollUpChild} to 14 | * define which view controls this behavior. 15 | */ 16 | class ScrollChildSwipeRefreshLayout : SwipeRefreshLayout { 17 | private var mScrollUpChild: View? = null 18 | 19 | constructor(context: Context?) : super(context!!) {} 20 | constructor(context: Context?, attrs: AttributeSet?) : super( 21 | context!!, attrs 22 | ) { 23 | } 24 | 25 | override fun canChildScrollUp(): Boolean { 26 | return if (mScrollUpChild != null) { 27 | canScrollHorizontally(-1) 28 | } else super.canChildScrollUp() 29 | } 30 | 31 | fun setScrollUpChild(view: View?) { 32 | mScrollUpChild = view 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/util/DisplayUtils/TimeUtils.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util.DisplayUtils 2 | 3 | import java.util.* 4 | import java.util.concurrent.TimeUnit 5 | 6 | object TimeUtils { 7 | private val times: MutableList = Arrays.asList( 8 | TimeUnit.DAYS.toMillis(365), 9 | TimeUnit.DAYS.toMillis(30), 10 | TimeUnit.DAYS.toMillis(1), 11 | TimeUnit.HOURS.toMillis(1), 12 | TimeUnit.MINUTES.toMillis(1), 13 | TimeUnit.SECONDS.toMillis(1) 14 | ) 15 | private val timesString: MutableList = 16 | Arrays.asList("year", "month", "day", "hour", "minute", "second") 17 | 18 | fun toDuration(duration: Long): String { 19 | val res: StringBuffer = StringBuffer() 20 | for (i in times.indices) { 21 | val current: Long = times.get(i) 22 | val temp: Long = duration / current 23 | if (temp > 0) { 24 | res.append(temp).append(" ").append(timesString.get(i)) 25 | .append(if (temp != 1L) "s" else "").append(" ago") 26 | break 27 | } 28 | } 29 | if (("" == res.toString())) return "Just now" else return res.toString() 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/di/quakes/QuakesViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.di.quakes 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.inspiringteam.xchange.di.scopes.AppScoped 6 | import javax.inject.Inject 7 | import javax.inject.Provider 8 | 9 | 10 | @Suppress("UNCHECKED_CAST") 11 | @AppScoped 12 | class QuakesViewModelFactory @Inject 13 | constructor( 14 | private val creators: Map, 15 | @JvmSuppressWildcards Provider> 16 | ) : ViewModelProvider.Factory { 17 | 18 | override fun create(modelClass: Class): T { 19 | var creator: Provider? = creators[modelClass] 20 | if (creator == null) { 21 | for ((key, value) in creators) { 22 | if (modelClass.isAssignableFrom(key)) { 23 | creator = value 24 | break 25 | } 26 | } 27 | } 28 | if (creator == null) { 29 | throw IllegalArgumentException("unknown model class " + modelClass) 30 | } 31 | try { 32 | return creator.get() as T 33 | } catch (e: Exception) { 34 | throw RuntimeException(e) 35 | } 36 | 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/di/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.di 2 | 3 | import com.inspiringteam.xchange.data.source.QuakesRepositoryModule 4 | import com.inspiringteam.xchange.di.scopes.AppScoped 5 | import dagger.BindsInstance 6 | import dagger.Component 7 | import dagger.android.AndroidInjector 8 | import dagger.android.support.AndroidSupportInjectionModule 9 | import android.app.Application 10 | import com.inspiringteam.xchange.App 11 | 12 | /** 13 | * This is the root Dagger component. 14 | * [AndroidSupportInjectionModule] 15 | * is the module from Dagger.Android that helps with the generation 16 | * and location of subcomponents, which will be in our case, activities 17 | */ 18 | @AppScoped 19 | @Component( 20 | modules = [ 21 | QuakesRepositoryModule::class, 22 | ViewModelModule::class, 23 | UtilsModule::class, 24 | AppModule::class, 25 | ActivityBindingModule::class, 26 | AndroidSupportInjectionModule::class] 27 | ) 28 | interface AppComponent : AndroidInjector { 29 | 30 | // Application will just be provided into our app graph 31 | @Component.Builder 32 | interface Builder { 33 | 34 | @BindsInstance 35 | fun application(application: Application): AppComponent.Builder 36 | 37 | fun build(): AppComponent 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3F51B5 4 | #303F9F 5 | #FF4081 6 | 7 | // colors for gravity of quakes 8 | #98FB98 9 | #90EE90 10 | #7CFC00 11 | #228B22 12 | #006400 13 | 14 | #FFFF00 15 | 16 | #FFA500 17 | #FF8C00 18 | #FF7F50 19 | #FF6347 20 | #FF4500 21 | 22 | #FF0000 23 | 24 | 25 | @color/green0 26 | @color/green1 27 | @color/green2 28 | @color/green3 29 | @color/green4 30 | @color/yellow0 31 | @color/orange0 32 | @color/orange1 33 | @color/orange2 34 | @color/orange3 35 | @color/orange4 36 | @color/red0 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/source/local/QuakesDao.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source.local 2 | 3 | import androidx.room.* 4 | import com.inspiringteam.xchange.data.models.Quake 5 | import io.reactivex.Single 6 | import java.util.* 7 | 8 | /** 9 | * Room Dao interface 10 | */ 11 | @Dao 12 | interface QuakesDao { 13 | @Query("SELECT * FROM Quakes ") 14 | fun getQuakes(): Single> 15 | 16 | /** 17 | * Retrieve a quake by id. 18 | * 19 | * @param quakeId the Quake id. 20 | * @return the quake with quakeId 21 | */ 22 | @Query("SELECT * FROM Quakes WHERE id = :quakeId") 23 | fun getQuakeById(quakeId: String): Single 24 | 25 | /** 26 | * Insert Quake in the database. If the Quake already exists, ignore the action. 27 | * 28 | * @param Quake to be inserted. 29 | */ 30 | @Insert(onConflict = OnConflictStrategy.REPLACE) 31 | fun insertQuake(Quake: Quake) 32 | 33 | /** 34 | * Delete a Quake by id. 35 | * 36 | * @return the number of Quakes deleted. This should always be 1. 37 | */ 38 | @Query("DELETE FROM Quakes WHERE id = :QuakeId") 39 | fun deleteQuakeById(QuakeId: String): Int 40 | 41 | /** 42 | * Delete all Quake (items). 43 | */ 44 | @Query("DELETE FROM Quakes") 45 | fun deleteQuakes() 46 | 47 | @Update 48 | fun updateQuake(Quake: Quake): Int 49 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/source/remote/QuakesRemoteDataModule.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source.remote 2 | 3 | import com.google.gson.FieldNamingPolicy 4 | import com.google.gson.Gson 5 | import com.google.gson.GsonBuilder 6 | import com.inspiringteam.xchange.di.scopes.AppScoped 7 | import com.inspiringteam.xchange.util.Constants 8 | import dagger.Module 9 | import dagger.Provides 10 | import retrofit2.Retrofit 11 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 12 | import retrofit2.converter.gson.GsonConverterFactory 13 | 14 | @Module 15 | class QuakesRemoteDataModule { 16 | @AppScoped 17 | @Provides 18 | fun provideQuakesService(retrofit: Retrofit): QuakesApiService { 19 | return retrofit.create(QuakesApiService::class.java) 20 | } 21 | 22 | @Provides 23 | @AppScoped 24 | fun provideRetrofit(gson: Gson): Retrofit { 25 | return Retrofit.Builder() 26 | .addConverterFactory(GsonConverterFactory.create(gson)) 27 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 28 | .baseUrl(Constants.QUAKES_API_BASE_URL) 29 | .build() 30 | } 31 | 32 | @Provides 33 | @AppScoped 34 | fun provideGson(): Gson { 35 | val gsonBuilder = GsonBuilder() 36 | gsonBuilder.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) 37 | return gsonBuilder.create() 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/inspiringteam/xchange/quakes/QuakesScreenTest.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.quakes 2 | 3 | import android.app.Activity 4 | import androidx.test.filters.LargeTest 5 | import androidx.test.runner.AndroidJUnit4 6 | import com.inspiringteam.xchange.R 7 | import org.junit.Test 8 | import org.junit.runner.RunWith 9 | import androidx.test.rule.ActivityTestRule 10 | 11 | import com.inspiringteam.xchange.ui.quakes.QuakesActivity 12 | import org.junit.Rule 13 | 14 | import androidx.test.espresso.Espresso.onView 15 | import androidx.test.espresso.assertion.ViewAssertions.matches 16 | import androidx.test.espresso.intent.rule.IntentsTestRule 17 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed 18 | import androidx.test.espresso.matcher.ViewMatchers.withId 19 | 20 | 21 | @RunWith(AndroidJUnit4::class) 22 | @LargeTest 23 | class QuakesScreenTest { 24 | @Rule 25 | @JvmField 26 | val activityRule = activityTestRule() 27 | 28 | // More complex tests should be added as app's complexity rises 29 | @Test 30 | fun displayItemsInList() { 31 | // check if the ListView is visible 32 | onView(withId(R.id.quakesListView)) 33 | .check(matches(isDisplayed())) 34 | } 35 | } 36 | 37 | inline fun activityTestRule(initialTouchMode: Boolean = false, launchActivity: Boolean = true) = 38 | ActivityTestRule(T::class.java, initialTouchMode, launchActivity) 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/di/UtilsModule.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.di 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.net.ConnectivityManager 6 | import com.inspiringteam.xchange.di.scopes.AppScoped 7 | import com.inspiringteam.xchange.util.chromeTabsUtils.ChromeTabsWrapper 8 | import com.inspiringteam.xchange.util.ConnectivityUtils.DefaultOnlineChecker 9 | import com.inspiringteam.xchange.util.ConnectivityUtils.OnlineChecker 10 | import com.inspiringteam.xchange.util.schedulers.BaseSchedulerProvider 11 | import com.inspiringteam.xchange.util.schedulers.SchedulerProvider 12 | import dagger.Module 13 | import dagger.Provides 14 | 15 | @Module 16 | class UtilsModule { 17 | @Provides 18 | @AppScoped 19 | fun provideConnectivityManager(context: Application): ConnectivityManager { 20 | return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 21 | } 22 | 23 | @AppScoped 24 | @Provides 25 | fun providesChromeTabsWrapper(app: Application): ChromeTabsWrapper { 26 | return ChromeTabsWrapper(app.applicationContext) 27 | } 28 | 29 | @Provides 30 | @AppScoped 31 | fun onlineChecker(cm: ConnectivityManager): OnlineChecker { 32 | return DefaultOnlineChecker(cm) 33 | } 34 | 35 | @AppScoped 36 | @Provides 37 | fun provideSchedulerProvider(): BaseSchedulerProvider { 38 | return SchedulerProvider.instance 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/ui/quakes/QuakesAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.ui.quakes 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import android.widget.BaseAdapter 7 | import com.inspiringteam.xchange.R 8 | import java.util.* 9 | 10 | internal class QuakesAdapter(private var quakes: MutableList) : BaseAdapter() { 11 | 12 | fun replaceData(quakes: MutableList) { 13 | this.quakes = quakes 14 | notifyDataSetChanged() 15 | } 16 | 17 | override fun getCount(): Int { 18 | return quakes.size 19 | } 20 | 21 | override fun getItem(i: Int): QuakeItem { 22 | return quakes[i] 23 | } 24 | 25 | override fun getItemId(i: Int): Long { 26 | return i.toLong() 27 | } 28 | 29 | override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { 30 | var rowView = convertView 31 | val viewHolder: QuakeItemViewHolder 32 | if (rowView == null) { 33 | val inflater = LayoutInflater.from(parent?.context) 34 | rowView = inflater.inflate(R.layout.quake_item, parent, false) 35 | viewHolder = QuakeItemViewHolder(rowView) 36 | rowView.tag = viewHolder 37 | } else 38 | viewHolder = rowView.tag as QuakeItemViewHolder 39 | 40 | val quakeItem = getItem(position) 41 | viewHolder.bindItem(quakeItem) 42 | return rowView!! 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/source/QuakesRepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source 2 | 3 | import com.inspiringteam.xchange.data.source.local.QuakesDao 4 | import com.inspiringteam.xchange.data.source.local.QuakesLocalDataModule 5 | import com.inspiringteam.xchange.data.source.local.QuakesLocalDataSource 6 | import com.inspiringteam.xchange.data.source.remote.QuakesApiService 7 | import com.inspiringteam.xchange.data.source.remote.QuakesRemoteDataModule 8 | import com.inspiringteam.xchange.data.source.remote.QuakesRemoteDataSource 9 | import com.inspiringteam.xchange.data.source.scopes.Local 10 | import com.inspiringteam.xchange.data.source.scopes.Remote 11 | import com.inspiringteam.xchange.di.scopes.AppScoped 12 | import com.inspiringteam.xchange.util.schedulers.BaseSchedulerProvider 13 | import dagger.Module 14 | import dagger.Provides 15 | 16 | @Module(includes = [QuakesLocalDataModule::class, QuakesRemoteDataModule::class]) 17 | class QuakesRepositoryModule { 18 | @Provides 19 | @Local 20 | @AppScoped 21 | fun provideQuakesLocalDataSource( 22 | quakesDao: QuakesDao?, 23 | schedulerProvider: BaseSchedulerProvider? 24 | ): QuakesDataSource { 25 | return QuakesLocalDataSource(quakesDao!!, schedulerProvider!!) 26 | } 27 | 28 | @Provides 29 | @Remote 30 | @AppScoped 31 | fun provideQuakesRemoteDataSource(apiService: QuakesApiService?): QuakesDataSource { 32 | return QuakesRemoteDataSource(apiService!!) 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_quakes.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 19 | 20 | 24 | 25 | 26 | 32 | 33 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/source/remote/QuakesRemoteDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source.remote 2 | 3 | import com.inspiringteam.xchange.data.models.Quake 4 | import com.inspiringteam.xchange.data.models.QuakeWrapper 5 | import com.inspiringteam.xchange.data.models.QuakesResponse 6 | import com.inspiringteam.xchange.data.source.QuakesDataSource 7 | import com.inspiringteam.xchange.di.scopes.AppScoped 8 | import io.reactivex.Observable 9 | import io.reactivex.Single 10 | import java.lang.Exception 11 | import javax.inject.Inject 12 | 13 | /** 14 | * Remote Data Source implementation 15 | */ 16 | @AppScoped 17 | class QuakesRemoteDataSource @Inject constructor(private val mApiService: QuakesApiService) : 18 | QuakesDataSource { 19 | /** 20 | * Fresh items are retrieved from Remote API 21 | */ 22 | override fun getQuakes(): Single> { 23 | return mApiService.quakes 24 | .flatMap { response: QuakesResponse -> 25 | Observable.fromIterable(response.quakeWrapperList).toList() 26 | } 27 | .flatMap { wrappersResponse: List? -> 28 | Observable.fromIterable(wrappersResponse) 29 | .map { wrapper: QuakeWrapper -> 30 | wrapper.quake.timeStampAdded = System.currentTimeMillis() 31 | wrapper.quake 32 | }.toList() 33 | } 34 | } 35 | 36 | /** 37 | * These methods should be implemented when required 38 | * (e.g. when a cloud service is integrated) 39 | */ 40 | override fun getQuake(quakeId: String): Single { 41 | throw Exception("Not implemented") 42 | } 43 | 44 | override fun saveQuakes(quakes: List) {} 45 | override fun saveQuake(quake: Quake) {} 46 | override fun deleteAllQuakes() {} 47 | override fun deleteQuake(quakeId: String) {} 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/ui/quakes/QuakeItemViewHolder.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.ui.quakes 2 | 3 | import android.view.View 4 | import android.widget.TextView 5 | import com.inspiringteam.xchange.R 6 | import com.inspiringteam.xchange.util.DisplayUtils.GravityUtils 7 | import com.inspiringteam.xchange.util.DisplayUtils.TimeUtils 8 | import rx.functions.Action0 9 | 10 | /** 11 | * View holder for the quake item. 12 | */ 13 | internal class QuakeItemViewHolder(private val rowView: View) : View.OnClickListener { 14 | private val titleTextView: TextView = rowView.findViewById(R.id.title_tv) 15 | private val magnitudeTextView: TextView = rowView.findViewById(R.id.magnitude_tv) 16 | private val timeStampTextView: TextView = rowView.findViewById(R.id.timestamp_tv) 17 | private val gravityTextView: TextView = rowView.findViewById(R.id.gravity_tv) 18 | private var clickAction: Action0? = null 19 | 20 | 21 | init { 22 | rowView.setOnClickListener(this) 23 | } 24 | 25 | fun bindItem(quakeItem: QuakeItem) { 26 | val dangerColorsArray = rowView.context.resources.getIntArray(R.array.danger_color_array) 27 | val magnitude = quakeItem.quake.magnitude ?: 0.0 28 | val dangerIndex = GravityUtils.toMagnitudeColor(magnitude) 29 | rowView.setBackgroundColor(dangerColorsArray[dangerIndex]) 30 | magnitudeTextView.text = magnitude.toString() 31 | titleTextView.text = quakeItem.quake.location 32 | timeStampTextView.text = 33 | TimeUtils.toDuration(System.currentTimeMillis() - (quakeItem.quake.timeStamp ?: 0)) 34 | val intro = rowView.context.resources.getString(R.string.quake_item_risk) 35 | val gravity = intro + GravityUtils.toGravityString(quakeItem.quake.gravity) 36 | gravityTextView.text = gravity 37 | clickAction = quakeItem.onClickAction 38 | } 39 | 40 | override fun onClick(v: View) { 41 | clickAction?.call() 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /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/java/com/inspiringteam/xchange/util/chromeTabsUtils/ChromeTabsWrapper.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.util.chromeTabsUtils 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import androidx.browser.customtabs.CustomTabsClient 7 | import androidx.browser.customtabs.CustomTabsIntent 8 | import androidx.browser.customtabs.CustomTabsServiceConnection 9 | import androidx.core.content.ContextCompat 10 | import com.inspiringteam.xchange.R 11 | 12 | // TODO Deprecate this wrapper 13 | class ChromeTabsWrapper constructor(private val mContext: Context) : ServiceConnectionCallback { 14 | private var mConnection: CustomTabsServiceConnection? = null 15 | private var mClient: CustomTabsClient? = null 16 | 17 | fun openCustomtab(url: String?) { 18 | val builder: CustomTabsIntent.Builder = CustomTabsIntent.Builder() 19 | builder.setExitAnimations(mContext, R.anim.fade_in, R.anim.fade_out) 20 | builder.setToolbarColor(ContextCompat.getColor(mContext, R.color.colorPrimary)) 21 | val customTabsIntent: CustomTabsIntent = builder.build() 22 | customTabsIntent.intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 23 | customTabsIntent.launchUrl(mContext, Uri.parse(url)) 24 | } 25 | 26 | fun bindCustomTabsService() { 27 | if (mClient != null) return 28 | if (mConnection == null) { 29 | mConnection = ServiceConnection(this) 30 | } 31 | CustomTabsClient.bindCustomTabsService(mContext, CUSTOM_TAB_PACKAGE_NAME, mConnection) 32 | } 33 | 34 | fun unbindCustomTabsService() { 35 | if (mConnection == null) return 36 | mContext.unbindService(mConnection!!) 37 | mClient = null 38 | mConnection = null 39 | } 40 | 41 | public override fun onServiceConnected(client: CustomTabsClient) { 42 | mClient = client 43 | } 44 | 45 | public override fun onServiceDisconnected() { 46 | mClient = null 47 | } 48 | 49 | companion object { 50 | private val CUSTOM_TAB_PACKAGE_NAME: String = "com.android.chrome" 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/source/local/QuakesLocalDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source.local 2 | 3 | import com.google.common.base.Preconditions 4 | import com.inspiringteam.xchange.data.models.Quake 5 | import com.inspiringteam.xchange.data.source.QuakesDataSource 6 | import com.inspiringteam.xchange.di.scopes.AppScoped 7 | import com.inspiringteam.xchange.util.schedulers.BaseSchedulerProvider 8 | import io.reactivex.Completable 9 | import io.reactivex.Single 10 | import java.util.* 11 | import javax.inject.Inject 12 | 13 | /** 14 | * Concrete implementation of the Local Data Source 15 | */ 16 | @AppScoped 17 | class QuakesLocalDataSource @Inject constructor( 18 | quakesDao: QuakesDao, 19 | schedulerProvider: BaseSchedulerProvider 20 | ) : QuakesDataSource { 21 | 22 | private val mQuakesDao: QuakesDao 23 | private val mSchedulerProvider: BaseSchedulerProvider 24 | 25 | init { 26 | Preconditions.checkNotNull(schedulerProvider, "scheduleProvider cannot be null") 27 | Preconditions.checkNotNull(quakesDao, "quakesDao cannot be null") 28 | mQuakesDao = quakesDao 29 | mSchedulerProvider = schedulerProvider 30 | } 31 | 32 | /** 33 | * Items are retrieved from disk 34 | */ 35 | override fun getQuakes(): Single> { 36 | return mQuakesDao.getQuakes().map { it.toList() } 37 | } 38 | 39 | override fun getQuake(quakeId: String): Single { 40 | return mQuakesDao.getQuakeById(quakeId) 41 | } 42 | 43 | override fun saveQuakes(quakes: List) { 44 | Preconditions.checkNotNull(quakes) 45 | for (quake in quakes) saveQuake(quake) 46 | } 47 | 48 | override fun saveQuake(quake: Quake) { 49 | Preconditions.checkNotNull(quake) 50 | Completable.fromRunnable { mQuakesDao.insertQuake(quake) } 51 | .subscribeOn(mSchedulerProvider.io()).subscribe() 52 | } 53 | 54 | override fun deleteAllQuakes() { 55 | Completable.fromRunnable { mQuakesDao.deleteQuakes() } 56 | .subscribeOn(mSchedulerProvider.io()).subscribe() 57 | } 58 | 59 | override fun deleteQuake(quakeId: String) { 60 | Completable.fromRunnable { mQuakesDao.deleteQuakeById(quakeId) } 61 | .subscribeOn(mSchedulerProvider.io()).subscribe() 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | 34 | 35 | 39 | 40 | 44 | 45 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/src/main/res/layout/quake_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 16 | 17 | 26 | 27 | 38 | 39 | 40 | 45 | 46 | 54 | 55 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /app/src/test/java/com/inspiringteam/xchange/data/source/remote/QuakesRemoteDataSourceTest.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source.remote 2 | 3 | import com.inspiringteam.xchange.data.models.Quake 4 | import com.inspiringteam.xchange.data.models.QuakeWrapper 5 | import com.inspiringteam.xchange.data.models.QuakesResponse 6 | import io.reactivex.Single 7 | import io.reactivex.subscribers.TestSubscriber 8 | import org.junit.Assert 9 | import org.junit.Before 10 | import org.junit.Test 11 | import org.mockito.Mock 12 | import org.mockito.Mockito 13 | import org.mockito.MockitoAnnotations 14 | import java.io.IOException 15 | import java.util.* 16 | 17 | /** 18 | * Test 19 | * SUT - [QuakesRemoteDataSource] 20 | */ 21 | class QuakesRemoteDataSourceTest constructor() { 22 | @Mock 23 | var mQuakeService: QuakesApiService? = null 24 | private var mRemoteDataSource: QuakesRemoteDataSource? = null 25 | @Before 26 | @Throws(Exception::class) 27 | fun setup() { 28 | // init mocks 29 | MockitoAnnotations.initMocks(this) 30 | 31 | // get reference to the class in test 32 | mRemoteDataSource = QuakesRemoteDataSource((mQuakeService)!!) 33 | } 34 | 35 | @Test 36 | fun testPreConditions() { 37 | Assert.assertNotNull(mRemoteDataSource) 38 | } 39 | 40 | /** 41 | * Test scenario states: 42 | * Remote Source should get the correct results in success scenario 43 | */ 44 | @Test 45 | @Throws(Exception::class) 46 | fun testRemoteApiResponse_success() { 47 | val testSubscriber: TestSubscriber> = 48 | TestSubscriber>() 49 | val listQuakes: kotlin.collections.MutableList = ArrayList() 50 | 51 | // set up mock response 52 | val mockQuakeResponse: QuakesResponse = QuakesResponse() 53 | val tempQuake: Quake = Quake("id", "location") 54 | listQuakes.add(tempQuake) 55 | val quakeWrapper: QuakeWrapper = QuakeWrapper(tempQuake) 56 | val wrapperList: kotlin.collections.MutableList = ArrayList() 57 | wrapperList.add(quakeWrapper) 58 | mockQuakeResponse.setquakeWrapperList(wrapperList) 59 | 60 | // prepare fake response 61 | Mockito.`when`(mQuakeService!!.quakes) 62 | .thenReturn(Single.just(mockQuakeResponse)) 63 | 64 | // trigger response 65 | mRemoteDataSource!!.getQuakes().toFlowable().subscribe(testSubscriber) 66 | val result: kotlin.collections.MutableList = testSubscriber.values().get(0) 67 | testSubscriber.assertValue(listQuakes) 68 | } 69 | 70 | /** 71 | * Test scenario states: 72 | * Remote Source should get the correct results in failure scenario 73 | */ 74 | @Test 75 | @Throws(Exception::class) 76 | fun testRemoteApiResponse_failure() { 77 | val testSubscriber: TestSubscriber> = 78 | TestSubscriber>() 79 | 80 | // prepare fake exception 81 | val exception: Throwable = IOException() 82 | 83 | // prepare fake response 84 | Mockito.`when`(mQuakeService!!.quakes).thenReturn(Single.error(exception)) 85 | 86 | // assume the repository calls the remote DataSource 87 | mRemoteDataSource!!.getQuakes().toFlowable().subscribe(testSubscriber) 88 | testSubscriber.assertError(IOException::class.java) 89 | } 90 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/source/QuakesRepository.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source 2 | 3 | import com.google.common.base.Preconditions 4 | import com.inspiringteam.xchange.data.models.Quake 5 | import com.inspiringteam.xchange.data.source.scopes.Local 6 | import com.inspiringteam.xchange.data.source.scopes.Remote 7 | import com.inspiringteam.xchange.di.scopes.AppScoped 8 | import com.inspiringteam.xchange.util.ConnectivityUtils.OnlineChecker 9 | import com.inspiringteam.xchange.util.DisplayUtils.SortUtils 10 | import io.reactivex.Single 11 | import java.util.* 12 | import javax.inject.Inject 13 | 14 | /** 15 | * Consists of a functional set of methods that allow ViewModels to access appropriate data flows 16 | */ 17 | @AppScoped 18 | class QuakesRepository 19 | /** 20 | * Dagger allows us to have a single instance of the repository throughout the app 21 | * 22 | * @param quakesRemoteDataSource the backend data source (Remote Source) 23 | * @param quakesLocalDataSource the device storage data source (Local Source) 24 | */ @Inject constructor( 25 | @param:Remote private val quakesRemoteDataSource: QuakesDataSource, 26 | @param:Local private val quakesLocalDataSource: QuakesDataSource, 27 | private val onlineChecker: OnlineChecker 28 | ) : QuakesDataSource { 29 | /** 30 | * The retrieval logic sets the Local Source as the primary source 31 | * In case of an active internet connection and the absence of Local database 32 | * or if it contains stale data, the Remote Source is queried and the Local one is refreshed 33 | */ 34 | override fun getQuakes(): Single> { 35 | return quakesLocalDataSource.getQuakes() 36 | .flatMap { data: List -> 37 | if (onlineChecker.isOnline() && (data.isEmpty() || isStale(data))) 38 | return@flatMap getFreshQuakes() 39 | Single.just(SortUtils.sortByNewest(data)) 40 | } 41 | } 42 | 43 | override fun getQuake(quakeId: String): Single { 44 | Preconditions.checkNotNull(quakeId) 45 | return quakesLocalDataSource.getQuake(quakeId) 46 | } 47 | 48 | override fun saveQuakes(quakes: List) { 49 | Preconditions.checkNotNull(quakes) 50 | quakesLocalDataSource.saveQuakes(quakes) 51 | quakesRemoteDataSource.saveQuakes(quakes) 52 | } 53 | 54 | override fun saveQuake(quake: Quake) { 55 | Preconditions.checkNotNull(quake) 56 | quakesLocalDataSource.saveQuake(quake) 57 | quakesRemoteDataSource.saveQuake(quake) 58 | } 59 | 60 | override fun deleteAllQuakes() { 61 | quakesLocalDataSource.deleteAllQuakes() 62 | quakesRemoteDataSource.deleteAllQuakes() 63 | } 64 | 65 | override fun deleteQuake(quakeId: String) { 66 | quakesLocalDataSource.deleteQuake(quakeId) 67 | quakesRemoteDataSource.deleteQuake(quakeId) 68 | } 69 | 70 | /** 71 | * Helper methods, should be encapsulated 72 | */ 73 | private fun isStale(data: List): Boolean { 74 | // It's enough for 1 item to be stale 75 | return !data[0].isUpToDate 76 | } 77 | 78 | /** 79 | * Contains data refreshing logic 80 | * Both sources are emptied, then new items are retrieved from querying the Remote Source 81 | * and finally, sources are replenished 82 | */ 83 | private fun getFreshQuakes(): Single> { 84 | deleteAllQuakes() 85 | return quakesRemoteDataSource 86 | .getQuakes() 87 | .doOnSuccess { quakes: List -> 88 | saveQuakes(quakes) 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/data/models/Quake.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.models 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.Ignore 6 | import androidx.room.PrimaryKey 7 | import com.google.common.base.Objects 8 | import com.google.gson.annotations.Expose 9 | import com.google.gson.annotations.SerializedName 10 | 11 | /** 12 | * Immutable model class for a Quake. 13 | */ 14 | @Entity(tableName = "quakes") 15 | class Quake { 16 | @PrimaryKey 17 | @ColumnInfo(name = "id") 18 | @SerializedName("code") 19 | @Expose 20 | var id: String = "" 21 | 22 | @SerializedName("mag") 23 | @ColumnInfo(name = "mag") 24 | @Expose 25 | var magnitude: Double? = null 26 | 27 | @SerializedName("time") 28 | @ColumnInfo(name = "time") 29 | @Expose 30 | var timeStamp: Long? = null 31 | 32 | @ColumnInfo(name = "timeAdded") 33 | @Expose 34 | var timeStampAdded: Long? = null 35 | 36 | @SerializedName("place") 37 | @ColumnInfo(name = "place") 38 | @Expose 39 | var location: String 40 | 41 | @SerializedName("url") 42 | @ColumnInfo(name = "url") 43 | @Expose 44 | var url: String? = null 45 | 46 | @SerializedName("sig") 47 | @ColumnInfo(name = "sig") 48 | @Expose 49 | var gravity: Int = 0 50 | 51 | @Ignore 52 | constructor(id: String, location: String) { 53 | this.id = id 54 | this.location = location 55 | } 56 | 57 | @Ignore 58 | constructor(id: String, location: String, url: String?) { 59 | this.id = id 60 | this.location = location 61 | this.url = url 62 | } 63 | 64 | @Ignore 65 | constructor(magnitude: Double?, location: String) { 66 | this.magnitude = magnitude 67 | this.location = location 68 | } 69 | 70 | @Ignore 71 | constructor(magnitude: Double?, location: String, timeStamp: Long?, timeStampAdded: Long?) { 72 | this.magnitude = magnitude 73 | this.timeStamp = timeStamp 74 | this.location = location 75 | this.timeStampAdded = timeStampAdded 76 | } 77 | 78 | constructor( 79 | id: String, 80 | magnitude: Double?, 81 | timeStamp: Long?, 82 | timeStampAdded: Long?, 83 | location: String, 84 | url: String?, 85 | gravity: Int 86 | ) { 87 | this.id = id 88 | this.magnitude = magnitude 89 | this.timeStamp = timeStamp 90 | this.timeStampAdded = timeStampAdded 91 | this.location = location 92 | this.url = url 93 | this.gravity = gravity 94 | } 95 | 96 | val isUpToDate: Boolean 97 | get() { 98 | return System.currentTimeMillis() - (timeStampAdded)!! < STALE_MS 99 | } 100 | 101 | public override fun hashCode(): Int { 102 | return Objects.hashCode(id, location, magnitude) 103 | } 104 | 105 | public override fun equals(obj: Any?): Boolean { 106 | if (this === obj) return true 107 | if (obj == null || javaClass != obj.javaClass) return false 108 | val quake: Quake = obj as Quake 109 | return (Objects.equal(id, quake.id) && 110 | Objects.equal(magnitude, quake.magnitude) && 111 | Objects.equal(location, quake.location)) 112 | } 113 | 114 | public override fun toString(): String { 115 | return location + magnitude 116 | } 117 | 118 | companion object { 119 | @Ignore 120 | private val STALE_MS: Long = (5 * 60 * 1000 // Data is stale after 5 minutes 121 | ).toLong() 122 | } 123 | } -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/ui/quakes/QuakesViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.ui.quakes 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.inspiringteam.xchange.R 5 | import com.inspiringteam.xchange.data.models.Quake 6 | import com.inspiringteam.xchange.data.source.QuakesRepository 7 | import com.inspiringteam.xchange.di.scopes.AppScoped 8 | import com.inspiringteam.xchange.util.chromeTabsUtils.ChromeTabsWrapper 9 | import io.reactivex.Observable 10 | import io.reactivex.Single 11 | import io.reactivex.subjects.BehaviorSubject 12 | import io.reactivex.subjects.PublishSubject 13 | import javax.inject.Inject 14 | 15 | /** 16 | * ViewModel for quakes screen 17 | */ 18 | @AppScoped 19 | class QuakesViewModel @Inject constructor( 20 | private val repository: QuakesRepository, 21 | private val tabsWrapper: ChromeTabsWrapper 22 | ) : ViewModel() { 23 | // Use a BehaviourSubject because we are interested in the last object that was emitted before 24 | // subscribing. We ensure that the loading indicator has the correct visibility. 25 | private val loadingIndicatorSubject: BehaviorSubject = 26 | BehaviorSubject.createDefault(false) 27 | 28 | // Use a PublishSubject because we are not interested in the last object that was emitted 29 | // before subscribing. We avoid displaying the snackBar multiple times. 30 | private val snackBarTextView: PublishSubject = PublishSubject.create() 31 | 32 | /** 33 | * @return a stream of ids that should be displayed in the snackBar 34 | */ 35 | val snackBarMessage: Observable 36 | get() = snackBarTextView.hide() 37 | 38 | /** 39 | * @return a stream that emits true if the progress indicator should be displayed, false otherwise 40 | */ 41 | val loadingIndicatorVisibility: Observable 42 | get() = loadingIndicatorSubject.hide() 43 | 44 | /** 45 | * @return the model for the quakes screen 46 | */ 47 | fun getUiModel(isForcedCall: Boolean): Single { 48 | return getQuakeItems(isForcedCall) 49 | .doOnSubscribe { loadingIndicatorSubject.onNext(true) } 50 | .doOnSuccess { loadingIndicatorSubject.onNext(false) } 51 | .doOnError { snackBarTextView.onNext(R.string.loading_quakes_error) } 52 | .map { quakes: List -> constructQuakesModel(quakes) } 53 | } 54 | 55 | fun bindTabsService() = tabsWrapper.unbindCustomTabsService() 56 | 57 | fun unbindTabsService() = tabsWrapper.unbindCustomTabsService() 58 | 59 | private fun getNoQuakesModel() = NoQuakesModel(R.string.no_quakes) 60 | 61 | private fun handleQuakeClicked(quake: Quake) = tabsWrapper.openCustomtab(quake.url) 62 | 63 | private fun getQuakeItems(isForcedCall: Boolean): Single> { 64 | if (isForcedCall) 65 | repository.deleteAllQuakes() 66 | return repository.getQuakes() 67 | .flatMap { list: List? -> 68 | Observable 69 | .fromIterable(list) 70 | .map { quake: Quake -> constructQuakeItem(quake) }.toList() 71 | } 72 | } 73 | 74 | private fun constructQuakeItem(quake: Quake): QuakeItem { 75 | return QuakeItem(quake) { handleQuakeClicked(quake) } 76 | } 77 | 78 | private fun constructQuakesModel(quakes: List): QuakesUiModel { 79 | val isQuakesListVisible = quakes.isNotEmpty() 80 | val isNoQuakesViewVisible = !isQuakesListVisible 81 | var noQuakesModel: NoQuakesModel? = null 82 | if (quakes.isEmpty()) 83 | noQuakesModel = getNoQuakesModel() 84 | return QuakesUiModel( 85 | isQuakesListVisible, 86 | quakes, 87 | isNoQuakesViewVisible, 88 | noQuakesModel 89 | ) 90 | } 91 | 92 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/inspiringteam/xchange/data/source/local/QuakesDaoTest.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source.local 2 | 3 | import androidx.room.Room 4 | import androidx.test.InstrumentationRegistry 5 | import androidx.test.runner.AndroidJUnit4 6 | import com.inspiringteam.xchange.data.models.Quake 7 | import io.reactivex.subscribers.TestSubscriber 8 | import org.junit.After 9 | import org.junit.Before 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import java.util.* 13 | 14 | @RunWith(AndroidJUnit4::class) 15 | class QuakesDaoTest constructor() { 16 | private var mDatabase: QuakesDatabase? = null 17 | private var mQuakeTestSubscriber: TestSubscriber? = null 18 | private var mQuakesTestSubscriber: TestSubscriber>? = null 19 | @Before 20 | fun initDb() { 21 | // using an in-memory database because the information stored here disappears when the 22 | // process is killed 23 | mDatabase = Room.inMemoryDatabaseBuilder( 24 | InstrumentationRegistry.getContext(), 25 | QuakesDatabase::class.java 26 | ).build() 27 | mQuakeTestSubscriber = TestSubscriber() 28 | mQuakesTestSubscriber = TestSubscriber>() 29 | } 30 | 31 | @After 32 | fun closeDb() { 33 | mDatabase!!.close() 34 | } 35 | 36 | /** 37 | * Test scenario states: 38 | * Upon insertion of a quake, the correct item is retrieved 39 | */ 40 | @Test 41 | fun insertQuakeAndGetById() { 42 | // insert quake 43 | mDatabase!!.quakesDao().insertQuake(QUAKES.get(0)) 44 | 45 | // getting the Quake by id from the database 46 | mDatabase!!.quakesDao() 47 | .getQuakeById(QUAKES.get(0).id).toFlowable().subscribe(mQuakeTestSubscriber) 48 | 49 | // the loaded data contains the expected values 50 | mQuakeTestSubscriber.assertValue(QUAKES.get(0)) 51 | } 52 | 53 | /** 54 | * Test scenario states: 55 | * Upon insertion of quakes, the correct list is retrieved 56 | */ 57 | @Test 58 | fun insertQuakesAndGet() { 59 | // insert quakes 60 | mDatabase!!.quakesDao().insertQuake(QUAKES.get(0)) 61 | mDatabase!!.quakesDao().insertQuake(QUAKES.get(1)) 62 | mDatabase!!.quakesDao().insertQuake(QUAKES.get(2)) 63 | 64 | 65 | // getting quakes from the database 66 | mDatabase!!.quakesDao() 67 | .getQuakes().toFlowable().subscribe(mQuakesTestSubscriber) 68 | 69 | // the loaded data contains the expected values 70 | mQuakesTestSubscriber.assertValue(QUAKES) 71 | } 72 | 73 | /** 74 | * Test scenario states: 75 | * Upon insertion of a conflictual quake, the latter one should be retrieved 76 | */ 77 | @Test 78 | fun insertQuakeAndReplaceOnConflict() { 79 | val conflictualQuake: Quake = Quake("id1", "locationN") 80 | 81 | // insert initial quake 82 | mDatabase!!.quakesDao().insertQuake(QUAKES.get(0)) 83 | 84 | // insert conflictual quake 85 | mDatabase!!.quakesDao().insertQuake(conflictualQuake) 86 | 87 | 88 | // getting quake from the database 89 | mDatabase!!.quakesDao() 90 | .getQuakeById("id1").toFlowable().subscribe(mQuakeTestSubscriber) 91 | 92 | // the loaded data contains the expected values 93 | mQuakeTestSubscriber!!.assertValue(conflictualQuake) 94 | } 95 | 96 | /** 97 | * Test scenario states: 98 | * Upon insertion of 1 quake and deletion of all records, we should retrieve an empty list 99 | * fom the database 100 | */ 101 | @Test 102 | fun deleteQuakeAndGetQuakes_Scenario() { 103 | // given a quake (item) inserted 104 | mDatabase!!.quakesDao().insertQuake(QUAKES.get(0)) 105 | 106 | // deleting all data 107 | mDatabase!!.quakesDao().deleteQuakes() 108 | 109 | // getting the data 110 | mDatabase!!.quakesDao().getQuakes().toFlowable().subscribe(mQuakesTestSubscriber) 111 | 112 | // the list should be empty 113 | mQuakesTestSubscriber!!.assertValue(ArrayList()) 114 | } 115 | 116 | companion object { 117 | private val QUAKES: MutableList = Arrays.asList( 118 | Quake("id1", "location1"), 119 | Quake("id2", "location2"), 120 | Quake("id3", "location3") 121 | ) 122 | } 123 | } -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | xmlns:android 29 | 30 | ^$ 31 | 32 | 33 | 34 |
35 |
36 | 37 | 38 | 39 | xmlns:.* 40 | 41 | ^$ 42 | 43 | 44 | BY_NAME 45 | 46 |
47 |
48 | 49 | 50 | 51 | .*:id 52 | 53 | http://schemas.android.com/apk/res/android 54 | 55 | 56 | 57 |
58 |
59 | 60 | 61 | 62 | .*:name 63 | 64 | http://schemas.android.com/apk/res/android 65 | 66 | 67 | 68 |
69 |
70 | 71 | 72 | 73 | name 74 | 75 | ^$ 76 | 77 | 78 | 79 |
80 |
81 | 82 | 83 | 84 | style 85 | 86 | ^$ 87 | 88 | 89 | 90 |
91 |
92 | 93 | 94 | 95 | .* 96 | 97 | ^$ 98 | 99 | 100 | BY_NAME 101 | 102 |
103 |
104 | 105 | 106 | 107 | .* 108 | 109 | http://schemas.android.com/apk/res/android 110 | 111 | 112 | ANDROID_ATTRIBUTE_ORDER 113 | 114 |
115 |
116 | 117 | 118 | 119 | .* 120 | 121 | .* 122 | 123 | 124 | BY_NAME 125 | 126 |
127 |
128 |
129 |
130 |
131 |
-------------------------------------------------------------------------------- /app/src/androidTest/java/com/inspiringteam/xchange/data/source/local/QuakesLocalDataSourceTest.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source.local 2 | 3 | import androidx.room.Room 4 | import androidx.test.runner.AndroidJUnit4 5 | import com.inspiringteam.xchange.data.models.Quake 6 | import com.inspiringteam.xchange.util.schedulers.BaseSchedulerProvider 7 | import com.inspiringteam.xchange.util.schedulers.ImmediateSchedulerProvider 8 | import org.junit.Before 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | 12 | /** 13 | * Test 14 | * SUT - [QuakesLocalDataSource] 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class QuakesLocalDataSourceTest constructor() { 18 | private var mLocalDataSource: QuakesLocalDataSource? = null 19 | private var mDatabase: QuakesDatabase? = null 20 | private var mSchedulerProvider: BaseSchedulerProvider? = null 21 | @Before 22 | fun setup() { 23 | // using an in-memory database for testing, since it doesn't survive killing the process 24 | mDatabase = Room.inMemoryDatabaseBuilder( 25 | androidx.test.InstrumentationRegistry.getContext(), 26 | QuakesDatabase::class.java 27 | ) 28 | .build() 29 | val quakesDao: QuakesDao = mDatabase!!.quakesDao() 30 | mSchedulerProvider = ImmediateSchedulerProvider() 31 | 32 | // Make sure that we're not keeping a reference to the wrong instance. 33 | mLocalDataSource = QuakesLocalDataSource(quakesDao, mSchedulerProvider) 34 | } 35 | 36 | @org.junit.After 37 | fun cleanUp() { 38 | mDatabase!!.quakesDao().deleteQuakes() 39 | mDatabase!!.close() 40 | } 41 | 42 | @org.junit.Test 43 | fun testPreConditions() { 44 | junit.framework.Assert.assertNotNull(mLocalDataSource) 45 | } 46 | 47 | /** 48 | * Test scenario states: 49 | * Local Source should get the correct result upon saving a quake 50 | */ 51 | @org.junit.Test 52 | fun saveQuake_retrievesQuake() { 53 | // When saved into the quakes repository 54 | mLocalDataSource!!.saveQuake(QUAKE) 55 | 56 | // Then the quake can be retrieved from the persistent repository 57 | val testSubscriber: io.reactivex.subscribers.TestSubscriber = 58 | io.reactivex.subscribers.TestSubscriber() 59 | mLocalDataSource!!.getQuake(QUAKE.id).toFlowable().subscribe(testSubscriber) 60 | testSubscriber.assertValue(QUAKE) 61 | }// Given 2 new quakes in the persistent repository 62 | 63 | // Then the quakes can be retrieved from the persistent repository 64 | /** 65 | * Test scenario states: 66 | * Local Source should get the correct result upon saving quakes 67 | */ 68 | @get:Test 69 | val quakes_retrieveSavedQuakes: Unit 70 | get() { 71 | // Given 2 new quakes in the persistent repository 72 | val newQuake1: Quake = Quake("id1", "location1") 73 | mLocalDataSource!!.saveQuake(newQuake1) 74 | val newQuake2: Quake = Quake("id2", "location2") 75 | mLocalDataSource!!.saveQuake(newQuake2) 76 | 77 | // Then the quakes can be retrieved from the persistent repository 78 | val testSubscriber: io.reactivex.subscribers.TestSubscriber> = 79 | io.reactivex.subscribers.TestSubscriber>() 80 | mLocalDataSource!!.getQuakes().toFlowable().subscribe(testSubscriber) 81 | val result: kotlin.collections.MutableList = testSubscriber.values().get(0) 82 | org.junit.Assert.assertThat>( 83 | result, 84 | org.hamcrest.core.IsCollectionContaining.hasItems(newQuake1, newQuake2) 85 | ) 86 | }//Given that no Quake has been saved 87 | //When querying for a quake, no values are returned. 88 | /** 89 | * Test scenario states: 90 | * Local Source should get the correct result upon having no data 91 | */ 92 | @get:Test 93 | val quake_whenQuakeNotSaved: Unit 94 | get() { 95 | //Given that no Quake has been saved 96 | //When querying for a quake, no values are returned. 97 | val testSubscriber: io.reactivex.subscribers.TestSubscriber = 98 | io.reactivex.subscribers.TestSubscriber() 99 | mLocalDataSource!!.getQuake("some_id").toFlowable().subscribe(testSubscriber) 100 | testSubscriber.assertNoValues() 101 | } 102 | 103 | /** 104 | * Test scenario states: 105 | * Local Source should get the correct result upon emptying source 106 | */ 107 | @org.junit.Test 108 | fun deleteAllQuakes_emptyListOfRetrievedQuakes() { 109 | // Given a new quake in the persistent repository 110 | mLocalDataSource!!.saveQuake(QUAKE) 111 | 112 | // When all quakes are deleted 113 | mLocalDataSource!!.deleteAllQuakes() 114 | 115 | // Then the retrieved quakes is an empty list 116 | val testSubscriber: io.reactivex.subscribers.TestSubscriber> = 117 | io.reactivex.subscribers.TestSubscriber>() 118 | mLocalDataSource!!.getQuakes().toFlowable().subscribe(testSubscriber) 119 | val result: MutableList = testSubscriber.values().get(0) 120 | org.junit.Assert.assertThat(result.size, org.hamcrest.core.Is.`is`(0)) 121 | } 122 | 123 | companion object { 124 | private val QUAKE: Quake = Quake("id", "location") 125 | } 126 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android-MVVM-RxJava2-Dagger2 2 | 3 | ## ! Sample has been migrated to Kotlin. 4 | 5 | This repository contains a detailed sample application that uses MVVM as its presentation layer pattern. **The app aims to be extremely flexible to creating variants for automated and manual testing.** Also, the project implements and follows the guidelines presented in Google Sample [MVVM+RXJAVA-android](https://github.com/googlesamples/android-architecture/tree/dev-todo-mvvm-rxjava/). 6 | 7 | Essential dependencies are Dagger2 with Dagger-android, RxJava2 with RxAndroid, Room, Retrofit and Espresso. Other noteworthy dependencies would be Mockito, Chrome CustomTabs and Guava. 8 | ## App Demo 9 | This app displays latest earthquakes from all around the world. A number of quakes is fetched realtime upon request. If offline, the app displays the most recent loaded quakes. 10 | 11 | ![content](https://github.com/catalinghita8/android-mvvm-rxjava-dagger2/blob/master/readme_pics/scrolling.gif) 12 | ![content](https://github.com/catalinghita8/android-mvvm-rxjava-dagger2/blob/master/readme_pics/forcing_update.gif) 13 | ![content](https://github.com/catalinghita8/android-mvvm-rxjava-dagger2/blob/master/readme_pics/open_tab.gif) 14 | ## Presentation Layer 15 | MVVM pattern is integrated to facilitate testing and to allow separating the user interface logic from business logic. 16 | 17 | As Views were passive in MVP, here the View layer is much more flexibile as an indefinite number of Views can bind to a ViewModel. Also, MVVM enforces a clear separation between Views and their master - ViewModel, as the latter holds no reference to Views. The model layer is completely isolated and centralized through the repository pattern. 18 | 19 | ![Presentation](https://github.com/catalinghita8/android-mvvm-rxjava-dagger2/blob/master/readme_pics/mvvm_diagram.png) 20 | 21 | ## Model Layer 22 | The model layer is structured on repository pattern so that the ViewModel has no clue on the origins of the data. 23 | 24 | The repository handles data interactions and transactions from two main data sources - local and remote: 25 | - `QuakesRemoteDataSource` defined by a REST API consumed with [Retrofit](http://square.github.io/retrofit) 26 | - `QuakesLocalDataSource` defined by a SQL database consumed with [Room](https://developer.android.com/topic/libraries/architecture/room) 27 | 28 | There are two main use-cases, online and offline. In both use cases, `QuakesLocalDataSource` has priority. In the online use-case if the local data is stale, new data is fetched from the `NewsRemoteDataSource` and the repository data is refreshed. In case of no internet connection, `QuakesLocalDataSource` is always queried. 29 | 30 | Decoupling is also inforced within the Model layer (entirely consisted by `QuakesRepository`). Therefore, lower level components (which are the data sources: `QuakesRemoteDataSource` and `QuakesLocalDatasource`) are decoupled through `QuakesDataSource` interface. Also, through their dependence on the same interface, these data sources are interchangeable. 31 | 32 | In this manner, the project respects the DIP (Dependency Inversion Principle) as both low and high level modules depend on abstractions. 33 | ### Reactive approach 34 | It is extremely important to note that this project is not essentially a reactive app as it is not capitalizing the enormous potential of a fully reactive approach. 35 | Nevertheless, the app was intended to have a flexible and efficient testing capability, rather than a fully reactive build. 36 | 37 | Even in this case, we are able to notice RxJava's benefits when data is being retrieved from the repository through different sources and then is channeled through the ViewModel and finally consumed in Views. 38 | - Data Flow is centralized. 39 | - Threading is much easier, with no need for the dreaded `AsyncTasks`. 40 | - Error handling is straightforward and comfortable. 41 | ## Dependency Injection 42 | Dagger2 is used to externalize the creation of dependencies from the classes that use them. Android specific helpers are provided by `Dagger-Android` and the most significant advantage is that they generate a subcomponent for each `Activity` through a new code generator. 43 | Such subcomponent is: 44 | ```kotlin 45 | @ActivityScoped 46 | @ContributesAndroidInjector(modules = [QuakesModule::class]) 47 | abstract fun quakesActivity(): QuakesActivity? 48 | ``` 49 | The below diagram illustrates the most significant relations between components and modules. An important note is the fact that the ViewModel is now `@AppScoped` whereas in MVP the Presenter is `@ActivityScoped` - this is mainly due to the fact that in MVVM the ViewModel is a Android Architecture Component so therefore has a greater scope than Views. You can also get a quick glance on how annotations help us define custom Scopes in order to properly handle classes instantiation. 50 | ![Dependecy](https://github.com/catalinghita8/android-mvvm-rxjava-dagger2/blob/master/readme_pics/mvvm_dagger_dependency.png) 51 | _Note: The above diagram might help you understand how Dagger-android works. Also, only essential components/modules/objects are included here, this is suggested by the "…"_ 52 | ## Testing 53 | The apps' components are extremely easy to test due to DI achieved through Dagger and the project's structure, but as well for the reason that the data flow is centralized with RxJava which results in highly testable pieces of code. 54 | 55 | Unit tests are conducted with the help of Mockito and Instrumentation tests with the help of Espresso. 56 | ## Strong points 57 | - Decoupling level is high. 58 | - Data Flow is centralized through RxJava. 59 | - Possess high flexibility to create variants for automated and manual testing. 60 | - Possess lightweight structure due to MVVM presentation pattern. 61 | - Is scalable and easy to expand. 62 | ## Weak points 63 | - Possess high code base - simpler approaches will certainly lower code size 64 | - Possess medium complexity - other approaches might lower complexity and increase efficiency. 65 | 66 | # Final notes: 67 | - The app is not a polished ready-to-publish product, it acts as a boilerplate project or as a starting point for android enthusiasts out there. 68 | - Using this project as your starting point and expanding it is also encouraged, as at this point it is very easy to add new modules. 69 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/test/java/com/inspiringteam/xchange/quakes/QuakesViewModelTest.java: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.quakes; 2 | 3 | import com.google.common.collect.Lists; 4 | import com.inspiringteam.xchange.R; 5 | import com.inspiringteam.xchange.data.models.Quake; 6 | import com.inspiringteam.xchange.data.source.QuakesRepository; 7 | import com.inspiringteam.xchange.ui.quakes.QuakeItem; 8 | import com.inspiringteam.xchange.ui.quakes.QuakesUiModel; 9 | import com.inspiringteam.xchange.ui.quakes.QuakesViewModel; 10 | import com.inspiringteam.xchange.util.chromeTabsUtils.ChromeTabsWrapper; 11 | 12 | import org.junit.Before; 13 | import org.junit.Test; 14 | import org.mockito.Mock; 15 | import org.mockito.MockitoAnnotations; 16 | 17 | import java.util.ArrayList; 18 | import java.util.List; 19 | 20 | import io.reactivex.Single; 21 | import io.reactivex.observers.TestObserver; 22 | 23 | 24 | import static org.junit.Assert.assertEquals; 25 | import static org.junit.Assert.assertFalse; 26 | import static org.junit.Assert.assertNotNull; 27 | import static org.junit.Assert.assertNull; 28 | import static org.junit.Assert.assertTrue; 29 | import static org.mockito.Matchers.eq; 30 | import static org.mockito.Mockito.verify; 31 | import static org.mockito.Mockito.when; 32 | 33 | 34 | /** 35 | * Unit test for {@link QuakesViewModel} 36 | */ 37 | public class QuakesViewModelTest { 38 | private static List QUAKES; 39 | 40 | private QuakesViewModel mViewModel; 41 | 42 | private TestObserver mQuakesSubscriber; 43 | 44 | private TestObserver mProgressIndicatorSubscriber; 45 | 46 | private TestObserver mSnackbarTextSubscriber; 47 | 48 | @Mock 49 | private QuakesRepository mQuakesRepository; 50 | 51 | @Mock 52 | private ChromeTabsWrapper mChromeTabsWrapper; 53 | 54 | @Before 55 | public void setupQuakesViewModel() { 56 | // Mockito has a very convenient way to inject mocks by using the @Mock annotation. To 57 | // inject the mocks in the test the initMocks method needs to be called. 58 | MockitoAnnotations.initMocks(this); 59 | 60 | // Get a reference to the class under test 61 | mViewModel = new QuakesViewModel(mQuakesRepository, mChromeTabsWrapper); 62 | 63 | 64 | QUAKES = Lists.newArrayList(new Quake("id1", "location1"), 65 | new Quake("id2", "location2")); 66 | 67 | mQuakesSubscriber = new TestObserver<>(); 68 | mProgressIndicatorSubscriber = new TestObserver<>(); 69 | mSnackbarTextSubscriber = new TestObserver<>(); 70 | } 71 | 72 | @Test 73 | public void progressIndicator_emits_whenSubscribedToData() { 74 | // Given that the quake repository never emits 75 | when(mQuakesRepository.getQuakes()).thenReturn(Single.never()); 76 | 77 | // Given that we are subscribed to the progress indicator 78 | mProgressIndicatorSubscriber = mViewModel.getLoadingIndicatorVisibility().test(); 79 | 80 | // When subscribed to the quakes model 81 | mViewModel.getUiModel(false).subscribe(); 82 | 83 | // The progress indicator emits initially false and then true 84 | mProgressIndicatorSubscriber.assertValues(false, true); 85 | } 86 | 87 | @Test 88 | public void snackbarText_emits_whenError_whenRetrievingData() { 89 | // Given an error when retrieving quakes 90 | when(mQuakesRepository.getQuakes()).thenReturn(Single.error(new RuntimeException())); 91 | 92 | // Given that we are subscribed to the snackbar text 93 | mViewModel.getSnackBarMessage().subscribe(mSnackbarTextSubscriber); 94 | 95 | // When subscribed to the quakes model 96 | mViewModel.getUiModel(false).subscribe(mQuakesSubscriber); 97 | 98 | // The snackbar emits an error message 99 | mSnackbarTextSubscriber.assertValue(R.string.loading_quakes_error); 100 | } 101 | 102 | @Test 103 | public void getQuakesModel_emits_whenQuakes() { 104 | // Given that we are subscribed to the emissions of the UI model 105 | withQuakesInRepositoryAndSubscribed(QUAKES); 106 | 107 | // The Quakes model containing the list of Quakes is emitted 108 | mQuakesSubscriber.assertValueCount(1); 109 | QuakesUiModel model = mQuakesSubscriber.values().get(0); 110 | assertQuakesModelWithQuakesVisible(model); 111 | } 112 | 113 | @Test 114 | public void forceUpdateQuakes_updatesQuakesRepository() { 115 | // Given that the quake repository never emits 116 | when(mQuakesRepository.getQuakes()).thenReturn(Single.never()); 117 | 118 | // When calling force update 119 | mViewModel.getUiModel(true); 120 | 121 | // The quakes are refreshed in the repository 122 | verify(mQuakesRepository).deleteAllQuakes(); 123 | } 124 | 125 | @Test 126 | public void QuakeItem_tapAction_opensQuakeDetails() { 127 | Quake tempQuake = new Quake("id", "location", "https://google.com"); 128 | 129 | // Given a quake 130 | withQuakeInRepositoryAndSubscribed(tempQuake); 131 | // And list of quake items is emitted 132 | List items = mQuakesSubscriber.values().get(0).getQuakes(); 133 | QuakeItem QuakeItem = items.get(0); 134 | 135 | // When triggering the click action 136 | QuakeItem.getOnClickAction().call(); 137 | 138 | // Opening of the Quake details is called with the correct action 139 | verify(mChromeTabsWrapper).openCustomtab(tempQuake.getUrl()); 140 | } 141 | 142 | private void withQuakeInRepositoryAndSubscribed(Quake Quake) { 143 | List Quakes = new ArrayList<>(); 144 | Quakes.add(Quake); 145 | withQuakesInRepositoryAndSubscribed(Quakes); 146 | } 147 | 148 | private void assertQuakesModelWithQuakesVisible(QuakesUiModel model) { 149 | assertTrue(model.isQuakesListVisible()); 150 | assertQuakeItems(model.getQuakes()); 151 | assertFalse(model.isNoQuakesViewVisible()); 152 | assertNull(model.getNoQuakesModel()); 153 | } 154 | 155 | private void withQuakesInRepositoryAndSubscribed(List quakes) { 156 | // Given that the quake repository returns quakes 157 | when(mQuakesRepository.getQuakes()).thenReturn(Single.just(quakes)); 158 | 159 | // Given that we are subscribed to the quakes 160 | mViewModel.getUiModel(false).subscribe(mQuakesSubscriber); 161 | } 162 | 163 | private void assertQuakeItems(List items) { 164 | // check if the QuakeItems are the expected ones 165 | assertEquals(items.size(), QUAKES.size()); 166 | 167 | assertQuake(items.get(0), QUAKES.get(0)); 168 | assertQuake(items.get(1), QUAKES.get(1)); 169 | } 170 | 171 | private void assertQuake(QuakeItem QuakeItem, Quake Quake) { 172 | assertEquals(QuakeItem.getQuake(), Quake); 173 | assertNotNull(QuakeItem.getOnClickAction()); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /app/src/main/java/com/inspiringteam/xchange/ui/quakes/QuakesFragment.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.ui.quakes 2 | 3 | import android.os.Bundle 4 | import android.util.Log 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.annotation.StringRes 9 | import androidx.core.content.ContextCompat 10 | import androidx.fragment.app.Fragment 11 | import androidx.lifecycle.ViewModelProvider 12 | import androidx.lifecycle.ViewModelProviders 13 | import com.google.android.material.snackbar.Snackbar 14 | import com.inspiringteam.xchange.BaseView 15 | import com.inspiringteam.xchange.R 16 | import com.inspiringteam.xchange.data.models.Quake 17 | import com.inspiringteam.xchange.di.scopes.ActivityScoped 18 | import io.reactivex.android.schedulers.AndroidSchedulers 19 | import io.reactivex.disposables.CompositeDisposable 20 | import io.reactivex.schedulers.Schedulers 21 | import kotlinx.android.synthetic.main.fragment_quakes.* 22 | import java.util.* 23 | import javax.inject.Inject 24 | 25 | @ActivityScoped 26 | class QuakesFragment @Inject constructor() : Fragment(), BaseView { 27 | private var viewModel: QuakesViewModel? = null 28 | private var adapter: QuakesAdapter? = null 29 | private var subscription: CompositeDisposable = CompositeDisposable() 30 | 31 | @JvmField 32 | @Inject 33 | var viewModelFactory: ViewModelProvider.Factory? = null 34 | 35 | override fun onCreate(savedInstanceState: Bundle?) { 36 | super.onCreate(savedInstanceState) 37 | viewModel = ViewModelProviders.of(this, viewModelFactory).get( 38 | QuakesViewModel::class.java 39 | ) 40 | } 41 | 42 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 43 | super.onViewCreated(view, savedInstanceState) 44 | adapter = QuakesAdapter(mutableListOf()) 45 | quakesListView.adapter = adapter 46 | setupSwipeRefreshLayout() 47 | } 48 | 49 | override fun onCreateView( 50 | inflater: LayoutInflater, container: ViewGroup?, 51 | savedInstanceState: Bundle? 52 | ): View? { 53 | return inflater.inflate(R.layout.fragment_quakes, container, false) 54 | } 55 | 56 | override fun onResume() { 57 | super.onResume() 58 | bindViewModel() 59 | } 60 | 61 | override fun onPause() { 62 | super.onPause() 63 | unbindViewModel() 64 | } 65 | 66 | override fun bindViewModel() { 67 | // Use a CompositeDisposable to gather all the subscriptions, so all of them can be 68 | // later unsubscribed together 69 | subscription = CompositeDisposable() 70 | 71 | // The ViewModel holds an observable containing the state of the UI. 72 | // Subscribe to the emissions of the Ui Model 73 | // Update the view at every emission of the UI Model 74 | subscription.add( 75 | viewModel!!.getUiModel(false) 76 | .subscribeOn(Schedulers.computation()) 77 | .observeOn(AndroidSchedulers.mainThread()) 78 | .subscribe( 79 | { model: QuakesUiModel -> updateView(model) } 80 | ) //onError 81 | { error: Throwable? -> 82 | Log.d(TAG, "Error loading quakes") 83 | error?.printStackTrace() 84 | }) 85 | 86 | // Subscribe to the emissions of the snackbar text 87 | subscription.add( 88 | viewModel!!.snackBarMessage 89 | .subscribeOn(Schedulers.computation()) 90 | .observeOn(AndroidSchedulers.mainThread()) 91 | .subscribe( 92 | { message: Int -> showSnackbar(message) } 93 | ) //onError 94 | { error: Throwable? -> Log.d(TAG, "Error showing snackbar", error) }) 95 | 96 | // Subscribe to the emissions of the loading indicator visibility 97 | subscription.add( 98 | viewModel!!.loadingIndicatorVisibility 99 | .subscribeOn(Schedulers.computation()) 100 | .observeOn(AndroidSchedulers.mainThread()) 101 | .subscribe( 102 | { isVisible: Boolean -> setLoadingIndicatorVisibility(isVisible) } 103 | ) //onError 104 | { error: Throwable? -> Log.d(TAG, "Error showing loading indicator", error) }) 105 | 106 | // Bind Chrome tabs service 107 | viewModel!!.bindTabsService() 108 | } 109 | 110 | override fun unbindViewModel() { 111 | // Unbind tabs service 112 | viewModel?.unbindTabsService() 113 | } 114 | 115 | private fun setupSwipeRefreshLayout() { 116 | refreshLayout.setColorSchemeColors( 117 | ContextCompat.getColor(requireActivity(), R.color.colorPrimary), 118 | ContextCompat.getColor(requireActivity(), R.color.colorAccent), 119 | ContextCompat.getColor(requireActivity(), R.color.colorPrimaryDark) 120 | ) 121 | // Set the scrolling view in the custom SwipeRefreshLayout. 122 | refreshLayout.setScrollUpChild(quakesListView) 123 | refreshLayout.setOnRefreshListener { forceUpdate() } 124 | } 125 | 126 | private fun forceUpdate() { 127 | subscription.add( 128 | viewModel!!.getUiModel(true) 129 | .subscribeOn(Schedulers.computation()) 130 | .observeOn(AndroidSchedulers.mainThread()) 131 | .subscribe( 132 | { model: QuakesUiModel -> updateView(model) } 133 | ) //onError 134 | { error: Throwable? -> 135 | Log.d( 136 | TAG, 137 | "Error loading quakes: " + error?.localizedMessage 138 | ) 139 | }) 140 | } 141 | 142 | private fun updateView(model: QuakesUiModel) { 143 | val ratesListVisibility = if (model.isQuakesListVisible) View.VISIBLE else View.GONE 144 | val noQuakesViewVisibility = if (model.isNoQuakesViewVisible) View.VISIBLE else View.GONE 145 | quakesLayout.visibility = ratesListVisibility 146 | noQuakesLayout.visibility = noQuakesViewVisibility 147 | if (model.isQuakesListVisible) 148 | showQuakes(model.quakes) 149 | 150 | if (model.isNoQuakesViewVisible && model.noQuakesModel != null) 151 | showNoQuakes(model.noQuakesModel) 152 | } 153 | 154 | private fun showNoQuakes(model: NoQuakesModel?) { 155 | noQuakesFoundTextView.setText(model!!.text) 156 | } 157 | 158 | private fun showQuakes(quakes: List) { 159 | adapter!!.replaceData(quakes.toMutableList()) 160 | } 161 | 162 | private fun showSnackbar(@StringRes message: Int) { 163 | Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show() 164 | } 165 | 166 | private fun setLoadingIndicatorVisibility(isVisible: Boolean) { 167 | // Make sure isRefreshing is called after the layout is done with everything else. 168 | refreshLayout.post { refreshLayout.isRefreshing = isVisible } 169 | } 170 | 171 | companion object { 172 | private val TAG = QuakesFragment::class.java.simpleName 173 | } 174 | } -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | apply plugin: 'kotlin-android-extensions' 5 | 6 | android { 7 | compileSdkVersion rootProject.ext.compileSdkVersion 8 | 9 | defaultConfig { 10 | applicationId "com.inspiringteam.xchange" 11 | minSdkVersion rootProject.ext.minSdkVersion 12 | targetSdkVersion rootProject.ext.targetSdkVersion 13 | versionCode 1 14 | versionName "1.0" 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | multiDexEnabled true 17 | } 18 | 19 | compileOptions { 20 | sourceCompatibility JavaVersion.VERSION_1_8 21 | targetCompatibility JavaVersion.VERSION_1_8 22 | } 23 | 24 | buildTypes { 25 | debug { 26 | // Minifying the variant used for tests is not supported when using Jack. 27 | minifyEnabled false 28 | // Uses new built-in shrinker http://tools.android.com/tech-docs/new-build-system/built-in-shrinker 29 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 30 | testProguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguardTest-rules.pro' 31 | } 32 | 33 | release { 34 | minifyEnabled true 35 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 36 | testProguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguardTest-rules.pro' 37 | } 38 | } 39 | 40 | // Using gradle plugin 3 you need to specify flavor dimensions. 41 | flavorDimensions 'buildType' 42 | 43 | // If you need to add more flavors, consider using flavor dimensions. 44 | productFlavors { 45 | mock { 46 | dimension 'buildType' 47 | applicationIdSuffix = ".mock" 48 | } 49 | prod { 50 | dimension 'buildType' 51 | } 52 | } 53 | 54 | // Remove mockRelease as it's not needed. 55 | android.variantFilter { variant -> 56 | if (variant.buildType.name.equals('release') 57 | && variant.getFlavors().get(0).name.equals('mock')) { 58 | variant.setIgnore(true) 59 | } 60 | } 61 | 62 | // Always show the result of every unit test, even if it passes. 63 | testOptions.unitTests.all { 64 | testLogging { 65 | events 'passed', 'skipped', 'failed', 'standardOut', 'standardError' 66 | } 67 | } 68 | } 69 | 70 | dependencies { 71 | // App's dependencies, including test 72 | implementation "androidx.appcompat:appcompat:$rootProject.androidxVersion" 73 | implementation "androidx.cardview:cardview:1.0.0" 74 | implementation "com.google.android.material:material:$rootProject.supportLibraryVersion" 75 | implementation "androidx.recyclerview:recyclerview:1.2.1" 76 | implementation "androidx.legacy:legacy-support-v4:1.0.0" 77 | implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayout" 78 | implementation "com.google.guava:guava:24.1-jre" 79 | 80 | implementation "androidx.room:room-rxjava2:$rootProject.roomVersion" 81 | implementation "androidx.room:room-runtime:$rootProject.roomVersion" 82 | kapt "androidx.room:room-compiler:$rootProject.roomVersion" 83 | implementation "androidx.room:room-ktx:2.3.0" 84 | androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion" 85 | 86 | // Dagger dependencies 87 | kapt "com.google.dagger:dagger-compiler:$rootProject.daggerVersion" 88 | annotationProcessor "com.google.dagger:dagger-android-processor:$rootProject.daggerVersion" 89 | implementation "com.google.dagger:dagger:$rootProject.daggerVersion" 90 | implementation "com.google.dagger:dagger-android-support:$rootProject.daggerVersion" 91 | kapt "com.google.dagger:dagger-android-processor:$rootProject.daggerVersion" 92 | 93 | // Android Architecture components dependencies 94 | implementation "androidx.lifecycle:lifecycle-runtime-ktx:$arch_version" 95 | implementation "androidx.lifecycle:lifecycle-extensions:$arch_version" 96 | kapt "androidx.lifecycle:lifecycle-compiler:$arch_version" 97 | 98 | // RxJava dependencies 99 | implementation "io.reactivex.rxjava2:rxandroid:$rootProject.rxandroidVersion" 100 | implementation "io.reactivex.rxjava2:rxjava:$rootProject.rxjavaVersion" 101 | implementation "io.reactivex:rxandroid:$rootProject.rx1androidVersion" 102 | 103 | // Retrofit dependencies 104 | implementation "com.squareup.retrofit2:retrofit:$rootProject.retrofitClientVersion" 105 | implementation "com.squareup.retrofit2:adapter-rxjava2:$rootProject.retrofitAdapterRxJavaVersion" 106 | implementation "com.google.code.gson:gson:$rootProject.gsonVersion" 107 | implementation "com.squareup.retrofit2:converter-gson:$rootProject.gsonConverterVersion" 108 | 109 | // Chrome Custom Tabs dependencies 110 | implementation "androidx.browser:browser:$rootProject.supportCustomTabsVersion" 111 | 112 | // Dependencies for local unit tests 113 | testImplementation "junit:junit:$rootProject.ext.junitVersion" 114 | testImplementation "org.mockito:mockito-all:$rootProject.ext.mockitoVersion" 115 | testImplementation "org.hamcrest:hamcrest-all:$rootProject.ext.hamcrestVersion" 116 | 117 | // Android Testing Support Library's runner and rules 118 | androidTestImplementation "androidx.test:runner:$rootProject.ext.runnerVersion" 119 | androidTestImplementation "androidx.test:rules:$rootProject.ext.runnerVersion" 120 | 121 | // Dependencies for Android unit tests 122 | androidTestImplementation "junit:junit:$rootProject.ext.junitVersion" 123 | androidTestImplementation "org.mockito:mockito-core:$rootProject.ext.mockitoVersion" 124 | androidTestImplementation 'com.google.dexmaker:dexmaker:1.2' 125 | androidTestImplementation 'com.google.dexmaker:dexmaker-mockito:1.2' 126 | 127 | // Espresso UI Testing 128 | androidTestImplementation "androidx.test.espresso:espresso-core:$rootProject.espressoVersion" 129 | androidTestImplementation "androidx.test.espresso:espresso-contrib:$rootProject.espressoVersion" 130 | androidTestImplementation "androidx.test.espresso:espresso-intents:$rootProject.espressoVersion" 131 | implementation "androidx.test.espresso:espresso-idling-resource:$rootProject.espressoVersion" 132 | 133 | // Resolve conflicts between main and test APK: 134 | androidTestImplementation "androidx.annotation:annotation:$rootProject.supportLibraryVersion" 135 | androidTestImplementation "androidx.legacy:legacy-support-v4:$rootProject.supportLibraryVersion" 136 | androidTestImplementation "androidx.recyclerview:recyclerview:$rootProject.supportLibraryVersion" 137 | androidTestImplementation "androidx.appcompat:appcompat:$rootProject.androidxVersion" 138 | androidTestImplementation "com.google.android.material:material:$rootProject.supportLibraryVersion" 139 | androidTestImplementation "com.google.code.findbugs:jsr305:3.0.2" 140 | 141 | testImplementation ("androidx.arch.core:core-testing:$arch_testing", { 142 | exclude group: 'com.android.support', module: 'support-compat' 143 | exclude group: 'com.android.support', module: 'support-annotations' 144 | exclude group: 'com.android.support', module: 'support-core-utils' 145 | }) 146 | 147 | androidTestImplementation("androidx.arch.core:core-testing:$arch_testing", { 148 | }) 149 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 150 | } 151 | repositories { 152 | mavenCentral() 153 | } 154 | -------------------------------------------------------------------------------- /app/src/test/java/com/inspiringteam/xchange/data/source/QuakesRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package com.inspiringteam.xchange.data.source 2 | 3 | import com.inspiringteam.xchange.data.models.Quake 4 | import com.inspiringteam.xchange.util.ConnectivityUtils.OnlineChecker 5 | import io.reactivex.Single 6 | import io.reactivex.subscribers.TestSubscriber 7 | import org.junit.Before 8 | import org.junit.Test 9 | import org.mockito.Mock 10 | import org.mockito.Mockito 11 | import org.mockito.MockitoAnnotations 12 | import java.util.* 13 | 14 | /** 15 | * Unit tests for the implementation of the repository 16 | */ 17 | class QuakesRepositoryTest { 18 | private val testTime = System.currentTimeMillis() 19 | private val staleTime = (6 * 60 * 1000).toLong() 20 | 21 | private val QUAKES_RECENT: ArrayList = arrayListOf( 22 | Quake(4.5, "Place1", testTime, testTime), 23 | Quake(3.3, "Place2", testTime - 1, testTime) 24 | ) 25 | private val QUAKES_STALE: ArrayList = arrayListOf( 26 | Quake(4.5, "Place1", testTime, testTime - staleTime), 27 | Quake(3.3, "Place2", testTime - 1, testTime) 28 | ) 29 | 30 | private var mQuakesRepository: QuakesRepository? = null 31 | 32 | private var mQuakesTestSubscriber: TestSubscriber>? = null 33 | 34 | @Mock 35 | private val mQuakesRemoteDataSource: QuakesDataSource? = null 36 | 37 | @Mock 38 | private val mQuakesLocalDataSource: QuakesDataSource? = null 39 | 40 | @Mock 41 | private val mOnlineChecker: OnlineChecker? = null 42 | 43 | @Before 44 | fun setupQuakesRepository() { 45 | // Mockito has a very convenient way to inject mocks by using the @Mock annotation. To 46 | // inject the mocks in the test the initMocks method needs to be called. 47 | MockitoAnnotations.initMocks(this) 48 | 49 | // Get a reference to the class under test 50 | mQuakesRepository = QuakesRepository( 51 | mQuakesRemoteDataSource!!, 52 | mQuakesLocalDataSource!!, mOnlineChecker!! 53 | ) 54 | mQuakesTestSubscriber = TestSubscriber>() 55 | } 56 | 57 | /** 58 | * Offline Test scenario states: 59 | * As the disk has up-to-date items, upon querying the repository with no internet connection, 60 | * the Local Data Source should be accessed and correct items should be retrieved 61 | */ 62 | @Test 63 | fun getQuakesOffline_requestsQuakesFromLocalDataSource() { 64 | 65 | // the local data source has up-to-date data available 66 | ArrangeBuilder() 67 | .withQuakesAvailable(mQuakesLocalDataSource, QUAKES_RECENT) 68 | 69 | // establish a fake internet connection status 70 | Mockito.`when`(mOnlineChecker!!.isOnline()).thenReturn(false) 71 | 72 | // When quakes are requested from the quakes repository 73 | mQuakesRepository!!.getQuakes().toFlowable().subscribe(mQuakesTestSubscriber) 74 | 75 | // Then quakes are loaded from the local data source 76 | Mockito.verify(mQuakesLocalDataSource).getQuakes() 77 | mQuakesTestSubscriber!!.assertValue(QUAKES_RECENT) 78 | } 79 | 80 | /** 81 | * Online Test scenario states: 82 | * As the disk has up-to-date items, upon querying the repository with active internet connection, 83 | * the Local Data Source should be accessed and correct items should be retrieved 84 | */ 85 | @Test 86 | fun getQuakesOnline_requestsQuakesFromLocalDataSource_upToDateLocal() { 87 | 88 | // the local data source has up-to-date data available 89 | ArrangeBuilder() 90 | .withQuakesAvailable(mQuakesLocalDataSource, QUAKES_RECENT) 91 | 92 | // establish a fake internet connection status 93 | Mockito.`when`(mOnlineChecker!!.isOnline()).thenReturn(true) 94 | 95 | // When quakes are requested from the quakes repository 96 | mQuakesRepository!!.getQuakes().toFlowable().subscribe(mQuakesTestSubscriber) 97 | 98 | // Then quakes are loaded from the local data source 99 | Mockito.verify(mQuakesLocalDataSource).getQuakes() 100 | mQuakesTestSubscriber!!.assertValue(QUAKES_RECENT) 101 | } 102 | 103 | /** 104 | * Online Test scenario states: 105 | * As the disk has stale items, upon querying the repository with active internet connection, 106 | * the Remote Data Source should be accessed and correct items should be retrieved 107 | */ 108 | @Test 109 | fun getQuakesOnline_requestsQuakesFromRemoteDataSource_staleLocal() { 110 | 111 | // the remote data source has fresh data available 112 | ArrangeBuilder() 113 | .withQuakesAvailable(mQuakesRemoteDataSource, QUAKES_RECENT) 114 | 115 | // the local data source has stale data available 116 | ArrangeBuilder() 117 | .withQuakesAvailable(mQuakesLocalDataSource, QUAKES_STALE) 118 | 119 | // establish a fake internet connection status 120 | Mockito.`when`(mOnlineChecker!!.isOnline()).thenReturn(true) 121 | 122 | // When quakes are requested from the quakes repository 123 | mQuakesRepository!!.getQuakes().toFlowable().subscribe(mQuakesTestSubscriber) 124 | 125 | // Both sources should be queried, yet the local source has stale items 126 | // which triggers the call to the remote source 127 | Mockito.verify(mQuakesLocalDataSource).getQuakes() 128 | Mockito.verify(mQuakesLocalDataSource).getQuakes() 129 | mQuakesTestSubscriber!!.assertValue(QUAKES_RECENT) 130 | } 131 | 132 | /** 133 | * Online Test scenario states: 134 | * As the disk has no items, upon querying the repository with active internet connection, 135 | * the Remote Data Source should be accessed and correct items should be retrieved 136 | */ 137 | @Test 138 | fun getQuakesOnline_requestsQuakesFromRemoteDataSource_emptyLocal() { 139 | 140 | // the remote data source has fresh data available 141 | ArrangeBuilder() 142 | .withQuakesAvailable((mQuakesRemoteDataSource)!!, QUAKES_RECENT) 143 | 144 | // the local data source has stale data available 145 | ArrangeBuilder() 146 | .withQuakesNotAvailable((mQuakesLocalDataSource)!!) 147 | 148 | // establish a fake internet connection status 149 | Mockito.`when`(mOnlineChecker!!.isOnline()).thenReturn(true) 150 | 151 | // When quakes are requested from the quakes repository 152 | mQuakesRepository!!.getQuakes().toFlowable().subscribe(mQuakesTestSubscriber) 153 | 154 | // Both sources should be queried, yet the local source has no items 155 | // which triggers the call to the remote source 156 | Mockito.verify(mQuakesLocalDataSource).getQuakes() 157 | Mockito.verify(mQuakesLocalDataSource).getQuakes() 158 | mQuakesTestSubscriber!!.assertValue(QUAKES_RECENT) 159 | } 160 | 161 | /** 162 | * Test scenario states: 163 | * Upon get command , both Local Data Source should retrieve the item locally 164 | */ 165 | @Test 166 | fun getQuakeFromLocal() { 167 | val tempQuake: Quake = Quake("id", "location") 168 | ArrangeBuilder().withQuakeById((mQuakesLocalDataSource)!!, tempQuake) 169 | mQuakesRepository!!.getQuake(tempQuake.id).toFlowable().subscribe() 170 | 171 | // upon get command, check if only local data source is being called 172 | Mockito.verify(mQuakesLocalDataSource).getQuake(tempQuake.id) 173 | Mockito.verify(mQuakesRemoteDataSource, Mockito.never()).getQuake(tempQuake.id) 174 | } 175 | 176 | 177 | /** 178 | * Test scenario states: 179 | * Upon save command , both Local Data Source and Remote Data Source should save 180 | * the corresponding items taken as parameter 181 | */ 182 | @Test 183 | fun saveQuakes() { 184 | mQuakesRepository!!.saveQuakes(QUAKES_RECENT) 185 | 186 | // upon save command, check if both data sources are being called 187 | Mockito.verify(mQuakesLocalDataSource).saveQuakes(QUAKES_RECENT) 188 | Mockito.verify(mQuakesRemoteDataSource).saveQuakes(QUAKES_RECENT) 189 | } 190 | 191 | /** 192 | * Test scenario states: 193 | * Upon save command , both Local Data Source and Remote Data Source should save 194 | * the corresponding item taken as parameter 195 | */ 196 | @Test 197 | fun saveQuake() { 198 | mQuakesRepository!!.saveQuake(QUAKES_RECENT.get(0)) 199 | 200 | // upon save command, check if both data sources are being called 201 | Mockito.verify(mQuakesLocalDataSource).saveQuake(QUAKES_RECENT.get(0)) 202 | Mockito.verify(mQuakesRemoteDataSource).saveQuake(QUAKES_RECENT.get(0)) 203 | } 204 | 205 | /** 206 | * Test scenario states: 207 | * Upon delete command , both Local Data Source and Remote Data Source should delete 208 | * all items 209 | */ 210 | @Test 211 | fun deleteQuakes() { 212 | mQuakesRepository!!.deleteAllQuakes() 213 | 214 | // upon save command, check if both data sources are being called 215 | Mockito.verify(mQuakesLocalDataSource).deleteAllQuakes() 216 | Mockito.verify(mQuakesRemoteDataSource).deleteAllQuakes() 217 | } 218 | 219 | /** 220 | * Test scenario states: 221 | * Upon delete command , both Local Data Source and Remote Data Source should delete 222 | * the corresponding item 223 | */ 224 | @Test 225 | fun deleteQuake() { 226 | mQuakesRepository!!.deleteQuake("id") 227 | 228 | // upon save command, check if both data sources are being called 229 | Mockito.verify(mQuakesLocalDataSource).deleteQuake("id") 230 | Mockito.verify(mQuakesRemoteDataSource).deleteQuake("id") 231 | } 232 | 233 | 234 | internal class ArrangeBuilder constructor() { 235 | fun withQuakesNotAvailable(dataSource: QuakesDataSource): ArrangeBuilder { 236 | Mockito.`when`>(dataSource.getQuakes()) 237 | .thenReturn(Single.just(emptyList())) 238 | return this 239 | } 240 | 241 | fun withQuakesAvailable( 242 | dataSource: QuakesDataSource, 243 | quakes: kotlin.collections.MutableList? 244 | ): ArrangeBuilder { 245 | // don't allow the data sources to complete. 246 | Mockito.`when`>(dataSource.getQuakes()) 247 | .thenReturn(Single.just(quakes)) 248 | return this 249 | } 250 | 251 | fun withQuakeById(dataSource: QuakesDataSource, quake: Quake): ArrangeBuilder { 252 | // don't allow the data sources to complete. 253 | Mockito.`when`(dataSource.getQuake(quake.id)).thenReturn(Single.just(quake)) 254 | return this 255 | } 256 | } 257 | } --------------------------------------------------------------------------------