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

1
.gitignore vendored
View File

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

9
.idea/kotlinc.xml generated
View File

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

View File

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

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() 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 val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) 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_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging" const val KEY_LOGGING_ENABLED = "logging"
const val KEY_LOGS_SHARE = "logs_share" const val KEY_LOGS_SHARE = "logs_share"
const val KEY_SOURCES_GRID = "sources_grid"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -1,8 +1,13 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import androidx.lifecycle.liveData 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.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 { fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
var lastValue: T = valueProducer() 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.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar 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.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleAction 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.bookmarks.ui.BookmarksActivity
import org.koitharu.kotatsu.databinding.FragmentExploreBinding import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -63,6 +66,7 @@ class ExploreFragment :
with(binding.recyclerView) { with(binding.recyclerView) {
adapter = exploreAdapter adapter = exploreAdapter
setHasFixedSize(true) setHasFixedSize(true)
SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach()
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
paddingHorizontal = spacing paddingHorizontal = spacing
} }
@@ -72,6 +76,7 @@ class ExploreFragment :
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga) viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone) viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged)
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -149,6 +154,16 @@ class ExploreFragment :
snackbar.show() 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 inner class SourceMenuListener(
private val sourceItem: ExploreItem.Source, private val sourceItem: ExploreItem.Source,
) : PopupMenu.OnMenuItemClickListener { ) : 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 androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings 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.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreItem import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject import javax.inject.Inject
@@ -30,8 +34,15 @@ class ExploreViewModel @Inject constructor(
private val exploreRepository: ExploreRepository, private val exploreRepository: ExploreRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val gridMode = settings.observeAsStateFlow(
key = AppSettings.KEY_SOURCES_GRID,
scope = viewModelScope + Dispatchers.IO,
valueProducer = { isSourcesGridMode },
)
val onOpenManga = SingleLiveEvent<Manga>() val onOpenManga = SingleLiveEvent<Manga>()
val onActionDone = SingleLiveEvent<ReversibleAction>() val onActionDone = SingleLiveEvent<ReversibleAction>()
val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext)
val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading -> val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading ->
if (loading) { if (loading) {
@@ -67,16 +78,16 @@ class ExploreViewModel @Inject constructor(
.onStart { emit("") } .onStart { emit("") }
.map { settings.getMangaSources(includeHidden = false) } .map { settings.getMangaSources(includeHidden = false) }
.distinctUntilChanged() .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) val result = ArrayList<ExploreItem>(sources.size + 3)
result += ExploreItem.Buttons( result += ExploreItem.Buttons(
isSuggestionsEnabled = settings.isSuggestionsEnabled, isSuggestionsEnabled = settings.isSuggestionsEnabled,
) )
result += ExploreItem.Header(R.string.remote_sources, sources.isNotEmpty()) result += ExploreItem.Header(R.string.remote_sources, sources.isNotEmpty())
if (sources.isNotEmpty()) { if (sources.isNotEmpty()) {
sources.mapTo(result) { ExploreItem.Source(it) } sources.mapTo(result) { ExploreItem.Source(it, isGrid) }
} else { } else {
result += ExploreItem.EmptyHint( result += ExploreItem.EmptyHint(
icon = R.drawable.ic_empty_common, icon = R.drawable.ic_empty_common,

View File

@@ -11,11 +11,25 @@ class ExploreAdapter(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
listener: ExploreListEventListener, listener: ExploreListEventListener,
clickListener: OnListItemClickListener<ExploreItem.Source>, clickListener: OnListItemClickListener<ExploreItem.Source>,
) : AsyncListDifferDelegationAdapter<ExploreItem>( ) : AsyncListDifferDelegationAdapter<ExploreItem>(ExploreDiffCallback()) {
ExploreDiffCallback(),
exploreButtonsAD(listener), init {
exploreSourcesHeaderAD(listener), delegatesManager
exploreSourceItemAD(coil, clickListener, lifecycleOwner), .addDelegate(ITEM_TYPE_BUTTONS, exploreButtonsAD(listener))
exploreEmptyHintListAD(listener), .addDelegate(ITEM_TYPE_HEADER, exploreSourcesHeaderAD(listener))
exploreLoadingAD(), .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.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding 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.databinding.ItemHeaderButtonBinding
import org.koitharu.kotatsu.explore.ui.model.ExploreItem import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -25,7 +26,7 @@ import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable
fun exploreButtonsAD( fun exploreButtonsAD(
clickListener: View.OnClickListener, clickListener: View.OnClickListener,
) = adapterDelegateViewBinding<ExploreItem.Buttons, ExploreItem, ItemExploreButtonsBinding>( ) = adapterDelegateViewBinding<ExploreItem.Buttons, ExploreItem, ItemExploreButtonsBinding>(
{ layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) } { layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) },
) { ) {
binding.buttonBookmarks.setOnClickListener(clickListener) binding.buttonBookmarks.setOnClickListener(clickListener)
@@ -43,7 +44,7 @@ fun exploreButtonsAD(
fun exploreSourcesHeaderAD( fun exploreSourcesHeaderAD(
listener: ExploreListEventListener, listener: ExploreListEventListener,
) = adapterDelegateViewBinding<ExploreItem.Header, ExploreItem, ItemHeaderButtonBinding>( ) = adapterDelegateViewBinding<ExploreItem.Header, ExploreItem, ItemHeaderButtonBinding>(
{ layoutInflater, parent -> ItemHeaderButtonBinding.inflate(layoutInflater, parent, false) } { layoutInflater, parent -> ItemHeaderButtonBinding.inflate(layoutInflater, parent, false) },
) { ) {
val listenerAdapter = View.OnClickListener { val listenerAdapter = View.OnClickListener {
@@ -58,13 +59,44 @@ fun exploreSourcesHeaderAD(
} }
} }
fun exploreSourceItemAD( fun exploreSourceListItemAD(
coil: ImageLoader, coil: ImageLoader,
listener: OnListItemClickListener<ExploreItem.Source>, listener: OnListItemClickListener<ExploreItem.Source>,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceBinding>( ) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceListBinding>(
{ layoutInflater, parent -> ItemExploreSourceBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent -> ItemExploreSourceListBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is ExploreItem.Source } 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) val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
@@ -92,7 +124,7 @@ fun exploreSourceItemAD(
fun exploreEmptyHintListAD( fun exploreEmptyHintListAD(
listener: ListStateHolderListener, listener: ListStateHolderListener,
) = adapterDelegateViewBinding<ExploreItem.EmptyHint, ExploreItem, ItemEmptyCardBinding>( ) = adapterDelegateViewBinding<ExploreItem.EmptyHint, ExploreItem, ItemEmptyCardBinding>(
{ inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) },
) { ) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() } 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.Loading && newItem is ExploreItem.Loading -> true
oldItem is ExploreItem.EmptyHint && newItem is ExploreItem.EmptyHint -> true oldItem is ExploreItem.EmptyHint && newItem is ExploreItem.EmptyHint -> true
oldItem is ExploreItem.Source && newItem is ExploreItem.Source -> { 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 is ExploreItem.Header && newItem is ExploreItem.Header -> {
oldItem.titleResId == newItem.titleResId oldItem.titleResId == newItem.titleResId
} }
else -> false else -> false
} }
} }
@@ -24,4 +26,4 @@ class ExploreDiffCallback : DiffUtil.ItemCallback<ExploreItem>() {
override fun areContentsTheSame(oldItem: ExploreItem, newItem: ExploreItem): Boolean { override fun areContentsTheSame(oldItem: ExploreItem, newItem: ExploreItem): Boolean {
return oldItem == newItem return oldItem == newItem
} }
} }

View File

@@ -54,6 +54,7 @@ sealed interface ExploreItem : ListModel {
class Source( class Source(
val source: MangaSource, val source: MangaSource,
val isGrid: Boolean,
) : ExploreItem { ) : ExploreItem {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@@ -63,12 +64,15 @@ sealed interface ExploreItem : ListModel {
other as Source other as Source
if (source != other.source) return false if (source != other.source) return false
if (isGrid != other.isGrid) return false
return true return true
} }
override fun hashCode(): Int { 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) { 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 {

View File

@@ -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 ->
when { if (list.isNotEmpty()) {
list.isEmpty() -> listOf(emptyResultsHint()) if (isHasNextPage) {
isHasNextPage -> list + LoadingFooter list + LoadingFooter
else -> list } else {
list
}
} else {
listOf(
when {
error != null -> errorHint(error)
isHasNextPage -> LoadingFooter
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 (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) { if (loadingJob?.isActive == true) {
return return
} }
if (append && !hasNextPage.value) {
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
if (!append) { runCatchingCancellable {
scrobblerMangaList.value = list currentScrobbler.findManga(checkNotNull(searchQuery.value), offset)
} else if (list.isNotEmpty()) { }.onSuccess { list ->
scrobblerMangaList.value = scrobblerMangaList.value?.plus(list) ?: 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() { 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 {

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 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 ScrobblerSelectorAdapter( class ScrobblerSelectorAdapter(
@@ -24,7 +24,7 @@ class ScrobblerSelectorAdapter(
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 ScrobblerSelectorAdapter(
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
} }
} }

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

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="reading_progress_text_size">10dp</dimen>
<dimen name="reader_bar_inset_fallback">8dp</dimen> <dimen name="reader_bar_inset_fallback">8dp</dimen>
<dimen name="scrobbling_list_spacing">12dp</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_height">124dp</dimen>
<dimen name="search_suggestions_manga_spacing">4dp</dimen> <dimen name="search_suggestions_manga_spacing">4dp</dimen>

View File

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

View File

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

View File

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