Handle errors properly in scrobbler selector
This commit is contained in:
@@ -90,7 +90,7 @@ dependencies {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.0'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.10'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
|
||||
|
||||
implementation "androidx.appcompat:appcompat:1.6.0"
|
||||
|
||||
@@ -91,6 +91,12 @@ class ScrobblingSelectorBottomSheet :
|
||||
viewModel.onClose.observe(viewLifecycleOwner) {
|
||||
dismiss()
|
||||
}
|
||||
viewModel.selectedScrobblerIndex.observe(viewLifecycleOwner) { index ->
|
||||
val tab = binding.tabs.getTabAt(index)
|
||||
if (tab != null && !tab.isSelected) {
|
||||
tab.select()
|
||||
}
|
||||
}
|
||||
viewModel.searchQuery.observe(viewLifecycleOwner) {
|
||||
binding.headerBar.subtitle = it
|
||||
}
|
||||
@@ -106,14 +112,16 @@ class ScrobblingSelectorBottomSheet :
|
||||
viewModel.selectedItemId.value = item.id
|
||||
}
|
||||
|
||||
override fun onRetryClick(error: Throwable) = Unit
|
||||
override fun onRetryClick(error: Throwable) {
|
||||
viewModel.retry()
|
||||
}
|
||||
|
||||
override fun onEmptyActionClick() {
|
||||
openSearch()
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() {
|
||||
viewModel.loadList(append = true)
|
||||
viewModel.loadNextPage()
|
||||
}
|
||||
|
||||
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
|
||||
|
||||
@@ -11,18 +11,19 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyHint
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
|
||||
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
|
||||
import org.koitharu.kotatsu.scrobbling.ui.selector.model.ScrobblerHint
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.utils.ext.requireValue
|
||||
|
||||
class ScrobblingSelectorViewModel @AssistedInject constructor(
|
||||
@@ -34,8 +35,9 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
|
||||
|
||||
val selectedScrobblerIndex = MutableLiveData(0)
|
||||
|
||||
private val scrobblerMangaList = MutableStateFlow<List<ScrobblerManga>?>(null)
|
||||
private val hasNextPage = MutableStateFlow(false)
|
||||
private val scrobblerMangaList = MutableStateFlow<List<ScrobblerManga>>(emptyList())
|
||||
private val hasNextPage = MutableStateFlow(true)
|
||||
private val listError = MutableStateFlow<Throwable?>(null)
|
||||
private var loadingJob: Job? = null
|
||||
private var doneJob: Job? = null
|
||||
private var initJob: Job? = null
|
||||
@@ -44,13 +46,24 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
|
||||
get() = availableScrobblers[selectedScrobblerIndex.requireValue()]
|
||||
|
||||
val content: LiveData<List<ListModel>> = combine(
|
||||
scrobblerMangaList.filterNotNull(),
|
||||
scrobblerMangaList,
|
||||
listError,
|
||||
hasNextPage,
|
||||
) { list, isHasNextPage ->
|
||||
when {
|
||||
list.isEmpty() -> listOf(emptyResultsHint())
|
||||
isHasNextPage -> list + LoadingFooter
|
||||
else -> list
|
||||
) { list, error, isHasNextPage ->
|
||||
if (list.isNotEmpty()) {
|
||||
if (isHasNextPage) {
|
||||
list + LoadingFooter
|
||||
} else {
|
||||
list
|
||||
}
|
||||
} else {
|
||||
listOf(
|
||||
when {
|
||||
error != null -> errorHint(error)
|
||||
isHasNextPage -> LoadingFooter
|
||||
else -> emptyResultsHint()
|
||||
},
|
||||
)
|
||||
}
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
|
||||
|
||||
@@ -59,7 +72,7 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
|
||||
val onClose = SingleLiveEvent<Unit>()
|
||||
|
||||
val isEmpty: Boolean
|
||||
get() = scrobblerMangaList.value.isNullOrEmpty()
|
||||
get() = scrobblerMangaList.value.isEmpty()
|
||||
|
||||
init {
|
||||
initialize()
|
||||
@@ -71,22 +84,39 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
|
||||
loadList(append = false)
|
||||
}
|
||||
|
||||
fun loadList(append: Boolean) {
|
||||
fun loadNextPage() {
|
||||
if (scrobblerMangaList.value.isNotEmpty() && hasNextPage.value) {
|
||||
loadList(append = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
loadingJob?.cancel()
|
||||
hasNextPage.value = true
|
||||
scrobblerMangaList.value = emptyList()
|
||||
loadList(append = false)
|
||||
}
|
||||
|
||||
private fun loadList(append: Boolean) {
|
||||
if (loadingJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
if (append && !hasNextPage.value) {
|
||||
return
|
||||
}
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
val offset = if (append) scrobblerMangaList.value?.size ?: 0 else 0
|
||||
val list = currentScrobbler.findManga(checkNotNull(searchQuery.value), offset)
|
||||
if (!append) {
|
||||
scrobblerMangaList.value = list
|
||||
} else if (list.isNotEmpty()) {
|
||||
scrobblerMangaList.value = scrobblerMangaList.value?.plus(list) ?: list
|
||||
listError.value = null
|
||||
val offset = if (append) scrobblerMangaList.value.size else 0
|
||||
runCatchingCancellable {
|
||||
currentScrobbler.findManga(checkNotNull(searchQuery.value), offset)
|
||||
}.onSuccess { list ->
|
||||
if (!append) {
|
||||
scrobblerMangaList.value = list
|
||||
} else if (list.isNotEmpty()) {
|
||||
scrobblerMangaList.value = scrobblerMangaList.value + list
|
||||
}
|
||||
hasNextPage.value = list.isNotEmpty()
|
||||
}.onFailure { error ->
|
||||
error.printStackTraceDebug()
|
||||
listError.value = error
|
||||
}
|
||||
hasNextPage.value = list.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +143,8 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
|
||||
private fun initialize() {
|
||||
initJob?.cancel()
|
||||
loadingJob?.cancel()
|
||||
hasNextPage.value = false
|
||||
scrobblerMangaList.value = null
|
||||
hasNextPage.value = true
|
||||
scrobblerMangaList.value = emptyList()
|
||||
initJob = launchJob(Dispatchers.Default) {
|
||||
try {
|
||||
val info = currentScrobbler.getScrobblingInfoOrNull(manga.id)
|
||||
@@ -127,13 +157,22 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun emptyResultsHint() = EmptyHint(
|
||||
private fun emptyResultsHint() = ScrobblerHint(
|
||||
icon = R.drawable.ic_empty_history,
|
||||
textPrimary = R.string.nothing_found,
|
||||
textSecondary = R.string.text_search_holder_secondary,
|
||||
error = null,
|
||||
actionStringRes = R.string.search,
|
||||
)
|
||||
|
||||
private fun errorHint(e: Throwable) = ScrobblerHint(
|
||||
icon = R.drawable.ic_error_large,
|
||||
textPrimary = R.string.error_occurred,
|
||||
error = e,
|
||||
textSecondary = 0,
|
||||
actionStringRes = R.string.try_again,
|
||||
)
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
|
||||
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.scrobbling.ui.selector.model.ScrobblerHint
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.setTextAndVisible
|
||||
import org.koitharu.kotatsu.utils.ext.textAndVisible
|
||||
|
||||
fun scrobblerHintAD(
|
||||
listener: ListStateHolderListener,
|
||||
) = adapterDelegateViewBinding<ScrobblerHint, ListModel, ItemEmptyHintBinding>(
|
||||
{ inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
binding.buttonRetry.setOnClickListener {
|
||||
val e = item.error
|
||||
if (e != null) {
|
||||
listener.onRetryClick(e)
|
||||
} else {
|
||||
listener.onEmptyActionClick()
|
||||
}
|
||||
}
|
||||
|
||||
bind {
|
||||
binding.icon.setImageResource(item.icon)
|
||||
binding.textPrimary.setText(item.textPrimary)
|
||||
if (item.error != null) {
|
||||
binding.textSecondary.textAndVisible = item.error?.getDisplayMessage(context.resources)
|
||||
} else {
|
||||
binding.textSecondary.setTextAndVisible(item.textSecondary)
|
||||
}
|
||||
binding.buttonRetry.setTextAndVisible(item.actionStringRes)
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
||||
import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
|
||||
import org.koitharu.kotatsu.scrobbling.ui.selector.model.ScrobblerHint
|
||||
import kotlin.jvm.internal.Intrinsics
|
||||
|
||||
class ShikimoriSelectorAdapter(
|
||||
@@ -24,7 +24,7 @@ class ShikimoriSelectorAdapter(
|
||||
delegatesManager.addDelegate(loadingStateAD())
|
||||
.addDelegate(scrobblingMangaAD(lifecycleOwner, coil, clickListener))
|
||||
.addDelegate(loadingFooterAD())
|
||||
.addDelegate(emptyHintAD(stateHolderListener))
|
||||
.addDelegate(scrobblerHintAD(stateHolderListener))
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
|
||||
@@ -33,6 +33,7 @@ class ShikimoriSelectorAdapter(
|
||||
return when {
|
||||
oldItem === newItem -> true
|
||||
oldItem is ScrobblerManga && newItem is ScrobblerManga -> oldItem.id == newItem.id
|
||||
oldItem is ScrobblerHint && newItem is ScrobblerHint -> oldItem.textPrimary == newItem.textPrimary
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.koitharu.kotatsu.scrobbling.ui.selector.model
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class ScrobblerHint(
|
||||
@DrawableRes val icon: Int,
|
||||
@StringRes val textPrimary: Int,
|
||||
@StringRes val textSecondary: Int,
|
||||
val error: Throwable?,
|
||||
@StringRes val actionStringRes: Int,
|
||||
) : ListModel {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ScrobblerHint
|
||||
|
||||
if (icon != other.icon) return false
|
||||
if (textPrimary != other.textPrimary) return false
|
||||
if (textSecondary != other.textSecondary) return false
|
||||
if (error != other.error) return false
|
||||
if (actionStringRes != other.actionStringRes) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = icon
|
||||
result = 31 * result + textPrimary
|
||||
result = 31 * result + textSecondary
|
||||
result = 31 * result + (error?.hashCode() ?: 0)
|
||||
result = 31 * result + actionStringRes
|
||||
return result
|
||||
}
|
||||
}
|
||||
55
app/src/main/res/layout/item_empty_hint.xml
Normal file
55
app/src/main/res/layout/item_empty_hint.xml
Normal file
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
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="wrap_content"
|
||||
android:padding="@dimen/margin_normal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="120dp"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="fitCenter"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@drawable/ic_empty_favourites" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textPrimary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/margin_small"
|
||||
android:textAppearance="?attr/textAppearanceTitleLarge"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/icon"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="@tools:sample/lorem[3]" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textSecondary"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/margin_small"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/icon"
|
||||
app:layout_constraintTop_toBottomOf="@id/textPrimary"
|
||||
tools:text="@tools:sample/lorem[15]" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_retry"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/margin_small"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/textSecondary"
|
||||
tools:text="@string/try_again"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
Reference in New Issue
Block a user