diff --git a/mobile/build.gradle b/mobile/build.gradle index 09f93368a..772ad06cc 100644 --- a/mobile/build.gradle +++ b/mobile/build.gradle @@ -55,17 +55,14 @@ dependencies { implementation 'androidx.recyclerview:recyclerview:1.0.0-alpha3' implementation 'com.google.android.material:material:1.0.0-alpha3' + implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-experimental-adapter:1.0.0' implementation 'com.squareup.moshi:moshi:1.6.0' implementation 'com.squareup.okhttp3:logging-interceptor:3.10.0' implementation 'com.squareup.okhttp3:okhttp:3.10.0' implementation 'com.squareup.retrofit2:retrofit:2.4.0' - implementation 'com.squareup.retrofit2:adapter-rxjava2:2.4.0' implementation 'com.squareup.retrofit2:converter-moshi:2.4.0' - implementation 'io.reactivex.rxjava2:rxjava:2.1.14' - implementation 'io.reactivex.rxjava2:rxandroid:2.0.2' - implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.2.41' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:0.22.5' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:0.22.5' diff --git a/mobile/src/main/kotlin/io/ashdavies/databinding/extensions/CompositeDisposable.kt b/mobile/src/main/kotlin/io/ashdavies/databinding/extensions/CompositeDisposable.kt deleted file mode 100644 index bf5ad0cd6..000000000 --- a/mobile/src/main/kotlin/io/ashdavies/databinding/extensions/CompositeDisposable.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.ashdavies.databinding.extensions - -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.disposables.Disposable - -internal operator fun CompositeDisposable.plusAssign(disposable: Disposable) { - add(disposable) -} diff --git a/mobile/src/main/kotlin/io/ashdavies/databinding/repos/EmptyLiveData.kt b/mobile/src/main/kotlin/io/ashdavies/databinding/repos/EmptyLiveData.kt new file mode 100644 index 000000000..59760bf46 --- /dev/null +++ b/mobile/src/main/kotlin/io/ashdavies/databinding/repos/EmptyLiveData.kt @@ -0,0 +1,12 @@ +package io.ashdavies.databinding.repos + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData + +internal class EmptyLiveData(items: LiveData>, loading: LiveData) : MediatorLiveData() { + + init { + addSource(items) { value = it?.isEmpty() ?: false && loading.value ?: false } + addSource(loading) { value = it ?: false && items.value?.isEmpty() ?: false } + } +} diff --git a/mobile/src/main/kotlin/io/ashdavies/databinding/repos/RepoActivity.kt b/mobile/src/main/kotlin/io/ashdavies/databinding/repos/RepoActivity.kt index 03dd4ce7b..943c4909c 100644 --- a/mobile/src/main/kotlin/io/ashdavies/databinding/repos/RepoActivity.kt +++ b/mobile/src/main/kotlin/io/ashdavies/databinding/repos/RepoActivity.kt @@ -17,6 +17,7 @@ import io.ashdavies.databinding.extensions.snack import io.ashdavies.databinding.models.Repo import kotlinx.android.synthetic.main.activity_repo.coordinator import kotlinx.android.synthetic.main.activity_repo.recycler +import kotlinx.android.synthetic.main.activity_repo.search import kotlinx.android.synthetic.main.activity_repo.toolbar internal class RepoActivity : AppCompatActivity() { @@ -38,13 +39,13 @@ internal class RepoActivity : AppCompatActivity() { recycler.layoutManager = LinearLayoutManager(this) recycler.itemDecorations += DividerItemDecoration(this, VERTICAL) + search.onActionViewExpanded() + model.items.observe(this, NotNullObserver { adapter.items = it }) model.error.observe(this, NotNullObserver(::error)) } private fun error(throwable: Throwable) { - Log.e("RepoActivity", throwable.message, throwable) - val message = throwable.message if (message == null) { coordinator.snack(R.string.unexpected_error) diff --git a/mobile/src/main/kotlin/io/ashdavies/databinding/repos/RepoModule.kt b/mobile/src/main/kotlin/io/ashdavies/databinding/repos/RepoModule.kt index baebbc6e2..49913e340 100644 --- a/mobile/src/main/kotlin/io/ashdavies/databinding/repos/RepoModule.kt +++ b/mobile/src/main/kotlin/io/ashdavies/databinding/repos/RepoModule.kt @@ -1,9 +1,9 @@ package io.ashdavies.databinding.repos +import com.jakewharton.retrofit2.adapter.kotlin.coroutines.experimental.CoroutineCallAdapterFactory import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit -import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory import retrofit2.converter.moshi.MoshiConverterFactory private const val GITHUB_API = "https://api.github.com" @@ -12,7 +12,7 @@ internal val retrofit: Retrofit get() = Retrofit.Builder() .baseUrl(GITHUB_API) .client(client) - .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .addCallAdapterFactory(CoroutineCallAdapterFactory()) .addConverterFactory(MoshiConverterFactory.create()) .build() diff --git a/mobile/src/main/kotlin/io/ashdavies/databinding/repos/RepoViewModel.kt b/mobile/src/main/kotlin/io/ashdavies/databinding/repos/RepoViewModel.kt index 4ae6187c0..d17afc4c6 100644 --- a/mobile/src/main/kotlin/io/ashdavies/databinding/repos/RepoViewModel.kt +++ b/mobile/src/main/kotlin/io/ashdavies/databinding/repos/RepoViewModel.kt @@ -1,50 +1,58 @@ package io.ashdavies.databinding.repos -import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations.map import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import io.ashdavies.databinding.common.SingleLiveData import io.ashdavies.databinding.extensions.create import io.ashdavies.databinding.extensions.mutableLiveDataOf -import io.ashdavies.databinding.extensions.plusAssign import io.ashdavies.databinding.extensions.singleLiveDataOf import io.ashdavies.databinding.models.Repo import io.ashdavies.databinding.services.GitHub -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.processors.PublishProcessor -import io.reactivex.schedulers.Schedulers -import java.util.concurrent.TimeUnit.MILLISECONDS +import kotlinx.coroutines.experimental.DefaultDispatcher +import kotlinx.coroutines.experimental.Job +import kotlinx.coroutines.experimental.android.UI +import kotlinx.coroutines.experimental.channels.Channel +import kotlinx.coroutines.experimental.channels.ReceiveChannel +import kotlinx.coroutines.experimental.channels.consumeEach +import kotlinx.coroutines.experimental.channels.filter +import kotlinx.coroutines.experimental.channels.produce +import kotlinx.coroutines.experimental.delay +import kotlinx.coroutines.experimental.launch +import kotlin.coroutines.experimental.CoroutineContext internal class RepoViewModel(service: GitHub) : ViewModel() { - private val disposables = CompositeDisposable() - private val query = PublishProcessor.create() + private val jobs = Job() + private val query = Channel() val items: MutableLiveData> = mutableLiveDataOf() val loading: MutableLiveData = mutableLiveDataOf() val error: SingleLiveData = singleLiveDataOf() - val hasItems: LiveData = map(loading) { !it && items.value?.isNotEmpty() ?: false } + val empty: LiveData = EmptyLiveData(items, loading) init { - hasItems.observeForever { Log.e("RepoViewModel", "HasItems: $it") } - - disposables += query - .doOnNext { loading.postValue(true) } - .debounce(500, MILLISECONDS) - .switchMapSingle(service::getRepos) - .doOnNext { loading.postValue(false) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(items::setValue, error::setValue) + launch(UI, parent = jobs) { + query + .filter { it.length >= MIN_LENGTH } + .debounce() + .consumeEach { + try { + items.value = service.getRepos(it).await() + } catch (throwable: Throwable) { + error.value = throwable + } finally { + loading.value = false + } + } + } } fun onQuery(value: String): Boolean { - query.onNext(value) + launch(UI, parent = jobs) { query.send(value) } + loading.value = true return true } @@ -53,4 +61,24 @@ internal class RepoViewModel(service: GitHub) : ViewModel() { return RepoViewModel(retrofit.create()) as T } } + + companion object { + + private const val MIN_LENGTH = 3 + } +} + +internal fun ReceiveChannel.debounce(timeout: Long = 500, context: CoroutineContext = DefaultDispatcher): ReceiveChannel = produce(context) { + + var last: Job? = null + + consumeEach { + last?.cancel() + last = launch { + delay(timeout) + send(it) + } + } + + last?.join() } diff --git a/mobile/src/main/kotlin/io/ashdavies/databinding/services/GitHub.kt b/mobile/src/main/kotlin/io/ashdavies/databinding/services/GitHub.kt index 8225c28e4..35c2b45f5 100644 --- a/mobile/src/main/kotlin/io/ashdavies/databinding/services/GitHub.kt +++ b/mobile/src/main/kotlin/io/ashdavies/databinding/services/GitHub.kt @@ -2,15 +2,15 @@ package io.ashdavies.databinding.services import io.ashdavies.databinding.models.Repo import io.ashdavies.databinding.models.User -import io.reactivex.Single +import kotlinx.coroutines.experimental.Deferred import retrofit2.http.GET import retrofit2.http.Path internal interface GitHub { @GET("/users/{user}") - fun getUser(@Path("user") user: String): Single + fun getUser(@Path("user") user: String): Deferred @GET("/users/{user}/repos") - fun getRepos(@Path("user") user: String): Single> + fun getRepos(@Path("user") user: String): Deferred> } diff --git a/mobile/src/main/res/drawable/ic_stargazer.xml b/mobile/src/main/res/drawable/ic_stargazer.xml new file mode 100644 index 000000000..85c045c0d --- /dev/null +++ b/mobile/src/main/res/drawable/ic_stargazer.xml @@ -0,0 +1,5 @@ + + + diff --git a/mobile/src/main/res/drawable/ic_watcher.xml b/mobile/src/main/res/drawable/ic_watcher.xml new file mode 100644 index 000000000..743220b56 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_watcher.xml @@ -0,0 +1,5 @@ + + + diff --git a/mobile/src/main/res/layout/activity_repo.xml b/mobile/src/main/res/layout/activity_repo.xml index c3f1b7da4..a8abd73e5 100644 --- a/mobile/src/main/res/layout/activity_repo.xml +++ b/mobile/src/main/res/layout/activity_repo.xml @@ -48,7 +48,7 @@ android:id="@+id/recycler" android:layout_width="match_parent" android:layout_height="match_parent" - app:visible="@{model.hasItems}" + app:visible="@{!model.loading}" tools:listitem="@layout/list_item"/> + app:visible="@{model.empty}" + tools:visibility="gone"/> diff --git a/mobile/src/main/res/layout/list_item.xml b/mobile/src/main/res/layout/list_item.xml index 1a265ba7f..9da0bff47 100644 --- a/mobile/src/main/res/layout/list_item.xml +++ b/mobile/src/main/res/layout/list_item.xml @@ -12,35 +12,50 @@ + tools:text="Droidcon Berlin 2018: Leveraging Android Databinding with Kotlin"/> + + + + + + diff --git a/mobile/src/main/res/values/dimens.xml b/mobile/src/main/res/values/dimens.xml index 7b1a2f97c..87787a37c 100644 --- a/mobile/src/main/res/values/dimens.xml +++ b/mobile/src/main/res/values/dimens.xml @@ -1,4 +1,5 @@ 16dp + 4dp diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index 46ec9e180..9b068b0a1 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -3,5 +3,8 @@ Leveraging Android Data Binding with Kotlin Search GitHub repositories by user + Stargazers + Watchers + An unexpected error has occured