├── .gitignore ├── .idea ├── gradle.xml ├── misc.xml ├── modules.xml └── runConfigurations.xml ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── io │ │ └── spacenoodles │ │ └── makingyourappreactive │ │ ├── App.kt │ │ ├── dagger │ │ ├── NetInterceptor.kt │ │ ├── component │ │ │ └── AppComponent.kt │ │ └── module │ │ │ ├── AppModule.kt │ │ │ ├── NetModule.kt │ │ │ └── RepositoryModule.kt │ │ ├── model │ │ ├── Image.kt │ │ ├── ImagePost.kt │ │ ├── ImgurResponse.kt │ │ └── Tag.kt │ │ ├── sync │ │ ├── NetService.kt │ │ └── repository │ │ │ └── ImagePostRepository.kt │ │ ├── util │ │ └── extension │ │ │ ├── ContextExtensions.kt │ │ │ ├── ProgressBarExtensions.kt │ │ │ ├── RxExtensions.kt │ │ │ ├── StringExtensions.kt │ │ │ └── ViewGroupExtensions.kt │ │ ├── view │ │ ├── activity │ │ │ ├── BaseActivity.kt │ │ │ ├── ImageDetailActivity.kt │ │ │ └── MainActivity.kt │ │ └── adapter │ │ │ ├── ImagePostAdapter.kt │ │ │ └── listener │ │ │ └── LazyLoadRecyclerViewListener.kt │ │ └── viewModel │ │ ├── MainActivityViewModel.kt │ │ └── state │ │ ├── MainActivityState.kt │ │ └── Status.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ ├── ic_arrow_upward_black_24dp.xml │ ├── ic_image_24dp.xml │ ├── ic_launcher_background.xml │ ├── ic_link_black_24dp.xml │ └── ic_search_white_24dp.xml │ ├── layout │ ├── activity_image_detail.xml │ ├── activity_main.xml │ └── list_item_image_post.xml │ ├── menu │ └── search_menu.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ └── values │ ├── colors.xml │ ├── dimens.xml │ ├── strings.xml │ └── styles.xml ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/workspace.xml 5 | /.idea/libraries 6 | .DS_Store 7 | /build 8 | /captures 9 | .externalNativeBuild 10 | .idea/vcs.xml 11 | app/src/androidTest/ 12 | app/src/test/ 13 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 20 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | Groovy 40 | 41 | 42 | Java 43 | 44 | 45 | Potentially confusing code constructsGroovy 46 | 47 | 48 | Threading issuesJava 49 | 50 | 51 | 52 | 53 | Android 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 65 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MakingYourAppReactive 2 | A demo app that shows how to implement RxJava and MVVM. 3 | 4 | # Notice 5 | If you wish to use this code in your own project, please change the client id in NetInterceptor to use your own value. You must use your own Imgur client id in order to access Imgur's API. 6 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | apply plugin: 'kotlin-android' 4 | 5 | apply plugin: 'kotlin-android-extensions' 6 | 7 | apply plugin: 'kotlin-kapt' 8 | 9 | android { 10 | compileSdkVersion 27 11 | defaultConfig { 12 | applicationId "io.spacenoodles.makingyourappreactive" 13 | minSdkVersion 21 14 | targetSdkVersion 27 15 | versionCode 1 16 | versionName "1.0" 17 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 18 | multiDexEnabled true 19 | } 20 | buildTypes { 21 | release { 22 | minifyEnabled false 23 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 24 | } 25 | } 26 | } 27 | 28 | dependencies { 29 | implementation fileTree(dir: 'libs', include: ['*.jar']) 30 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" 31 | implementation "com.android.support:appcompat-v7:$support_lib_version" 32 | implementation 'com.android.support.constraint:constraint-layout:1.0.2' 33 | implementation "com.android.support:cardview-v7:$support_lib_version" 34 | implementation "com.android.support:recyclerview-v7:$support_lib_version" 35 | implementation 'com.android.support:design:27.0.2' 36 | testImplementation 'junit:junit:4.12' 37 | androidTestImplementation 'com.android.support.test:runner:1.0.1' 38 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1' 39 | 40 | //Rx 41 | implementation 'com.jakewharton.rxbinding2:rxbinding:2.0.0' 42 | implementation "io.reactivex.rxjava2:rxandroid:$rxandroid_version" 43 | implementation "io.reactivex.rxjava2:rxjava:$rxjava2_version" 44 | 45 | //Lifecycle, LiveData and ViewModel 46 | implementation "android.arch.lifecycle:runtime:1.0.3" 47 | implementation "android.arch.lifecycle:extensions:1.0.0" 48 | kapt "android.arch.lifecycle:compiler:1.0.0" 49 | 50 | //Dependency Injection Framework 51 | implementation "com.google.dagger:dagger:$dagger_version" 52 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 53 | 54 | //Networking and Communication 55 | implementation "com.google.code.gson:gson:$gson_version" 56 | implementation "com.squareup.retrofit2:converter-gson:$retrofit_version" 57 | implementation("com.squareup.retrofit2:retrofit:$retrofit_version") { 58 | //Exclude Retrofit’s OkHttp peer-dependency module - we define our own 59 | exclude module: 'okhttp' 60 | } 61 | implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofit_version" 62 | implementation 'com.squareup.okio:okio:1.13.0' 63 | implementation "com.squareup.okhttp3:okhttp:$okhttp_version" 64 | implementation 'com.squareup.picasso:picasso:2.5.2' 65 | implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version" 66 | 67 | implementation 'javax.annotation:javax.annotation-api:1.3.2' 68 | annotationProcessor("javax.annotation:javax.annotation-api:1.3.2") 69 | } 70 | -------------------------------------------------------------------------------- /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/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 27 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/App.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive 2 | 3 | import android.app.Application 4 | import io.spacenoodles.makingyourappreactive.dagger.component.AppComponent 5 | import io.spacenoodles.makingyourappreactive.dagger.component.DaggerAppComponent 6 | import io.spacenoodles.makingyourappreactive.dagger.module.AppModule 7 | 8 | class App : Application() { 9 | companion object { 10 | lateinit var component: AppComponent 11 | } 12 | 13 | override fun onCreate() { 14 | super.onCreate() 15 | component = DaggerAppComponent 16 | .builder() 17 | .appModule(AppModule(this)) 18 | .build() 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/dagger/NetInterceptor.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.dagger 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Response 5 | 6 | class NetInterceptor : Interceptor { 7 | override fun intercept(chain: Interceptor.Chain): Response { 8 | val ongoing = chain.request().newBuilder() 9 | 10 | ongoing.header("Authorization", "Client-ID 37398025a821c1b") 11 | ongoing.addHeader("Content-Type", "application/json") 12 | 13 | return chain.proceed(ongoing.build()) 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/dagger/component/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.dagger.component 2 | 3 | import dagger.Component 4 | import io.spacenoodles.makingyourappreactive.dagger.module.AppModule 5 | import io.spacenoodles.makingyourappreactive.dagger.module.NetModule 6 | import io.spacenoodles.makingyourappreactive.dagger.module.RepositoryModule 7 | import io.spacenoodles.makingyourappreactive.viewModel.MainActivityViewModel 8 | import javax.inject.Singleton 9 | 10 | @Singleton 11 | @Component(modules = arrayOf( 12 | AppModule::class, 13 | NetModule::class, 14 | RepositoryModule::class) 15 | ) interface AppComponent { 16 | fun inject(mainActivityViewModel: MainActivityViewModel) 17 | } -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/dagger/module/AppModule.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.dagger.module 2 | 3 | import android.content.Context 4 | import dagger.Module 5 | import dagger.Provides 6 | import javax.inject.Singleton 7 | 8 | @Module 9 | class AppModule(internal val context: Context) { 10 | @Provides 11 | @Singleton 12 | internal fun provideContext(): Context { 13 | return context 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/dagger/module/NetModule.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.dagger.module 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import io.spacenoodles.makingyourappreactive.BuildConfig 6 | import io.spacenoodles.makingyourappreactive.dagger.NetInterceptor 7 | import io.spacenoodles.makingyourappreactive.sync.NetService 8 | import okhttp3.OkHttpClient 9 | import retrofit2.Retrofit 10 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 11 | import retrofit2.converter.gson.GsonConverterFactory 12 | import java.util.concurrent.TimeUnit 13 | import javax.inject.Singleton 14 | 15 | @Module 16 | class NetModule { 17 | companion object { 18 | val BASE_URL = "https://api.imgur.com/3/" 19 | val OKHTTP_TIMEOUT = 15L //In Seconds 20 | } 21 | @Provides 22 | @Singleton 23 | internal fun provideNetInterceptor(): NetInterceptor { 24 | return NetInterceptor() 25 | } 26 | 27 | @Provides 28 | @Singleton 29 | internal fun provideHttpClient(interceptor: NetInterceptor): OkHttpClient { 30 | val builder = OkHttpClient.Builder() 31 | .connectTimeout(OKHTTP_TIMEOUT, TimeUnit.SECONDS) 32 | .writeTimeout(OKHTTP_TIMEOUT, TimeUnit.SECONDS) 33 | .readTimeout(OKHTTP_TIMEOUT, TimeUnit.SECONDS) 34 | .addInterceptor(interceptor) 35 | 36 | try { 37 | if(BuildConfig.DEBUG) { 38 | val loggingInterceptor = okhttp3.logging.HttpLoggingInterceptor() 39 | loggingInterceptor.level = okhttp3.logging.HttpLoggingInterceptor.Level.BODY 40 | builder.addInterceptor(loggingInterceptor) 41 | } 42 | } catch(e: Exception) { 43 | e.printStackTrace() 44 | } 45 | 46 | return builder.build() 47 | } 48 | 49 | @Provides 50 | internal fun provideNetService(okHttpClient: OkHttpClient, interceptor: NetInterceptor): NetService { 51 | 52 | val tokenClient = okHttpClient.newBuilder() 53 | .addInterceptor(interceptor) 54 | .build() 55 | 56 | val retrofit = Retrofit.Builder() 57 | .baseUrl(BASE_URL) 58 | .addConverterFactory(GsonConverterFactory.create()) 59 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 60 | .client(tokenClient) 61 | .build() 62 | return retrofit.create(NetService::class.java) 63 | } 64 | } -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/dagger/module/RepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.dagger.module 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import io.spacenoodles.makingyourappreactive.sync.NetService 6 | import io.spacenoodles.makingyourappreactive.sync.repository.ImagePostRepository 7 | import javax.inject.Singleton 8 | 9 | @Module 10 | class RepositoryModule { 11 | @Provides 12 | @Singleton 13 | internal fun provideImagePostRepository(netService: NetService): ImagePostRepository { 14 | return ImagePostRepository(netService) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/model/Image.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.model 2 | 3 | import com.google.gson.annotations.Expose 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Image ( 7 | @SerializedName("id") 8 | @Expose 9 | val id: String? = null, 10 | @SerializedName("title") 11 | @Expose 12 | val title: String? = null, 13 | @SerializedName("description") 14 | @Expose 15 | val description: String? = null, 16 | @SerializedName("datetime") 17 | @Expose 18 | val datetime: Long? = null, 19 | @SerializedName("type") 20 | @Expose 21 | val type: String? = null, 22 | @SerializedName("animated") 23 | @Expose 24 | val animated: Boolean? = null, 25 | @SerializedName("width") 26 | @Expose 27 | val width: Long? = null, 28 | @SerializedName("height") 29 | @Expose 30 | val height: Long? = null, 31 | @SerializedName("size") 32 | @Expose 33 | val size: Long? = null, 34 | @SerializedName("views") 35 | @Expose 36 | val views: Long? = null, 37 | @SerializedName("bandwidth") 38 | @Expose 39 | val bandwidth: Long? = null, 40 | @SerializedName("favorite") 41 | @Expose 42 | val favorite: Boolean? = null, 43 | @SerializedName("nsfw") 44 | @Expose 45 | val nsfw: Boolean? = null, 46 | @SerializedName("is_ad") 47 | @Expose 48 | val isAd: Boolean? = null, 49 | @SerializedName("in_most_viral") 50 | @Expose 51 | val inMostViral: Boolean? = null, 52 | @SerializedName("has_sound") 53 | @Expose 54 | val hasSound: Boolean? = null, 55 | @SerializedName("tags") 56 | @Expose 57 | val tags: List? = null, 58 | @SerializedName("ad_type") 59 | @Expose 60 | val adType: Long? = null, 61 | @SerializedName("ad_url") 62 | @Expose 63 | val adUrl: String? = null, 64 | @SerializedName("in_gallery") 65 | @Expose 66 | val inGallery: Boolean? = null, 67 | @SerializedName("link") 68 | @Expose 69 | val link: String? = null, 70 | @SerializedName("favorite_count") 71 | @Expose 72 | val favoriteCount: Long? = null, 73 | @SerializedName("ups") 74 | @Expose 75 | val ups: Long? = null, 76 | @SerializedName("downs") 77 | @Expose 78 | val downs: Long? = null, 79 | @SerializedName("poLongs") 80 | @Expose 81 | val poLongs: Long? = null, 82 | @SerializedName("score") 83 | @Expose 84 | val score: Long? = null 85 | ) -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/model/ImagePost.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.model 2 | 3 | import com.google.gson.annotations.Expose 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class ImagePost ( 7 | @SerializedName("id") 8 | @Expose 9 | val id: String? = null, 10 | @SerializedName("title") 11 | @Expose 12 | val title: String? = null, 13 | @SerializedName("description") 14 | @Expose 15 | val description: String? = null, 16 | @SerializedName("datetime") 17 | @Expose 18 | val datetime: Long? = null, 19 | @SerializedName("cover") 20 | @Expose 21 | val cover: String? = null, 22 | @SerializedName("cover_width") 23 | @Expose 24 | val coverWidth: Long? = null, 25 | @SerializedName("cover_height") 26 | @Expose 27 | val coverHeight: Long? = null, 28 | @SerializedName("account_url") 29 | @Expose 30 | val accountUrl: String? = null, 31 | @SerializedName("account_id") 32 | @Expose 33 | val accountId: Long? = null, 34 | @SerializedName("privacy") 35 | @Expose 36 | val privacy: String? = null, 37 | @SerializedName("layout") 38 | @Expose 39 | val layout: String? = null, 40 | @SerializedName("views") 41 | @Expose 42 | val views: Long? = null, 43 | @SerializedName("link") 44 | @Expose 45 | val link: String? = null, 46 | @SerializedName("ups") 47 | @Expose 48 | val ups: Long? = null, 49 | @SerializedName("downs") 50 | @Expose 51 | val downs: Long? = null, 52 | @SerializedName("poLongs") 53 | @Expose 54 | val poLongs: Long? = null, 55 | @SerializedName("score") 56 | @Expose 57 | val score: Long? = null, 58 | @SerializedName("is_album") 59 | @Expose 60 | val isAlbum: Boolean? = null, 61 | @SerializedName("favorite") 62 | @Expose 63 | val favorite: Boolean? = null, 64 | @SerializedName("nsfw") 65 | @Expose 66 | val nsfw: Boolean? = null, 67 | @SerializedName("section") 68 | @Expose 69 | val section: String? = null, 70 | @SerializedName("comment_count") 71 | @Expose 72 | val commentCount: Long? = null, 73 | @SerializedName("favorite_count") 74 | @Expose 75 | val favoriteCount: Long? = null, 76 | @SerializedName("topic") 77 | @Expose 78 | val topic: String? = null, 79 | @SerializedName("topic_id") 80 | @Expose 81 | val topicId: Long? = null, 82 | @SerializedName("images_count") 83 | @Expose 84 | val imagesCount: Long? = null, 85 | @SerializedName("in_gallery") 86 | @Expose 87 | val inGallery: Boolean? = null, 88 | @SerializedName("is_ad") 89 | @Expose 90 | val isAd: Boolean? = null, 91 | @SerializedName("tags") 92 | @Expose 93 | val tags: List? = null, 94 | @SerializedName("ad_type") 95 | @Expose 96 | val adType: Long? = null, 97 | @SerializedName("ad_url") 98 | @Expose 99 | val adUrl: String? = null, 100 | @SerializedName("in_most_viral") 101 | @Expose 102 | val inMostViral: Boolean? = null, 103 | @SerializedName("images") 104 | @Expose 105 | val images: List? = null 106 | ) -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/model/ImgurResponse.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.model 2 | 3 | import com.google.gson.annotations.Expose 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class ImgurResponse( 7 | @SerializedName("data") 8 | @Expose 9 | val data: List? = null 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/model/Tag.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.model 2 | 3 | import com.google.gson.annotations.Expose 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Tag ( 7 | @SerializedName("name") 8 | @Expose 9 | val name: String? = null, 10 | @SerializedName("display_name") 11 | @Expose 12 | val displayName: String? = null, 13 | @SerializedName("followers") 14 | @Expose 15 | val followers: Long? = null, 16 | @SerializedName("total_items") 17 | @Expose 18 | val totalItems: Long? = null, 19 | @SerializedName("following") 20 | @Expose 21 | val following: Boolean? = null, 22 | @SerializedName("background_hash") 23 | @Expose 24 | val backgroundHash: String? = null, 25 | @SerializedName("accent") 26 | @Expose 27 | val accent: String? = null, 28 | @SerializedName("background_is_animated") 29 | @Expose 30 | val backgroundIsAnimated: Boolean? = null, 31 | @SerializedName("thumbnail_is_animated") 32 | @Expose 33 | val thumbnailIsAnimated: Boolean? = null, 34 | @SerializedName("is_promoted") 35 | @Expose 36 | val isPromoted: Boolean? = null, 37 | @SerializedName("description") 38 | @Expose 39 | val description: String? = null 40 | ) 41 | -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/sync/NetService.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.sync 2 | 3 | import io.reactivex.Maybe 4 | import io.spacenoodles.makingyourappreactive.model.ImgurResponse 5 | import retrofit2.http.GET 6 | import retrofit2.http.Path 7 | import retrofit2.http.Query 8 | 9 | interface NetService { 10 | 11 | @GET("gallery/search/time/{page}") 12 | fun requestImagePosts(@Path("page") page: Int, @Query("q") searchQuery: String) 13 | : Maybe 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/sync/repository/ImagePostRepository.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.sync.repository 2 | 3 | import io.reactivex.Maybe 4 | import io.reactivex.schedulers.Schedulers 5 | import io.spacenoodles.makingyourappreactive.model.ImgurResponse 6 | import io.spacenoodles.makingyourappreactive.sync.NetService 7 | import io.spacenoodles.makingyourappreactive.util.extension.maybe 8 | 9 | class ImagePostRepository(private val netService: NetService) { 10 | 11 | private var cache = ImagePostCache() 12 | 13 | fun getImagePosts(page: Int, searchQuery: String): Maybe { 14 | 15 | return Maybe 16 | .concat(cache.getPageOfImagePosts(page, searchQuery).subscribeOn(Schedulers.io()), 17 | netService.requestImagePosts(page, searchQuery) 18 | .doOnSuccess { 19 | cache.update(page, searchQuery, it) 20 | }.subscribeOn(Schedulers.io())) 21 | .firstElement() 22 | } 23 | 24 | class ImagePostCache { 25 | private var cachedImagePosts = HashMap() 26 | var searchQuery = "" 27 | 28 | private fun clear() { 29 | cachedImagePosts.clear() 30 | } 31 | 32 | fun getPageOfImagePosts(page: Int, searchQuery: String): Maybe { 33 | if (this.searchQuery != searchQuery) { 34 | clear() 35 | } 36 | this.searchQuery = searchQuery 37 | if (cachedImagePosts.keys.contains(page)) { 38 | return maybe(cachedImagePosts[page]) 39 | } 40 | return Maybe.empty() 41 | } 42 | 43 | fun update(page: Int, query: String, data: ImgurResponse) { 44 | if (this.searchQuery == query) 45 | cachedImagePosts[page] = data 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/util/extension/ContextExtensions.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.util.extension 2 | 3 | import android.content.Context 4 | import android.widget.Toast 5 | 6 | fun Context.toastLong(text: String) { 7 | Toast.makeText(this, text, Toast.LENGTH_LONG).show() 8 | } 9 | 10 | fun Context.toastLong(textRes: Int) = Toast 11 | .makeText(this, textRes, Toast.LENGTH_LONG) 12 | .show() -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/util/extension/ProgressBarExtensions.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.util.extension 2 | 3 | import android.graphics.PorterDuff 4 | import android.widget.ProgressBar 5 | 6 | fun ProgressBar?.setColor(color: Int) { 7 | if (this == null) return 8 | this.indeterminateDrawable?.let { 9 | it.setColorFilter(color, PorterDuff.Mode.SRC_IN) 10 | } 11 | this.progressDrawable?.let { 12 | it.setColorFilter(color, PorterDuff.Mode.SRC_IN) 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/util/extension/RxExtensions.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.util.extension 2 | 3 | import io.reactivex.Maybe 4 | 5 | fun maybe(value: T?): Maybe { 6 | return if(value == null) Maybe.empty() 7 | else Maybe.just(value) 8 | } -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/util/extension/StringExtensions.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.util.extension 2 | 3 | import android.webkit.URLUtil 4 | 5 | fun String?.isValidURL(): Boolean { 6 | if (this == null) return false 7 | return this.isNotEmpty() && URLUtil.isValidUrl(this) 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/util/extension/ViewGroupExtensions.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.util.extension 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.ViewGroup 6 | 7 | fun ViewGroup.inflate(layoutRes: Int): View { 8 | return LayoutInflater.from(context).inflate(layoutRes, this, false) 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/view/activity/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.view.activity 2 | 3 | import android.os.Bundle 4 | import android.support.v7.app.AppCompatActivity 5 | import io.reactivex.disposables.CompositeDisposable 6 | import io.reactivex.disposables.Disposable 7 | 8 | abstract class BaseActivity: AppCompatActivity() { 9 | private lateinit var disposables: CompositeDisposable 10 | 11 | override fun onCreate(savedInstanceState: Bundle?) { 12 | super.onCreate(savedInstanceState) 13 | init() 14 | } 15 | 16 | private fun init() { 17 | initRx() 18 | } 19 | 20 | private fun initRx() { 21 | disposables = CompositeDisposable() 22 | } 23 | 24 | @Synchronized protected fun addSub(disposable: Disposable?) { 25 | if (disposable == null) return 26 | disposables.add(disposable) 27 | } 28 | 29 | override fun onDestroy() { 30 | super.onDestroy() 31 | if (!disposables.isDisposed) disposables.dispose() 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/view/activity/ImageDetailActivity.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.view.activity 2 | 3 | import android.os.Bundle 4 | import android.support.v7.app.AppCompatActivity 5 | import android.util.Log 6 | import android.view.View 7 | import android.view.ViewTreeObserver 8 | import com.squareup.picasso.Callback 9 | import com.squareup.picasso.Picasso 10 | import io.spacenoodles.makingyourappreactive.R 11 | import kotlinx.android.synthetic.main.activity_image_detail.* 12 | import android.view.WindowManager 13 | 14 | 15 | class ImageDetailActivity : AppCompatActivity() { 16 | 17 | private var imageUrl = "" 18 | private var title = "" 19 | 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | super.onCreate(savedInstanceState) 22 | setContentView(R.layout.activity_image_detail) 23 | 24 | val w = window 25 | w.setFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) 26 | 27 | val extras = intent.extras 28 | extras?.let { 29 | imageUrl = it.getString(MainActivity.EXTRA_IMAGE_URL, "") 30 | title = it.getString(MainActivity.EXTRA_IMAGE_TITLE, "") 31 | } 32 | 33 | init() 34 | postponeEnterTransition() 35 | //TODO: Handle situations where picasso fails better 36 | } 37 | 38 | private fun init() { 39 | loadImage() 40 | setTitle() 41 | } 42 | 43 | private fun loadImage() { 44 | Picasso.with(this) 45 | .load(imageUrl) 46 | .placeholder(R.drawable.ic_image_24dp) 47 | .into(image_view, object: Callback { 48 | override fun onSuccess() { 49 | scheduleTransition(image_view) 50 | } 51 | 52 | override fun onError() { 53 | Log.i("HI", "HI") 54 | } 55 | }) 56 | } 57 | 58 | private fun setTitle() { 59 | title_text.text = title 60 | } 61 | 62 | private fun scheduleTransition(sharedElement: View) { 63 | sharedElement.viewTreeObserver.addOnPreDrawListener( 64 | object : ViewTreeObserver.OnPreDrawListener { 65 | override fun onPreDraw(): Boolean { 66 | sharedElement.viewTreeObserver.removeOnPreDrawListener(this) 67 | startPostponedEnterTransition() 68 | return true 69 | } 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/view/activity/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.view.activity 2 | 3 | import android.app.ActivityOptions 4 | import android.arch.lifecycle.Observer 5 | import android.arch.lifecycle.ViewModelProviders 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.os.Bundle 9 | import android.support.v4.content.ContextCompat 10 | import android.support.v4.view.ViewCompat 11 | import android.support.v7.widget.LinearLayoutManager 12 | import android.support.v7.widget.RecyclerView 13 | import android.util.Log 14 | import android.view.Menu 15 | import android.view.View 16 | import android.view.inputmethod.InputMethodManager 17 | import android.widget.SearchView 18 | import io.reactivex.android.schedulers.AndroidSchedulers 19 | import io.spacenoodles.makingyourappreactive.R 20 | import io.spacenoodles.makingyourappreactive.util.extension.setColor 21 | import io.spacenoodles.makingyourappreactive.util.extension.toastLong 22 | import io.spacenoodles.makingyourappreactive.view.adapter.listener.LazyLoadRecyclerViewListener 23 | import io.spacenoodles.makingyourappreactive.viewModel.MainActivityViewModel 24 | import io.spacenoodles.makingyourappreactive.viewModel.state.MainActivityState 25 | import io.spacenoodles.makingyourappreactive.viewModel.state.Status 26 | import kotlinx.android.synthetic.main.activity_main.* 27 | 28 | 29 | class MainActivity : BaseActivity() { 30 | 31 | private lateinit var viewModel: MainActivityViewModel 32 | 33 | private var searchView: SearchView? = null 34 | private var lazyListener: LazyLoadRecyclerViewListener? = null 35 | 36 | companion object { 37 | val EXTRA_IMAGE_URL = "imageUrl" 38 | val EXTRA_IMAGE_TITLE = "imageTitle" 39 | } 40 | 41 | override fun onCreate(savedInstanceState: Bundle?) { 42 | super.onCreate(savedInstanceState) 43 | setContentView(R.layout.activity_main) 44 | 45 | init() 46 | } 47 | 48 | override fun onCreateOptionsMenu(menu: Menu): Boolean { 49 | menuInflater.inflate(R.menu.search_menu, menu) 50 | searchView = menu.findItem(R.id.search).actionView as SearchView 51 | searchView?.setOnQueryTextListener(object: SearchView.OnQueryTextListener { 52 | override fun onQueryTextSubmit(query: String): Boolean { 53 | viewModel.search(query) 54 | return true 55 | } 56 | 57 | override fun onQueryTextChange(newText: String): Boolean { 58 | viewModel.search(newText) 59 | return true 60 | } 61 | }) 62 | searchView?.setQuery(viewModel.searchQuery, true) 63 | return true 64 | } 65 | 66 | private fun init() { 67 | initViewModel() 68 | initLayout() 69 | initSubscriptions() 70 | } 71 | 72 | private fun initViewModel() { 73 | viewModel = ViewModelProviders.of(this).get(MainActivityViewModel::class.java) 74 | } 75 | 76 | private fun initLayout() { 77 | progress_spinner.setColor(ContextCompat.getColor(this, R.color.colorPrimary)) 78 | 79 | val layoutManager = LinearLayoutManager(this) 80 | image_post_list.layoutManager = layoutManager 81 | image_post_list.adapter = viewModel.imagePostAdapter 82 | image_post_list.addOnScrollListener(object: RecyclerView.OnScrollListener() { 83 | override fun onScrollStateChanged(recyclerView: RecyclerView?, newState: Int) { 84 | super.onScrollStateChanged(recyclerView, newState) 85 | hideKeyboard() 86 | } 87 | }) 88 | 89 | //Setup infinite scrolling 90 | lazyListener = object: LazyLoadRecyclerViewListener(layoutManager) { 91 | override fun onLoadMore(currentPage: Int) { 92 | viewModel.loadMore(searchView?.query.toString()) 93 | } 94 | } 95 | image_post_list.addOnScrollListener(lazyListener) 96 | } 97 | 98 | private fun initSubscriptions() { 99 | viewModel.state.observe(this, Observer { 100 | it?.let { 101 | update(it) 102 | } 103 | }) 104 | 105 | attachClickListenerToImageAdapter() 106 | } 107 | 108 | private fun update(state: MainActivityState) { 109 | when (state.status) { 110 | Status.LOADING -> { 111 | progress_spinner.visibility = View.VISIBLE 112 | search_result_header.visibility = View.GONE 113 | upward_arrow.visibility = View.GONE 114 | instructions.visibility = View.GONE 115 | } 116 | 117 | Status.TOO_SHORT -> { 118 | upward_arrow.visibility = View.VISIBLE 119 | instructions.visibility = View.VISIBLE 120 | search_result_header.visibility = View.GONE 121 | } 122 | 123 | Status.SUCCESS -> { 124 | if (image_post_list.adapter.itemCount == 0) { 125 | toastLong(R.string.no_images) 126 | upward_arrow.visibility = View.VISIBLE 127 | instructions.visibility = View.VISIBLE 128 | search_result_header.visibility = View.GONE 129 | } else { 130 | search_result_header.visibility = View.VISIBLE 131 | search_result_header.text = resources.getString(R.string.search_result_header, searchView?.query) 132 | } 133 | lazyListener?.setLoading(false) 134 | progress_spinner.visibility = View.GONE 135 | } 136 | 137 | Status.COMPLETE -> { 138 | progress_spinner.visibility = View.GONE 139 | } 140 | 141 | Status.ERROR -> { 142 | Log.e("MainActivity: ", state.error?.localizedMessage) 143 | } 144 | } 145 | } 146 | 147 | private fun attachClickListenerToImageAdapter() { 148 | addSub(viewModel.imagePostAdapter.clickStream.observeOn(AndroidSchedulers.mainThread())?.subscribe({ 149 | searchView?.clearFocus() 150 | var intent = Intent(this@MainActivity, ImageDetailActivity::class.java) 151 | viewModel.imagePostAdapter.let { adapter -> 152 | intent.putExtra(MainActivity.EXTRA_IMAGE_URL, it.data.images?.first()?.link?:"") 153 | intent.putExtra(MainActivity.EXTRA_IMAGE_TITLE, it.data.title) 154 | } 155 | val options = ActivityOptions 156 | .makeSceneTransitionAnimation(this@MainActivity, it.imageView, ViewCompat.getTransitionName(it.imageView)) 157 | startActivity(intent, options.toBundle()) 158 | })) 159 | } 160 | 161 | private fun hideKeyboard() { 162 | val inputManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 163 | 164 | // check if no view has focus: 165 | val v = this.currentFocus ?: return 166 | 167 | inputManager.hideSoftInputFromWindow(v.windowToken, 0) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/view/adapter/ImagePostAdapter.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.view.adapter 2 | 3 | import android.app.Service 4 | import android.content.ClipData 5 | import android.content.ClipboardManager 6 | import android.support.v7.widget.RecyclerView 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import com.squareup.picasso.Picasso 10 | import io.reactivex.Observable 11 | import io.reactivex.subjects.PublishSubject 12 | import io.spacenoodles.makingyourappreactive.R 13 | import io.spacenoodles.makingyourappreactive.model.ImagePost 14 | import io.spacenoodles.makingyourappreactive.util.extension.inflate 15 | import io.spacenoodles.makingyourappreactive.util.extension.toastLong 16 | import kotlinx.android.synthetic.main.list_item_image_post.view.* 17 | 18 | class ImagePostAdapter(items: List) : RecyclerView.Adapter() { 19 | var items = items 20 | 21 | private val onClickSubject = PublishSubject.create() 22 | val clickStream: Observable get() = onClickSubject.hide() 23 | 24 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 25 | return ViewHolder(parent.inflate(R.layout.list_item_image_post)) 26 | } 27 | 28 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 29 | holder.bind(items[position], { onClickSubject.onNext(it) }) 30 | } 31 | 32 | override fun getItemCount(): Int { 33 | return items.size 34 | } 35 | 36 | class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 37 | fun bind(item: ImagePost, click: (ImageItem) -> Unit = {}) { 38 | itemView.setOnClickListener(null) 39 | Picasso 40 | .with(itemView.context) 41 | .cancelRequest(itemView.image_view) 42 | itemView.image_view.setImageResource(R.drawable.ic_image_24dp) 43 | Picasso 44 | .with(itemView.context) 45 | .load(item.images?.first()?.link) 46 | .placeholder(R.drawable.ic_image_24dp) 47 | .fit() 48 | .centerCrop() 49 | .into(itemView.image_view) 50 | 51 | itemView.image_view.setOnClickListener { click.invoke(ImageItem(itemView.image_view, item)) } 52 | itemView.link_button.setOnClickListener { 53 | val clipboard = itemView.context.getSystemService(Service.CLIPBOARD_SERVICE) as ClipboardManager 54 | val clip = ClipData.newPlainText("Link", item.images?.first()?.link) 55 | clipboard.primaryClip = clip 56 | itemView.context.toastLong(itemView.context.getString(R.string.link_copied)) 57 | } 58 | 59 | itemView.score_text.text = itemView.context.getString(R.string.score, item.score) 60 | itemView.title_text.text = item.title 61 | } 62 | } 63 | 64 | data class ImageItem(val imageView: View, val data: ImagePost) 65 | } -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/view/adapter/listener/LazyLoadRecyclerViewListener.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.view.adapter.listener 2 | 3 | import android.support.v7.widget.LinearLayoutManager 4 | import android.support.v7.widget.RecyclerView 5 | 6 | abstract class LazyLoadRecyclerViewListener(private val layoutManager: LinearLayoutManager) : RecyclerView.OnScrollListener() { 7 | private var loading = true //True if we are still waiting for data to load 8 | private var currentPage = 1 9 | 10 | override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { 11 | super.onScrolled(recyclerView, dx, dy) 12 | 13 | val visibleItemCount = recyclerView.childCount 14 | val totalItemCount = layoutManager.itemCount 15 | val firstVisibleItem = layoutManager.findFirstVisibleItemPosition() 16 | 17 | if (!loading && totalItemCount - visibleItemCount <= firstVisibleItem + VISIBLE_THRESHOLD) { 18 | //End of list has been reached 19 | if (hasMore(totalItemCount)) { 20 | loading = true 21 | onLoadMore(++currentPage) 22 | } 23 | } 24 | } 25 | 26 | fun hasMore(totalItemCount: Int): Boolean { 27 | return true 28 | } 29 | 30 | fun reset() { 31 | loading = true 32 | currentPage = 1 33 | } 34 | 35 | fun setLoading(loading: Boolean) { 36 | this.loading = loading 37 | } 38 | 39 | abstract fun onLoadMore(currentPage: Int) 40 | 41 | companion object { 42 | private val VISIBLE_THRESHOLD = 5 //Number of items left in list before we start loading more 43 | } 44 | } -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/viewModel/MainActivityViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.viewModel 2 | 3 | import android.arch.lifecycle.MutableLiveData 4 | import android.arch.lifecycle.ViewModel 5 | import io.reactivex.Maybe 6 | import io.reactivex.android.schedulers.AndroidSchedulers 7 | import io.reactivex.disposables.CompositeDisposable 8 | import io.reactivex.disposables.Disposable 9 | import io.reactivex.schedulers.Schedulers 10 | import io.reactivex.subjects.PublishSubject 11 | import io.spacenoodles.makingyourappreactive.App 12 | import io.spacenoodles.makingyourappreactive.model.ImagePost 13 | import io.spacenoodles.makingyourappreactive.model.ImgurResponse 14 | import io.spacenoodles.makingyourappreactive.sync.repository.ImagePostRepository 15 | import io.spacenoodles.makingyourappreactive.util.extension.isValidURL 16 | import io.spacenoodles.makingyourappreactive.view.adapter.ImagePostAdapter 17 | import io.spacenoodles.makingyourappreactive.viewModel.state.MainActivityState 18 | import java.util.concurrent.TimeUnit 19 | import javax.inject.Inject 20 | 21 | class MainActivityViewModel : ViewModel() { 22 | 23 | val state: MutableLiveData 24 | 25 | init { 26 | App.component.inject(this) 27 | state = MutableLiveData() 28 | initRx() 29 | initSearch() 30 | } 31 | 32 | @Inject lateinit var imageRepo: ImagePostRepository 33 | 34 | private var currentPage = 0 35 | var imagePostAdapter = ImagePostAdapter(ArrayList()) 36 | var searchQuery = "" 37 | 38 | private lateinit var searchEmitter: PublishSubject 39 | 40 | private lateinit var disposables: CompositeDisposable 41 | 42 | fun search(query: String) { 43 | if (searchQuery != query) { 44 | imagePostAdapter.items = ArrayList() 45 | imagePostAdapter.notifyDataSetChanged() 46 | } 47 | searchQuery = query 48 | searchEmitter.onNext(query) 49 | } 50 | 51 | private fun searchForImages(query: String): Maybe { 52 | return imageRepo.getImagePosts(currentPage, query) 53 | } 54 | 55 | fun loadMore(searchQuery: String) { 56 | currentPage++ 57 | searchEmitter.onNext(searchQuery) 58 | } 59 | 60 | private fun initRx() { 61 | disposables = CompositeDisposable() 62 | } 63 | 64 | private fun initSearch() { 65 | searchEmitter = PublishSubject.create() 66 | addSub( 67 | searchEmitter 68 | .subscribeOn(Schedulers.io()) 69 | .debounce(250, TimeUnit.MILLISECONDS) 70 | .doOnNext { 71 | if (it.length < 2) { 72 | state.postValue(MainActivityState.tooShort()) 73 | } 74 | } 75 | .filter { it.length > 1 } 76 | .doOnNext { state.postValue(MainActivityState.loading()) } 77 | .doOnTerminate { state.postValue(MainActivityState.complete()) } 78 | .switchMap { 79 | searchForImages(it).toObservable() 80 | } 81 | .map { 82 | val newData = ImgurResponse(it.data?.filter { item -> 83 | item.images?.isNotEmpty() == true && item.images.first().link.isValidURL() 84 | && item.nsfw == false 85 | && item.images.first().size ?:Long.MAX_VALUE <= 1500000L 86 | }) 87 | newData 88 | } 89 | .observeOn(AndroidSchedulers.mainThread()) 90 | .doOnNext { response -> 91 | updateAdapter(response.data) 92 | state.postValue(MainActivityState.success()) 93 | } 94 | .subscribe({}, 95 | { 96 | state.postValue(MainActivityState.error(it)) 97 | }) 98 | ) 99 | } 100 | 101 | private fun updateAdapter(data: List?) { 102 | //If the search is at least length 2, put the items in the adapter, otherwise clear the adapter 103 | if (searchQuery.length > 1) { 104 | imagePostAdapter.items = imagePostAdapter.items.plus(data ?: ArrayList()) 105 | imagePostAdapter.notifyDataSetChanged() 106 | } else { 107 | imagePostAdapter.items = ArrayList() 108 | } 109 | } 110 | 111 | @Synchronized 112 | private fun addSub(disposable: Disposable?) { 113 | if (disposable == null) return 114 | disposables.add(disposable) 115 | } 116 | 117 | override fun onCleared() { 118 | super.onCleared() 119 | if (!disposables.isDisposed) disposables.dispose() 120 | } 121 | } -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/viewModel/state/MainActivityState.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.viewModel.state 2 | 3 | 4 | data class MainActivityState private constructor(val status: Status, 5 | val error: Throwable? = null) { 6 | companion object { 7 | 8 | fun loading(): MainActivityState { 9 | return MainActivityState(Status.LOADING) 10 | } 11 | 12 | fun success(): MainActivityState { 13 | return MainActivityState(Status.SUCCESS) 14 | } 15 | 16 | fun complete(): MainActivityState { 17 | return MainActivityState(Status.COMPLETE) 18 | } 19 | 20 | fun tooShort(): MainActivityState { 21 | return MainActivityState(Status.TOO_SHORT) 22 | } 23 | 24 | fun error(error: Throwable): MainActivityState { 25 | return MainActivityState(Status.ERROR, error) 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/java/io/spacenoodles/makingyourappreactive/viewModel/state/Status.kt: -------------------------------------------------------------------------------- 1 | package io.spacenoodles.makingyourappreactive.viewModel.state 2 | 3 | enum class Status { 4 | LOADING, 5 | SUCCESS, 6 | COMPLETE, 7 | TOO_SHORT, 8 | ERROR 9 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_arrow_upward_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_image_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /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/main/res/drawable/ic_link_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_search_white_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_image_detail.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 13 | 18 | 19 | 28 | 29 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 22 | 23 | 34 | 35 | 44 | 45 | 56 | 57 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/res/layout/list_item_image_post.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 15 | 16 | 25 | 26 | 42 | 43 | 54 | 55 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /app/src/main/res/menu/search_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpaceWaffles/MakingYourAppReactive/0189e343c38446d22a47476e06975e684d8d3192/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpaceWaffles/MakingYourAppReactive/0189e343c38446d22a47476e06975e684d8d3192/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpaceWaffles/MakingYourAppReactive/0189e343c38446d22a47476e06975e684d8d3192/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpaceWaffles/MakingYourAppReactive/0189e343c38446d22a47476e06975e684d8d3192/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpaceWaffles/MakingYourAppReactive/0189e343c38446d22a47476e06975e684d8d3192/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpaceWaffles/MakingYourAppReactive/0189e343c38446d22a47476e06975e684d8d3192/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpaceWaffles/MakingYourAppReactive/0189e343c38446d22a47476e06975e684d8d3192/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpaceWaffles/MakingYourAppReactive/0189e343c38446d22a47476e06975e684d8d3192/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpaceWaffles/MakingYourAppReactive/0189e343c38446d22a47476e06975e684d8d3192/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpaceWaffles/MakingYourAppReactive/0189e343c38446d22a47476e06975e684d8d3192/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #00BCD4 4 | #0097A7 5 | #B2EBF2 6 | #607D8B 7 | #212121 8 | #757575 9 | @color/white 10 | #BDBDBD 11 | #FFFFFF 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 4dp 5 | 8dp 6 | 10dp 7 | 12dp 8 | 16dp 9 | 24dp 10 | 36dp 11 | 48dp 12 | 56dp 13 | 14 | 15 | 22sp 16 | 16sp 17 | 56sp 18 | 19 | 20 | 250dp 21 | 16dp 22 | 23 | 24 | 100dp 25 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Make Your Apps Reactive 3 | 4 | Search for images... 5 | Results for %1$s 6 | Image link copied to clipboard! 7 | ImageDetailActivity 8 | Score: %1$d 9 | image_transition 10 | Search for images up there! 11 | No Images Found 12 | Search Query Too Short 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | ext.kotlin_version = '1.3.10' 5 | 6 | //Dependency Versions 7 | ext.support_lib_version = '27.0.2' 8 | ext.rxandroid_version = '2.0.1' 9 | ext.rxjava2_version = '2.1.1' 10 | ext.okhttp_version = '3.7.0' 11 | ext.retrofit_version = '2.3.0' 12 | ext.dagger_version = '2.9' 13 | ext.gson_version = '2.8.2' 14 | 15 | repositories { 16 | google() 17 | jcenter() 18 | } 19 | dependencies { 20 | classpath 'com.android.tools.build:gradle:3.3.2' 21 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 22 | 23 | // NOTE: Do not place your application dependencies here; they belong 24 | // in the individual module build.gradle files 25 | } 26 | } 27 | 28 | allprojects { 29 | repositories { 30 | google() 31 | jcenter() 32 | } 33 | } 34 | 35 | task clean(type: Delete) { 36 | delete rootProject.buildDir 37 | } 38 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | org.gradle.jvmargs=-Xmx1536m 13 | 14 | # When configured, Gradle will run in incubating parallel mode. 15 | # This option should only be used with decoupled projects. More details, visit 16 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 17 | # org.gradle.parallel=true 18 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpaceWaffles/MakingYourAppReactive/0189e343c38446d22a47476e06975e684d8d3192/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Mar 17 21:47:51 SGT 2019 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # Attempt to set APP_HOME 46 | # Resolve links: $0 may be a link 47 | PRG="$0" 48 | # Need this for relative symlinks. 49 | while [ -h "$PRG" ] ; do 50 | ls=`ls -ld "$PRG"` 51 | link=`expr "$ls" : '.*-> \(.*\)$'` 52 | if expr "$link" : '/.*' > /dev/null; then 53 | PRG="$link" 54 | else 55 | PRG=`dirname "$PRG"`"/$link" 56 | fi 57 | done 58 | SAVED="`pwd`" 59 | cd "`dirname \"$PRG\"`/" >/dev/null 60 | APP_HOME="`pwd -P`" 61 | cd "$SAVED" >/dev/null 62 | 63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 64 | 65 | # Determine the Java command to use to start the JVM. 66 | if [ -n "$JAVA_HOME" ] ; then 67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 68 | # IBM's JDK on AIX uses strange locations for the executables 69 | JAVACMD="$JAVA_HOME/jre/sh/java" 70 | else 71 | JAVACMD="$JAVA_HOME/bin/java" 72 | fi 73 | if [ ! -x "$JAVACMD" ] ; then 74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 75 | 76 | Please set the JAVA_HOME variable in your environment to match the 77 | location of your Java installation." 78 | fi 79 | else 80 | JAVACMD="java" 81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 82 | 83 | Please set the JAVA_HOME variable in your environment to match the 84 | location of your Java installation." 85 | fi 86 | 87 | # Increase the maximum file descriptors if we can. 88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 89 | MAX_FD_LIMIT=`ulimit -H -n` 90 | if [ $? -eq 0 ] ; then 91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 92 | MAX_FD="$MAX_FD_LIMIT" 93 | fi 94 | ulimit -n $MAX_FD 95 | if [ $? -ne 0 ] ; then 96 | warn "Could not set maximum file descriptor limit: $MAX_FD" 97 | fi 98 | else 99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 100 | fi 101 | fi 102 | 103 | # For Darwin, add options to specify how the application appears in the dock 104 | if $darwin; then 105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 106 | fi 107 | 108 | # For Cygwin, switch paths to Windows format before running java 109 | if $cygwin ; then 110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 112 | JAVACMD=`cygpath --unix "$JAVACMD"` 113 | 114 | # We build the pattern for arguments to be converted via cygpath 115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 116 | SEP="" 117 | for dir in $ROOTDIRSRAW ; do 118 | ROOTDIRS="$ROOTDIRS$SEP$dir" 119 | SEP="|" 120 | done 121 | OURCYGPATTERN="(^($ROOTDIRS))" 122 | # Add a user-defined pattern to the cygpath arguments 123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 125 | fi 126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 127 | i=0 128 | for arg in "$@" ; do 129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 131 | 132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 134 | else 135 | eval `echo args$i`="\"$arg\"" 136 | fi 137 | i=$((i+1)) 138 | done 139 | case $i in 140 | (0) set -- ;; 141 | (1) set -- "$args0" ;; 142 | (2) set -- "$args0" "$args1" ;; 143 | (3) set -- "$args0" "$args1" "$args2" ;; 144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 150 | esac 151 | fi 152 | 153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 154 | function splitJvmOpts() { 155 | JVM_OPTS=("$@") 156 | } 157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 159 | 160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 161 | -------------------------------------------------------------------------------- /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 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 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 Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | --------------------------------------------------------------------------------