Skip to content

Commit

Permalink
Merge pull request #27 from nimbl3/feature/refactor
Browse files Browse the repository at this point in the history
[18] Refactor base components, add unit test
  • Loading branch information
sleepylee authored Jun 14, 2018
2 parents 8b80dd4 + 0bdd7a3 commit 33ba10d
Show file tree
Hide file tree
Showing 12 changed files with 303 additions and 56 deletions.
16 changes: 9 additions & 7 deletions app/src/main/java/com/nimbl3/di/ApplicationComponent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import com.nimbl3.di.modules.*
import javax.inject.Singleton

@Singleton
@Component(modules = [AndroidSupportInjectionModule::class,
AppModule::class,
RetrofitModule::class,
GsonModule::class,
OkHttpClientModule::class,
SchedulersModule::class,
ActivityBuilderModule::class])
@Component(modules = [
AndroidSupportInjectionModule::class,
ViewModelFactoryModule::class,
AppModule::class,
RetrofitModule::class,
GsonModule::class,
OkHttpClientModule::class,
SchedulersModule::class,
ActivityBuilderModule::class])
interface ApplicationComponent : AndroidInjector<TemplateApplication> {

@Component.Builder
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/com/nimbl3/di/modules/ViewModelFactoryModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.nimbl3.di.modules

import android.arch.lifecycle.ViewModelProvider
import com.nimbl3.lib.viewmodel.ViewModelFactory
import dagger.Binds
import dagger.Module

@Module
abstract class ViewModelFactoryModule {
@Binds
abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory
}
4 changes: 2 additions & 2 deletions app/src/main/java/com/nimbl3/extension/ImageViewExtension.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import com.nimbl3.di.modules.GlideApp
* Provide extension functions relates to ImageView and loading image mechanism.
*/

fun ImageView.setImageUrl(url: String) {
fun ImageView.loadImage(url: String) {
GlideApp.with(context)
.load(url)
.placeholder(ColorDrawable(ContextCompat.getColor(context, R.color.black_20a)))
.diskCacheStrategy(DiskCacheStrategy.ALL)
.centerCrop()
.fitCenter()
.into(this)
}
3 changes: 3 additions & 0 deletions app/src/main/java/com/nimbl3/lib/Alias.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.nimbl3.lib

typealias IsLoading = Boolean
71 changes: 44 additions & 27 deletions app/src/main/java/com/nimbl3/ui/main/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,42 +1,59 @@
package com.nimbl3

import android.arch.lifecycle.ViewModelProvider
import android.arch.lifecycle.ViewModelProviders
import android.os.Bundle
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import dagger.android.AndroidInjection
import io.reactivex.android.schedulers.AndroidSchedulers
import com.nimbl3.extension.setImageUrl
import com.nimbl3.data.service.ApiRepository
import com.nimbl3.data.service.response.ExampleResponse
import android.view.View.*
import com.jakewharton.rxbinding2.view.RxView
import com.nimbl3.data.lib.schedulers.SchedulersProvider
import com.nimbl3.extension.loadImage
import com.nimbl3.lib.IsLoading
import com.nimbl3.ui.base.BaseActivity
import com.nimbl3.ui.main.MainViewModel
import com.nimbl3.ui.main.data.Data
import kotlinx.android.synthetic.main.activity_main.*
import javax.inject.Inject

class MainActivity : BaseActivity() {

@Inject lateinit var appRepository: ApiRepository
@Inject lateinit var viewModelFactory: ViewModelProvider.Factory
@Inject lateinit var schedulers: SchedulersProvider

private val viewModel: MainViewModel by lazy {
ViewModelProviders.of(this, viewModelFactory).get(MainViewModel::class.java)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
bindToViewModel()
}

private fun bindToViewModel() {
viewModel
.outputs
.loadData()
.observeOn(schedulers.main())
.subscribe(this::bindData)

viewModel
.outputs
.isLoading()
.subscribeOn(schedulers.io())
.observeOn(schedulers.main())
.subscribe(this::showLoading)

RxView.clicks(buttonRefresh)
.subscribe({ viewModel.inputs.refresh() })
}

private fun bindData(data: Data) {
textView.text = data.content
imageView.loadImage(data.imageUrl)
}

val textView = findViewById<TextView>(R.id.text)
val imageView = findViewById<ImageView>(R.id.appCompatImageView)

// Just for exampling the Retrofit implementation
appRepository
.getExampleData()
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ response: ExampleResponse ->
var displayText = ""
(0..2)
.map { response.data.children.get(it).data }
.forEach { displayText += "Author = ${it.author} \nTitle = ${it.title} \n\n" }

textView.setText(displayText)
imageView.setImageUrl("http://www.monkeyuser.com/assets/images/2018/80-the-struggle.png")
}, { error: Throwable ->
Toast.makeText(this, "Error: " + error.message, Toast.LENGTH_SHORT).show()
})
private fun showLoading(isLoading: IsLoading) {
buttonRefresh.visibility = if (isLoading) GONE else VISIBLE
progressBar.visibility = if (isLoading) VISIBLE else GONE
}
}
85 changes: 82 additions & 3 deletions app/src/main/java/com/nimbl3/ui/main/MainViewModel.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,88 @@
package com.nimbl3.ui.main

