Handle errors properly in scrobbler selector

pull/302/head
Koitharu 3 years ago
parent 35b8003cf9
commit 1daa02af52
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

1
.gitignore vendored

@ -12,6 +12,7 @@
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml /.idea/kotlinScripting.xml
/.idea/kotlinc.xml
/.idea/deploymentTargetDropDown.xml /.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml /.idea/androidTestResultsUserPreferences.xml
/.idea/render.experimental.xml /.idea/render.experimental.xml

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="1.8" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.0" />
</component>
</project>

@ -90,7 +90,7 @@ dependencies {
exclude group: 'org.json', module: 'json' 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 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation "androidx.appcompat:appcompat:1.6.0" implementation "androidx.appcompat:appcompat:1.6.0"

@ -91,6 +91,12 @@ class ScrobblingSelectorBottomSheet :
viewModel.onClose.observe(viewLifecycleOwner) { viewModel.onClose.observe(viewLifecycleOwner) {
dismiss() dismiss()
} }
viewModel.selectedScrobblerIndex.observe(viewLifecycleOwner) { index ->
val tab = binding.tabs.getTabAt(index)
if (tab != null && !tab.isSelected) {
tab.select()
}
}
viewModel.searchQuery.observe(viewLifecycleOwner) { viewModel.searchQuery.observe(viewLifecycleOwner) {
binding.headerBar.subtitle = it binding.headerBar.subtitle = it
} }
@ -106,14 +112,16 @@ class ScrobblingSelectorBottomSheet :
viewModel.selectedItemId.value = item.id viewModel.selectedItemId.value = item.id
} }
override fun onRetryClick(error: Throwable) = Unit override fun onRetryClick(error: Throwable) {
viewModel.retry()
}
override fun onEmptyActionClick() { override fun onEmptyActionClick() {
openSearch() openSearch()
} }
override fun onScrolledToEnd() { override fun onScrolledToEnd() {
viewModel.loadList(append = true) viewModel.loadNextPage()
} }
override fun onMenuItemActionExpand(item: MenuItem): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {

@ -11,18 +11,19 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel 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.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga 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.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga 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.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.requireValue import org.koitharu.kotatsu.utils.ext.requireValue
class ScrobblingSelectorViewModel @AssistedInject constructor( class ScrobblingSelectorViewModel @AssistedInject constructor(
@ -34,8 +35,9 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
val selectedScrobblerIndex = MutableLiveData(0) val selectedScrobblerIndex = MutableLiveData(0)
private val scrobblerMangaList = MutableStateFlow<List<ScrobblerManga>?>(null) private val scrobblerMangaList = MutableStateFlow<List<ScrobblerManga>>(emptyList())
private val hasNextPage = MutableStateFlow(false) private val hasNextPage = MutableStateFlow(true)
private val listError = MutableStateFlow<Throwable?>(null)
private var loadingJob: Job? = null private var loadingJob: Job? = null
private var doneJob: Job? = null private var doneJob: Job? = null
private var initJob: Job? = null private var initJob: Job? = null
@ -44,13 +46,24 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
get() = availableScrobblers[selectedScrobblerIndex.requireValue()] get() = availableScrobblers[selectedScrobblerIndex.requireValue()]
val content: LiveData<List<ListModel>> = combine( val content: LiveData<List<ListModel>> = combine(
scrobblerMangaList.filterNotNull(), scrobblerMangaList,
listError,
hasNextPage, hasNextPage,
) { list, isHasNextPage -> ) { list, error, isHasNextPage ->
if (list.isNotEmpty()) {
if (isHasNextPage) {
list + LoadingFooter
} else {
list
}
} else {
listOf(
when { when {
list.isEmpty() -> listOf(emptyResultsHint()) error != null -> errorHint(error)
isHasNextPage -> list + LoadingFooter isHasNextPage -> LoadingFooter
else -> list else -> emptyResultsHint()
},
)
} }
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
@ -59,7 +72,7 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
val onClose = SingleLiveEvent<Unit>() val onClose = SingleLiveEvent<Unit>()
val isEmpty: Boolean val isEmpty: Boolean
get() = scrobblerMangaList.value.isNullOrEmpty() get() = scrobblerMangaList.value.isEmpty()
init { init {
initialize() initialize()
@ -71,22 +84,39 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
loadList(append = false) loadList(append = false)
} }
fun loadList(append: Boolean) { fun loadNextPage() {
if (loadingJob?.isActive == true) { if (scrobblerMangaList.value.isNotEmpty() && hasNextPage.value) {
return loadList(append = true)
}
} }
if (append && !hasNextPage.value) {
fun retry() {
loadingJob?.cancel()
hasNextPage.value = true
scrobblerMangaList.value = emptyList()
loadList(append = false)
}
private fun loadList(append: Boolean) {
if (loadingJob?.isActive == true) {
return return
} }
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
val offset = if (append) scrobblerMangaList.value?.size ?: 0 else 0 listError.value = null
val list = currentScrobbler.findManga(checkNotNull(searchQuery.value), offset) val offset = if (append) scrobblerMangaList.value.size else 0
runCatchingCancellable {
currentScrobbler.findManga(checkNotNull(searchQuery.value), offset)
}.onSuccess { list ->
if (!append) { if (!append) {
scrobblerMangaList.value = list scrobblerMangaList.value = list
} else if (list.isNotEmpty()) { } else if (list.isNotEmpty()) {
scrobblerMangaList.value = scrobblerMangaList.value?.plus(list) ?: list scrobblerMangaList.value = scrobblerMangaList.value + list
} }
hasNextPage.value = list.isNotEmpty() hasNextPage.value = list.isNotEmpty()
}.onFailure { error ->
error.printStackTraceDebug()
listError.value = error
}
} }
} }
@ -113,8 +143,8 @@ class ScrobblingSelectorViewModel @AssistedInject constructor(
private fun initialize() { private fun initialize() {
initJob?.cancel() initJob?.cancel()
loadingJob?.cancel() loadingJob?.cancel()
hasNextPage.value = false hasNextPage.value = true
scrobblerMangaList.value = null scrobblerMangaList.value = emptyList()
initJob = launchJob(Dispatchers.Default) { initJob = launchJob(Dispatchers.Default) {
try { try {
val info = currentScrobbler.getScrobblingInfoOrNull(manga.id) 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, icon = R.drawable.ic_empty_history,
textPrimary = R.string.nothing_found, textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary, textSecondary = R.string.text_search_holder_secondary,
error = null,
actionStringRes = R.string.search, 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 @AssistedFactory
interface Factory { 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 com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener 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.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.ui.selector.model.ScrobblerHint
import kotlin.jvm.internal.Intrinsics import kotlin.jvm.internal.Intrinsics
class ShikimoriSelectorAdapter( class ShikimoriSelectorAdapter(
@ -24,7 +24,7 @@ class ShikimoriSelectorAdapter(
delegatesManager.addDelegate(loadingStateAD()) delegatesManager.addDelegate(loadingStateAD())
.addDelegate(scrobblingMangaAD(lifecycleOwner, coil, clickListener)) .addDelegate(scrobblingMangaAD(lifecycleOwner, coil, clickListener))
.addDelegate(loadingFooterAD()) .addDelegate(loadingFooterAD())
.addDelegate(emptyHintAD(stateHolderListener)) .addDelegate(scrobblerHintAD(stateHolderListener))
} }
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() { private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
@ -33,6 +33,7 @@ class ShikimoriSelectorAdapter(
return when { return when {
oldItem === newItem -> true oldItem === newItem -> true
oldItem is ScrobblerManga && newItem is ScrobblerManga -> oldItem.id == newItem.id oldItem is ScrobblerManga && newItem is ScrobblerManga -> oldItem.id == newItem.id
oldItem is ScrobblerHint && newItem is ScrobblerHint -> oldItem.textPrimary == newItem.textPrimary
else -> false 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
}
}

@ -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>

@ -4,9 +4,9 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.4.0' classpath 'com.android.tools.build:gradle:7.4.1'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10'
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.44' classpath 'com.google.dagger:hilt-android-gradle-plugin:2.44.2'
} }
} }

Loading…
Cancel
Save