Merge branch 'devel' into feature/mal

This commit is contained in:
Zakhar Timoshenko
2023-02-05 09:45:55 +03:00
26 changed files with 453 additions and 70 deletions

View File

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

View File

@@ -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()
}
}
}
}

View File

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

View File

@@ -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 <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
var lastValue: T = valueProducer()
@@ -33,3 +38,13 @@ fun <T> AppSettings.observeAsLiveData(
}
}
}
fun <T> AppSettings.observeAsStateFlow(
key: String,
scope: CoroutineScope,
valueProducer: AppSettings.() -> T,
): StateFlow<T> = observe().transform {
if (it == key) {
emit(valueProducer())
}
}.stateIn(scope, SharingStarted.Eagerly, valueProducer())

View File

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

View File

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

View File

@@ -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<Manga>()
val onActionDone = SingleLiveEvent<ReversibleAction>()
val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext)
val content: LiveData<List<ExploreItem>> = 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<MangaSource>): List<ExploreItem> {
private fun buildList(sources: List<MangaSource>, isGrid: Boolean): List<ExploreItem> {
val result = ArrayList<ExploreItem>(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,

View File

@@ -11,11 +11,25 @@ class ExploreAdapter(
lifecycleOwner: LifecycleOwner,
listener: ExploreListEventListener,
clickListener: OnListItemClickListener<ExploreItem.Source>,
) : AsyncListDifferDelegationAdapter<ExploreItem>(
ExploreDiffCallback(),
exploreButtonsAD(listener),
exploreSourcesHeaderAD(listener),
exploreSourceItemAD(coil, clickListener, lifecycleOwner),
exploreEmptyHintListAD(listener),
exploreLoadingAD(),
)
) : AsyncListDifferDelegationAdapter<ExploreItem>(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
}
}

View File

@@ -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<ExploreItem.Buttons, ExploreItem, ItemExploreButtonsBinding>(
{ 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<ExploreItem.Header, ExploreItem, ItemHeaderButtonBinding>(
{ 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<ExploreItem.Source>,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceBinding>(
{ layoutInflater, parent -> ItemExploreSourceBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is ExploreItem.Source }
) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceListBinding>(
{ 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<ExploreItem.Source>,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceGridBinding>(
{ 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<ExploreItem.EmptyHint, ExploreItem, ItemEmptyCardBinding>(
{ inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) }
{ inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) },
) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }

View File

@@ -12,11 +12,13 @@ class ExploreDiffCallback : DiffUtil.ItemCallback<ExploreItem>() {
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<ExploreItem>() {
override fun areContentsTheSame(oldItem: ExploreItem, newItem: ExploreItem): Boolean {
return oldItem == newItem
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ListModel>() {
@@ -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
}
}

View File

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

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
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:id="@+id/recyclerView"
android:layout_width="match_parent"
@@ -9,9 +8,8 @@
android:clipToPadding="false"
android:orientation="vertical"
android:paddingLeft="@dimen/list_spacing"
android:paddingRight="@dimen/list_spacing"
android:paddingTop="@dimen/grid_spacing_outer"
android:paddingRight="@dimen/list_spacing"
android:paddingBottom="@dimen/grid_spacing_outer"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_explore_source" />
tools:listitem="@layout/item_explore_source_list" />

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

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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:background="@drawable/list_selector"
android:clipChildren="false"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="@dimen/list_spacing"
tools:layout_width="120dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_icon"
android:layout_width="72dp"
android:layout_height="72dp"
android:background="?colorControlHighlight"
android:labelFor="@id/textView_title"
android:scaleType="fitCenter"
app:shapeAppearance="?shapeAppearanceCornerMedium"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_spacing"
android:elegantTextHeight="false"
android:ellipsize="end"
android:gravity="center_horizontal"
android:singleLine="true"
android:textAlignment="center"
android:textAppearance="?attr/textAppearanceBodyMedium"
tools:text="@tools:sample/lorem[2]" />
</LinearLayout>

View File

@@ -31,6 +31,7 @@
<dimen name="reading_progress_text_size">10dp</dimen>
<dimen name="reader_bar_inset_fallback">8dp</dimen>
<dimen name="scrobbling_list_spacing">12dp</dimen>
<dimen name="explore_grid_width">120dp</dimen>
<dimen name="search_suggestions_manga_height">124dp</dimen>
<dimen name="search_suggestions_manga_spacing">4dp</dimen>

View File

@@ -412,4 +412,5 @@
<string name="theme_name_dynamic">Dynamic</string>
<string name="color_theme">Color scheme</string>
<string name="theme_name_october">October</string>
<string name="show_in_grid_view">Show in grid view</string>
</resources>

View File

@@ -9,10 +9,16 @@
android:key="remote_sources"
android:title="@string/remote_sources" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="sources_grid"
android:title="@string/show_in_grid_view" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.SuggestionsSettingsFragment"
android:key="suggestions"
android:title="@string/suggestions" />
android:title="@string/suggestions"
app:allowDividerAbove="true" />
<ListPreference
android:entries="@array/doh_providers"