import com.nimbl3.data.lib.schedulers.SchedulersProvider
import com.nimbl3.data.service.ApiRepository
import com.nimbl3.data.service.response.ExampleResponse
import com.nimbl3.lib.IsLoading
import com.nimbl3.ui.base.BaseViewModel
import com.nimbl3.ui.main.data.Data
import io.reactivex.Observable
import io.reactivex.subjects.BehaviorSubject
import io.reactivex.subjects.PublishSubject
import javax.inject.Inject

// TODO: remove this rule later!
@Suppress("EmptyClassBlock")
class MainViewModel : BaseViewModel() {
class MainViewModel
@Inject constructor(private val repository: ApiRepository,
private val schedulers: SchedulersProvider) : BaseViewModel(), Inputs, Outputs {

private val refresh = PublishSubject.create<Unit>()

private val data = BehaviorSubject.create<Data>()
private val isLoading = BehaviorSubject.create<IsLoading>()

val inputs: Inputs = this
val outputs: Outputs = this

init {
fetchApi()
.map { fromResponse(it) }
.observeOn(schedulers.main())
.subscribe({
data.onNext(it)
isLoading.onNext(false)
}, {
TODO("Handle Error ¯\\_(ツ)_/¯ ")
})
.bindForDisposable()

refresh
.flatMap<ExampleResponse> { fetchApi() }
.map { fromResponse(it) }
.observeOn(schedulers.main())
.subscribe({
data.onNext(it)
isLoading.onNext(false)
}, {
TODO("Handle Error ¯\\_(ツ)_/¯ ")
})
.bindForDisposable()
}

private fun fetchApi(): Observable<ExampleResponse> =
repository
.getExampleData()
.subscribeOn(schedulers.io())
.doOnSubscribe { isLoading.onNext(true) }
.toObservable()

private fun fromResponse(response: ExampleResponse): Data {
var content = ""
(0..2)
.map { response.data.children[it].data }
.forEach {
content += "Author = ${it.author} \nTitle = ${it.title} \n\n"
}

// Image from a random place
var imageUrl = "http://www.monkeyuser.com/assets/images/2018/80-the-struggle.png"
return Data(content, imageUrl)
}

override fun refresh() {
refresh.onNext(Unit)
}

override fun loadData(): Observable<Data> = this.data

override fun isLoading(): Observable<IsLoading> = this.isLoading
}

interface Inputs {
fun refresh()
}

interface Outputs {
fun loadData(): Observable<Data>

fun isLoading(): Observable<IsLoading>
}
4 changes: 4 additions & 0 deletions app/src/main/java/com/nimbl3/ui/main/data/Data.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.nimbl3.ui.main.data

data class Data(val content: String,
val imageUrl: String)
62 changes: 45 additions & 17 deletions app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
@@ -1,26 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
<android.support.v4.widget.NestedScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
android:fillViewport="true">

<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="52dp"
android:layout_marginTop="28dp"
android:text="Hello World!"/>
<android.support.constraint.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<android.support.v7.widget.AppCompatImageView
android:id="@+id/appCompatImageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginStart="40dp"
android:layout_marginTop="8dp"/>
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="center"
android:text="Hello World!"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>

</LinearLayout>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/imageView"
android:layout_width="300dp"
android:layout_height="300dp"
android:layout_marginTop="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView"/>

<ProgressBar
android:id="@+id/progressBar"
android:layout_width="40dp"
android:layout_height="40dp"
android:visibility="gone"
android:layout_marginTop="5dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/imageView"/>

<Button
android:id="@+id/buttonRefresh"
android:layout_width="150dp"
android:layout_height="50dp"
android:layout_marginTop="5dp"
android:text="Refresh"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/imageView"/>
</android.support.constraint.ConstraintLayout>
</android.support.v4.widget.NestedScrollView>
17 changes: 17 additions & 0 deletions app/src/test/java/com/nimbl3/testutil/MockPositiveApiRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.nimbl3.testutil

import com.nimbl3.data.service.ApiRepository
import com.nimbl3.data.service.response.ExampleChildrenDataResponse
import com.nimbl3.data.service.response.ExampleChildrenResponse
import com.nimbl3.data.service.response.ExampleDataResponse
import com.nimbl3.data.service.response.ExampleResponse
import io.reactivex.Flowable

object MockPositiveApiRepository : ApiRepository {
override fun getExampleData(): Flowable<ExampleResponse> {
val response1 = ExampleChildrenResponse(ExampleChildrenDataResponse("author1", "title1"))
val response2 = ExampleChildrenResponse(ExampleChildrenDataResponse("author2", "title2"))
val response3 = ExampleChildrenResponse(ExampleChildrenDataResponse("author3", "title3"))
return Flowable.just(ExampleResponse(ExampleDataResponse(listOf(response1, response2, response3))))
}
}
12 changes: 12 additions & 0 deletions app/src/test/java/com/nimbl3/testutil/MockSchedulersProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.nimbl3.testutil

import com.nimbl3.data.lib.schedulers.SchedulersProvider
import io.reactivex.schedulers.Schedulers

object MockSchedulersProvider : SchedulersProvider {
override fun io() = Schedulers.trampoline()

override fun computation() = Schedulers.trampoline()

override fun main() = Schedulers.trampoline()
}
40 changes: 40 additions & 0 deletions app/src/test/java/com/nimbl3/ui/main/MainViewModelTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.nimbl3.ui.main

import com.nimbl3.testutil.MockPositiveApiRepository
import com.nimbl3.testutil.MockSchedulersProvider
import org.junit.Test

@Suppress("IllegalIdentifier")
class MainViewModelTest {

@Test
fun `At init state, it should emit first load data `() {
val viewModel = MainViewModel(MockPositiveApiRepository, MockSchedulersProvider)

val dataLoaded = viewModel.outputs.loadData().test()
dataLoaded
.assertValueCount(1)
.assertValue { it.content.contains("author1") }

viewModel
.outputs.isLoading().test()
.assertValues(false)
}

@Test
fun `When refresh data, it should emit show then hide loading, and emit data`() {
val viewModel = MainViewModel(MockPositiveApiRepository, MockSchedulersProvider)
val dataLoaded = viewModel.outputs.loadData().test()
val isLoading = viewModel.outputs.isLoading().test()

isLoading
.assertValueCount(1)
.assertValue(false)

viewModel.inputs.refresh()
dataLoaded.assertValueCount(2)
isLoading
.assertValueCount(3)
.assertValues(false, true, false)
}
}
Loading

0 comments on commit 33ba10d

Please sign in to comment.