diff --git a/.gitignore b/.gitignore
index 56cee6345..621f3e800 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml
+/.idea/kotlinc.xml
/.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/render.experimental.xml
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
deleted file mode 100644
index 22dcb880f..000000000
--- a/.idea/kotlinc.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index ad55f77f0..1b8ed2bc9 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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"
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/SpanSizeResolver.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/SpanSizeResolver.kt
new file mode 100644
index 000000000..71e5dc398
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/SpanSizeResolver.kt
@@ -0,0 +1,55 @@
+package org.koitharu.kotatsu.base.ui.util
+
+import android.view.View
+import androidx.annotation.Px
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import org.koitharu.kotatsu.parsers.util.toIntUp
+import kotlin.math.abs
+
+class SpanSizeResolver(
+ private val recyclerView: RecyclerView,
+ @Px private val minItemWidth: Int,
+) : View.OnLayoutChangeListener {
+
+ fun attach() {
+ recyclerView.addOnLayoutChangeListener(this)
+ }
+
+ fun detach() {
+ recyclerView.removeOnLayoutChangeListener(this)
+ }
+
+ override fun onLayoutChange(
+ v: View?,
+ left: Int,
+ top: Int,
+ right: Int,
+ bottom: Int,
+ oldLeft: Int,
+ oldTop: Int,
+ oldRight: Int,
+ oldBottom: Int,
+ ) {
+ invalidateInternal(abs(right - left))
+ }
+
+ fun invalidate() {
+ invalidateInternal(recyclerView.width)
+ }
+
+ private fun invalidateInternal(width: Int) {
+ if (width <= 0) {
+ return
+ }
+ val lm = recyclerView.layoutManager as? GridLayoutManager ?: return
+ val estimatedCount = (width / minItemWidth.toFloat()).toIntUp()
+ if (lm.spanCount != estimatedCount) {
+ lm.spanCount = estimatedCount
+ lm.spanSizeLookup?.run {
+ invalidateSpanGroupIndexCache()
+ invalidateSpanIndexCache()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
index e6af87f8c..5ade1ae07 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
@@ -205,6 +205,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct()
}
+ var isSourcesGridMode: Boolean
+ get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
+ set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
+
val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
@@ -377,6 +381,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging"
const val KEY_LOGS_SHARE = "logs_share"
+ const val KEY_SOURCES_GRID = "sources_grid"
// About
const val KEY_APP_UPDATE = "app_update"
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt
index a8e46ccc2..606ae9d84 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt
@@ -1,8 +1,13 @@
package org.koitharu.kotatsu.core.prefs
import androidx.lifecycle.liveData
-import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.transform
+import kotlin.coroutines.CoroutineContext
fun AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
var lastValue: T = valueProducer()
@@ -33,3 +38,13 @@ fun AppSettings.observeAsLiveData(
}
}
}
+
+fun AppSettings.observeAsStateFlow(
+ key: String,
+ scope: CoroutineScope,
+ valueProducer: AppSettings.() -> T,
+): StateFlow = observe().transform {
+ if (it == key) {
+ emit(valueProducer())
+ }
+}.stateIn(scope, SharingStarted.Eagerly, valueProducer())
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
index 98086832c..ac681432b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
@@ -9,6 +9,8 @@ import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
@@ -19,6 +21,7 @@ import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
+import org.koitharu.kotatsu.base.ui.util.SpanSizeResolver
import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -63,6 +66,7 @@ class ExploreFragment :
with(binding.recyclerView) {
adapter = exploreAdapter
setHasFixedSize(true)
+ SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach()
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
paddingHorizontal = spacing
}
@@ -72,6 +76,7 @@ class ExploreFragment :
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
+ viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged)
}
override fun onDestroyView() {
@@ -149,6 +154,16 @@ class ExploreFragment :
snackbar.show()
}
+ private fun onGridModeChanged(isGrid: Boolean) {
+ binding.recyclerView.layoutManager = if (isGrid) {
+ GridLayoutManager(requireContext(), 4).also { lm ->
+ lm.spanSizeLookup = ExploreGridSpanSizeLookup(checkNotNull(exploreAdapter), lm)
+ }
+ } else {
+ LinearLayoutManager(requireContext())
+ }
+ }
+
private inner class SourceMenuListener(
private val sourceItem: ExploreItem.Source,
) : PopupMenu.OnMenuItemClickListener {
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreGridSpanSizeLookup.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreGridSpanSizeLookup.kt
new file mode 100644
index 000000000..bb02ff6f9
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreGridSpanSizeLookup.kt
@@ -0,0 +1,16 @@
+package org.koitharu.kotatsu.explore.ui
+
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
+import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter
+
+class ExploreGridSpanSizeLookup(
+ private val adapter: ExploreAdapter,
+ private val layoutManager: GridLayoutManager,
+) : SpanSizeLookup() {
+
+ override fun getSpanSize(position: Int): Int {
+ val itemType = adapter.getItemViewType(position)
+ return if (itemType == ExploreAdapter.ITEM_TYPE_SOURCE_GRID) 1 else layoutManager.spanCount
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
index 7783ebac0..5ab8c8c27 100644
--- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
@@ -5,22 +5,26 @@ import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.SingleLiveEvent
+import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@@ -30,8 +34,15 @@ class ExploreViewModel @Inject constructor(
private val exploreRepository: ExploreRepository,
) : BaseViewModel() {
+ private val gridMode = settings.observeAsStateFlow(
+ key = AppSettings.KEY_SOURCES_GRID,
+ scope = viewModelScope + Dispatchers.IO,
+ valueProducer = { isSourcesGridMode },
+ )
+
val onOpenManga = SingleLiveEvent()
val onActionDone = SingleLiveEvent()
+ val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext)
val content: LiveData> = isLoading.asFlow().flatMapLatest { loading ->
if (loading) {
@@ -67,16 +78,16 @@ class ExploreViewModel @Inject constructor(
.onStart { emit("") }
.map { settings.getMangaSources(includeHidden = false) }
.distinctUntilChanged()
- .map { buildList(it) }
+ .combine(gridMode) { content, grid -> buildList(content, grid) }
- private fun buildList(sources: List): List {
+ private fun buildList(sources: List, isGrid: Boolean): List {
val result = ArrayList(sources.size + 3)
result += ExploreItem.Buttons(
isSuggestionsEnabled = settings.isSuggestionsEnabled,
)
result += ExploreItem.Header(R.string.remote_sources, sources.isNotEmpty())
if (sources.isNotEmpty()) {
- sources.mapTo(result) { ExploreItem.Source(it) }
+ sources.mapTo(result) { ExploreItem.Source(it, isGrid) }
} else {
result += ExploreItem.EmptyHint(
icon = R.drawable.ic_empty_common,
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt
index 10a40c900..51f5ccc13 100644
--- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt
@@ -11,11 +11,25 @@ class ExploreAdapter(
lifecycleOwner: LifecycleOwner,
listener: ExploreListEventListener,
clickListener: OnListItemClickListener,
-) : AsyncListDifferDelegationAdapter(
- ExploreDiffCallback(),
- exploreButtonsAD(listener),
- exploreSourcesHeaderAD(listener),
- exploreSourceItemAD(coil, clickListener, lifecycleOwner),
- exploreEmptyHintListAD(listener),
- exploreLoadingAD(),
-)
\ No newline at end of file
+) : AsyncListDifferDelegationAdapter(ExploreDiffCallback()) {
+
+ init {
+ delegatesManager
+ .addDelegate(ITEM_TYPE_BUTTONS, exploreButtonsAD(listener))
+ .addDelegate(ITEM_TYPE_HEADER, exploreSourcesHeaderAD(listener))
+ .addDelegate(ITEM_TYPE_SOURCE_LIST, exploreSourceListItemAD(coil, clickListener, lifecycleOwner))
+ .addDelegate(ITEM_TYPE_SOURCE_GRID, exploreSourceGridItemAD(coil, clickListener, lifecycleOwner))
+ .addDelegate(ITEM_TYPE_HINT, exploreEmptyHintListAD(listener))
+ .addDelegate(ITEM_TYPE_LOADING, exploreLoadingAD())
+ }
+
+ companion object {
+
+ const val ITEM_TYPE_BUTTONS = 0
+ const val ITEM_TYPE_HEADER = 1
+ const val ITEM_TYPE_SOURCE_LIST = 2
+ const val ITEM_TYPE_SOURCE_GRID = 3
+ const val ITEM_TYPE_HINT = 4
+ const val ITEM_TYPE_LOADING = 5
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt
index 6712ab46e..c1cf4f753 100644
--- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt
@@ -12,7 +12,8 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
-import org.koitharu.kotatsu.databinding.ItemExploreSourceBinding
+import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding
+import org.koitharu.kotatsu.databinding.ItemExploreSourceListBinding
import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding
import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -25,7 +26,7 @@ import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable
fun exploreButtonsAD(
clickListener: View.OnClickListener,
) = adapterDelegateViewBinding(
- { layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) }
+ { layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) },
) {
binding.buttonBookmarks.setOnClickListener(clickListener)
@@ -43,7 +44,7 @@ fun exploreButtonsAD(
fun exploreSourcesHeaderAD(
listener: ExploreListEventListener,
) = adapterDelegateViewBinding(
- { layoutInflater, parent -> ItemHeaderButtonBinding.inflate(layoutInflater, parent, false) }
+ { layoutInflater, parent -> ItemHeaderButtonBinding.inflate(layoutInflater, parent, false) },
) {
val listenerAdapter = View.OnClickListener {
@@ -58,13 +59,44 @@ fun exploreSourcesHeaderAD(
}
}
-fun exploreSourceItemAD(
+fun exploreSourceListItemAD(
coil: ImageLoader,
listener: OnListItemClickListener,
lifecycleOwner: LifecycleOwner,
-) = adapterDelegateViewBinding(
- { layoutInflater, parent -> ItemExploreSourceBinding.inflate(layoutInflater, parent, false) },
- on = { item, _, _ -> item is ExploreItem.Source }
+) = adapterDelegateViewBinding(
+ { layoutInflater, parent -> ItemExploreSourceListBinding.inflate(layoutInflater, parent, false) },
+ on = { item, _, _ -> item is ExploreItem.Source && !item.isGrid },
+) {
+
+ val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
+
+ binding.root.setOnClickListener(eventListener)
+ binding.root.setOnLongClickListener(eventListener)
+
+ bind {
+ binding.textViewTitle.text = item.source.title
+ val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
+ binding.imageViewIcon.newImageRequest(item.source.faviconUri())?.run {
+ fallback(fallbackIcon)
+ placeholder(fallbackIcon)
+ error(fallbackIcon)
+ lifecycle(lifecycleOwner)
+ enqueueWith(coil)
+ }
+ }
+
+ onViewRecycled {
+ binding.imageViewIcon.disposeImageRequest()
+ }
+}
+
+fun exploreSourceGridItemAD(
+ coil: ImageLoader,
+ listener: OnListItemClickListener,
+ lifecycleOwner: LifecycleOwner,
+) = adapterDelegateViewBinding(
+ { layoutInflater, parent -> ItemExploreSourceGridBinding.inflate(layoutInflater, parent, false) },
+ on = { item, _, _ -> item is ExploreItem.Source && item.isGrid },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
@@ -92,7 +124,7 @@ fun exploreSourceItemAD(
fun exploreEmptyHintListAD(
listener: ListStateHolderListener,
) = adapterDelegateViewBinding(
- { inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) }
+ { inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) },
) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreDiffCallback.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreDiffCallback.kt
index 352edd401..d8bf22bd9 100644
--- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreDiffCallback.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreDiffCallback.kt
@@ -12,11 +12,13 @@ class ExploreDiffCallback : DiffUtil.ItemCallback() {
oldItem is ExploreItem.Loading && newItem is ExploreItem.Loading -> true
oldItem is ExploreItem.EmptyHint && newItem is ExploreItem.EmptyHint -> true
oldItem is ExploreItem.Source && newItem is ExploreItem.Source -> {
- oldItem.source == newItem.source
+ oldItem.source == newItem.source && oldItem.isGrid == newItem.isGrid
}
+
oldItem is ExploreItem.Header && newItem is ExploreItem.Header -> {
oldItem.titleResId == newItem.titleResId
}
+
else -> false
}
}
@@ -24,4 +26,4 @@ class ExploreDiffCallback : DiffUtil.ItemCallback() {
override fun areContentsTheSame(oldItem: ExploreItem, newItem: ExploreItem): Boolean {
return oldItem == newItem
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt
index 4420a415d..5adb55563 100644
--- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt
@@ -54,6 +54,7 @@ sealed interface ExploreItem : ListModel {
class Source(
val source: MangaSource,
+ val isGrid: Boolean,
) : ExploreItem {
override fun equals(other: Any?): Boolean {
@@ -63,12 +64,15 @@ sealed interface ExploreItem : ListModel {
other as Source
if (source != other.source) return false
+ if (isGrid != other.isGrid) return false
return true
}
override fun hashCode(): Int {
- return source.hashCode()
+ var result = source.hashCode()
+ result = 31 * result + isGrid.hashCode()
+ return result
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt
index 0cffa3422..32b84b4ca 100644
--- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt
@@ -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 {
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt
index cb926248f..6c8a6ada1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt
@@ -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?>(null)
- private val hasNextPage = MutableStateFlow(false)
+ private val scrobblerMangaList = MutableStateFlow>(emptyList())
+ private val hasNextPage = MutableStateFlow(true)
+ private val listError = MutableStateFlow(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> = 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()
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 {
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerHintAD.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerHintAD.kt
new file mode 100644
index 000000000..311615826
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerHintAD.kt
@@ -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(
+ { 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)
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerSelectorAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerSelectorAdapter.kt
index cf1bd5aeb..d0f1d78b0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerSelectorAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerSelectorAdapter.kt
@@ -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 ScrobblerSelectorAdapter(
@@ -24,7 +24,7 @@ class ScrobblerSelectorAdapter(
delegatesManager.addDelegate(loadingStateAD())
.addDelegate(scrobblingMangaAD(lifecycleOwner, coil, clickListener))
.addDelegate(loadingFooterAD())
- .addDelegate(emptyHintAD(stateHolderListener))
+ .addDelegate(scrobblerHintAD(stateHolderListener))
}
private class DiffCallback : DiffUtil.ItemCallback() {
@@ -33,6 +33,7 @@ class ScrobblerSelectorAdapter(
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
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/model/ScrobblerHint.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/model/ScrobblerHint.kt
new file mode 100644
index 000000000..e30614da2
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/model/ScrobblerHint.kt
@@ -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
+ }
+}
diff --git a/app/src/main/res/layout/fragment_explore.xml b/app/src/main/res/layout/fragment_explore.xml
index dd7b22355..6267e23f6 100644
--- a/app/src/main/res/layout/fragment_explore.xml
+++ b/app/src/main/res/layout/fragment_explore.xml
@@ -1,7 +1,6 @@
+ tools:listitem="@layout/item_explore_source_list" />
diff --git a/app/src/main/res/layout/item_empty_hint.xml b/app/src/main/res/layout/item_empty_hint.xml
new file mode 100644
index 000000000..eb3fe5f7d
--- /dev/null
+++ b/app/src/main/res/layout/item_empty_hint.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_explore_source_grid.xml b/app/src/main/res/layout/item_explore_source_grid.xml
new file mode 100644
index 000000000..2f952ffee
--- /dev/null
+++ b/app/src/main/res/layout/item_explore_source_grid.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_explore_source.xml b/app/src/main/res/layout/item_explore_source_list.xml
similarity index 100%
rename from app/src/main/res/layout/item_explore_source.xml
rename to app/src/main/res/layout/item_explore_source_list.xml
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index d0d4412d9..b05f302df 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -31,6 +31,7 @@
10dp
8dp
12dp
+ 120dp
124dp
4dp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 62fa87ed3..32b7a0b76 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -412,4 +412,5 @@
Dynamic
Color scheme
October
+ Show in grid view
diff --git a/app/src/main/res/xml/pref_content.xml b/app/src/main/res/xml/pref_content.xml
index 1c94f2674..176d8931c 100644
--- a/app/src/main/res/xml/pref_content.xml
+++ b/app/src/main/res/xml/pref_content.xml
@@ -9,10 +9,16 @@
android:key="remote_sources"
android:title="@string/remote_sources" />
+
+
+ android:title="@string/suggestions"
+ app:allowDividerAbove="true" />