From 71f2c91e5aa66ae447a72db7b9cbe628aa5babc7 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 23 Dec 2023 08:20:05 +0200 Subject: [PATCH] Improve sources catalog --- .../browser/cloudflare/CaptchaNotifier.kt | 11 +++- .../kotatsu/core/model/MangaSource.kt | 9 ++-- .../core/ui/util/WindowInsetsDelegate.kt | 2 +- .../kotatsu/core/util/ext/LocaleList.kt | 7 +-- .../sources/catalog/SourceCatalogItemAD.kt | 33 ++++++++++++ .../sources/catalog/SourceCatalogPage.kt | 19 +++++++ .../sources/catalog/SourcesCatalogActivity.kt | 54 +++++-------------- .../catalog/SourcesCatalogMenuProvider.kt | 12 +++-- .../catalog/SourcesCatalogPagerAdapter.kt | 25 +++++++++ .../catalog/SourcesCatalogViewModel.kt | 48 +++++++++-------- .../res/layout/activity_sources_catalog.xml | 20 ++----- app/src/main/res/layout/item_catalog_page.xml | 19 +++++++ 12 files changed, 165 insertions(+), 94 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogPagerAdapter.kt create mode 100644 app/src/main/res/layout/item_catalog_page.xml diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt index 65a4966ea..11a2a47c7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt @@ -61,13 +61,20 @@ class CaptchaNotifier( override fun onError(request: ImageRequest, result: ErrorResult) { super.onError(request, result) val e = result.throwable - if (e is CloudFlareProtectedException) { + if (e is CloudFlareProtectedException && request.parameters.value(PARAM_IGNORE_CAPTCHA) != true) { notify(e) } } - private companion object { + companion object { + fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter( + key = PARAM_IGNORE_CAPTCHA, + value = true, + memoryCacheKey = null, + ) + + private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha" private const val CHANNEL_ID = "captcha" private const val TAG = CHANNEL_ID } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt index d09f69d06..dc736c8b9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -10,18 +10,15 @@ import androidx.annotation.StringRes import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.toTitleCase import java.util.Locale import com.google.android.material.R as materialR -fun MangaSource.getLocaleTitle(): String? { - val lc = Locale(locale ?: return null) - return lc.getDisplayLanguage(lc).toTitleCase(lc) -} - fun MangaSource(name: String): MangaSource { MangaSource.entries.forEach { if (it.name == name) return it @@ -42,7 +39,7 @@ val ContentType.titleResId fun MangaSource.getSummary(context: Context): String { val type = context.getString(contentType.titleResId) - val locale = getLocaleTitle() ?: context.getString(R.string.various_languages) + val locale = locale?.toLocale().getDisplayName(context) return context.getString(R.string.source_summary_pattern, type, locale) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt index a5e8c2d43..aa3ce78d1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt @@ -70,7 +70,7 @@ class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeLis lastInsets = null } - interface WindowInsetsListener { + fun interface WindowInsetsListener { fun onWindowInsetsChanged(insets: Insets) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LocaleList.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LocaleList.kt index c9e0cb3ef..7d817c6c1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LocaleList.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LocaleList.kt @@ -20,12 +20,13 @@ inline fun LocaleListCompat.mapToSet(block: (Locale) -> T): Set { fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException() -fun String?.getLocaleDisplayName(context: Context): String { +fun String.toLocale() = Locale(this) + +fun Locale?.getDisplayName(context: Context): String { if (this == null) { return context.getString(R.string.various_languages) } - val lc = Locale(this) - return lc.getDisplayLanguage(lc).toTitleCase(lc) + return getDisplayLanguage(this).toTitleCase(this) } private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt index 006bbb0ef..4b267125a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt @@ -1,20 +1,25 @@ package org.koitharu.kotatsu.settings.sources.catalog +import androidx.core.view.ViewCompat import androidx.core.view.isVisible +import androidx.core.view.updatePadding import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier.Companion.ignoreCaptchaErrors import org.koitharu.kotatsu.core.model.getSummary import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.ui.image.FaviconDrawable import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.source +import org.koitharu.kotatsu.databinding.ItemCatalogPageBinding import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding import org.koitharu.kotatsu.databinding.ItemSourceCatalogBinding @@ -47,6 +52,7 @@ fun sourceCatalogItemSourceAD( placeholder(fallbackIcon) fallback(fallbackIcon) source(item.source) + ignoreCaptchaErrors() enqueueWith(coil) } } @@ -67,3 +73,30 @@ fun sourceCatalogItemHintAD( binding.textSecondary.setTextAndVisible(item.text) } } + +fun sourceCatalogPageAD( + listener: OnListItemClickListener, + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemCatalogPageBinding.inflate(inflater, parent, false) }, +) { + + val sourcesAdapter = SourcesCatalogAdapter(listener, coil, lifecycleOwner) + with(binding.recyclerView) { + setHasFixedSize(true) + adapter = sourcesAdapter + } + val insetsDelegate = WindowInsetsDelegate() + ViewCompat.setOnApplyWindowInsetsListener(itemView, insetsDelegate) + itemView.addOnLayoutChangeListener(insetsDelegate) + insetsDelegate.addInsetsListener { insets -> + binding.recyclerView.updatePadding( + bottom = insets.bottom + binding.recyclerView.paddingTop, + ) + } + + bind { + sourcesAdapter.items = item.items + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt new file mode 100644 index 000000000..b129022db --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt @@ -0,0 +1,19 @@ +package org.koitharu.kotatsu.settings.sources.catalog + +import org.koitharu.kotatsu.list.ui.ListModelDiffCallback +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.model.ContentType + +data class SourceCatalogPage( + val type: ContentType, + val items: List, +) : ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is SourceCatalogPage && other.type == type + } + + override fun getChangePayload(previousState: ListModel): Any? { + return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt index 1407b05c0..af70d59ae 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt @@ -10,24 +10,21 @@ import androidx.core.view.isVisible import androidx.core.view.updatePadding import coil.ImageLoader import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver -import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition -import org.koitharu.kotatsu.core.util.ext.getLocaleDisplayName +import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner -import org.koitharu.kotatsu.parsers.model.ContentType import javax.inject.Inject @AndroidEntryPoint class SourcesCatalogActivity : BaseActivity(), - TabLayout.OnTabSelectedListener, OnListItemClickListener, AppBarOwner, MenuItem.OnActionExpandListener { @@ -43,19 +40,17 @@ class SourcesCatalogActivity : BaseActivity(), super.onCreate(savedInstanceState) setContentView(ActivitySourcesCatalogBinding.inflate(layoutInflater)) supportActionBar?.setDisplayHomeAsUpEnabled(true) - initTabs() - val sourcesAdapter = SourcesCatalogAdapter(this, coil, this) - with(viewBinding.recyclerView) { - setHasFixedSize(true) - adapter = sourcesAdapter - } - viewModel.content.observe(this, sourcesAdapter) + val pagerAdapter = SourcesCatalogPagerAdapter(this, coil, this) + viewBinding.pager.adapter = pagerAdapter + val tabMediator = TabLayoutMediator(viewBinding.tabs, viewBinding.pager, pagerAdapter) + tabMediator.attach() + viewModel.content.observe(this, pagerAdapter) viewModel.onActionDone.observeEvent( this, - ReversibleActionObserver(viewBinding.recyclerView), + ReversibleActionObserver(viewBinding.pager), ) viewModel.locale.observe(this) { - supportActionBar?.subtitle = it.getLocaleDisplayName(this) + supportActionBar?.subtitle = it?.toLocale().getDisplayName(this) } addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this)) } @@ -65,27 +60,15 @@ class SourcesCatalogActivity : BaseActivity(), left = insets.left, right = insets.right, ) - viewBinding.recyclerView.updatePadding( - bottom = insets.bottom + viewBinding.recyclerView.paddingTop, - ) } override fun onItemClick(item: SourceCatalogItem.Source, view: View) { viewModel.addSource(item.source) } - override fun onTabSelected(tab: TabLayout.Tab) { - viewModel.setContentType(tab.tag as ContentType) - } - - override fun onTabUnselected(tab: TabLayout.Tab) = Unit - - override fun onTabReselected(tab: TabLayout.Tab) { - viewBinding.recyclerView.firstVisibleItemPosition = 0 - } - override fun onMenuItemActionExpand(item: MenuItem): Boolean { viewBinding.tabs.isVisible = false + viewBinding.pager.isUserInputEnabled = false val sq = (item.actionView as? SearchView)?.query?.trim()?.toString().orEmpty() viewModel.performSearch(sq) return true @@ -93,21 +76,8 @@ class SourcesCatalogActivity : BaseActivity(), override fun onMenuItemActionCollapse(item: MenuItem): Boolean { viewBinding.tabs.isVisible = true + viewBinding.pager.isUserInputEnabled = true viewModel.performSearch(null) return true } - - private fun initTabs() { - val tabs = viewBinding.tabs - for (type in ContentType.entries) { - if (viewModel.isNsfwDisabled && type == ContentType.HENTAI) { - continue - } - val tab = tabs.newTab() - tab.setText(type.titleResId) - tab.tag = type - tabs.addTab(tab) - } - tabs.addOnTabSelectedListener(this) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogMenuProvider.kt index db09694b1..67154760e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogMenuProvider.kt @@ -9,7 +9,9 @@ import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.getLocaleDisplayName +import org.koitharu.kotatsu.core.util.LocaleComparator +import org.koitharu.kotatsu.core.util.ext.getDisplayName +import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.main.ui.owners.AppBarOwner class SourcesCatalogMenuProvider( @@ -57,15 +59,17 @@ class SourcesCatalogMenuProvider( } private fun showLocalesMenu() { - val locales = viewModel.locales.map { - it to it.getLocaleDisplayName(activity) + val locales = viewModel.locales.mapTo(ArrayList(viewModel.locales.size)) { + it to it?.toLocale() } + locales.sortWith(compareBy(nullsFirst(LocaleComparator())) { it.second }) + val anchor: View = (activity as AppBarOwner).appBar.let { it.findViewById(R.id.toolbar) ?: it } val menu = PopupMenu(activity, anchor) for ((i, lc) in locales.withIndex()) { - menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second) + menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second.getDisplayName(activity)) } menu.setOnMenuItemClickListener { viewModel.setLocale(locales.getOrNull(it.order)?.first) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogPagerAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogPagerAdapter.kt new file mode 100644 index 000000000..32f76fe15 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogPagerAdapter.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.settings.sources.catalog + +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import org.koitharu.kotatsu.core.model.titleResId +import org.koitharu.kotatsu.core.ui.BaseListAdapter +import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener + +class SourcesCatalogPagerAdapter( + listener: OnListItemClickListener, + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, +) : BaseListAdapter(), TabLayoutMediator.TabConfigurationStrategy { + + init { + delegatesManager.addDelegate(sourceCatalogPageAD(listener, coil, lifecycleOwner)) + } + + override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { + val item = items.getOrNull(position) ?: return + tab.setText(item.type.titleResId) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt index fcef5f987..135c8be4e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.settings.sources.catalog +import androidx.annotation.MainThread import androidx.lifecycle.viewModelScope import dagger.hilt.android.internal.lifecycle.RetainedLifecycleImpl import dagger.hilt.android.lifecycle.HiltViewModel @@ -8,9 +9,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel @@ -21,6 +23,8 @@ import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapToSet +import java.util.EnumMap +import java.util.EnumSet import java.util.Locale import javax.inject.Inject @@ -28,30 +32,23 @@ import javax.inject.Inject class SourcesCatalogViewModel @Inject constructor( private val repository: MangaSourcesRepository, private val listProducerFactory: SourcesCatalogListProducer.Factory, - settings: AppSettings, + private val settings: AppSettings, ) : BaseViewModel() { private val lifecycle = RetainedLifecycleImpl() private var searchQuery: String? = null val onActionDone = MutableEventFlow() - val contentType = MutableStateFlow(ContentType.entries.first()) val locales = repository.allMangaSources.mapToSet { it.locale } val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales }) - val isNsfwDisabled = settings.isNsfwContentDisabled + private val listProducers = locale.map { lc -> + createListProducers(lc) + }.stateIn(viewModelScope, SharingStarted.Eagerly, createListProducers(locale.value)) - private val listProducer: StateFlow = combine( - locale, - contentType, - ) { lc, type -> - listProducerFactory.create(lc, type, lifecycle).also { - it.setQuery(searchQuery) - } - }.stateIn(viewModelScope, SharingStarted.Eagerly, null) - - val content = listProducer.flatMapLatest { - it?.list ?: emptyFlow() - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val content: StateFlow> = listProducers.flatMapLatest { + val flows = it.entries.map { (type, producer) -> producer.list.map { x -> SourceCatalogPage(type, x) } } + combine>(flows, Array::toList) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) override fun onCleared() { super.onCleared() @@ -60,21 +57,30 @@ class SourcesCatalogViewModel @Inject constructor( fun performSearch(query: String?) { searchQuery = query - listProducer.value?.setQuery(query) + listProducers.value.forEach { (_, v) -> v.setQuery(query) } } fun setLocale(value: String?) { locale.value = value } - fun setContentType(value: ContentType) { - contentType.value = value - } - fun addSource(source: MangaSource) { launchJob(Dispatchers.Default) { val rollback = repository.setSourceEnabled(source, true) onActionDone.call(ReversibleAction(R.string.source_enabled, rollback)) } } + + @MainThread + private fun createListProducers(lc: String?): Map { + val types = EnumSet.allOf(ContentType::class.java) + if (settings.isNsfwContentDisabled) { + types.remove(ContentType.HENTAI) + } + return types.associateWithTo(EnumMap(ContentType::class.java)) { type -> + listProducerFactory.create(lc, type, lifecycle).also { + it.setQuery(searchQuery) + } + } + } } diff --git a/app/src/main/res/layout/activity_sources_catalog.xml b/app/src/main/res/layout/activity_sources_catalog.xml index 88e67d6d6..7b844cac0 100644 --- a/app/src/main/res/layout/activity_sources_catalog.xml +++ b/app/src/main/res/layout/activity_sources_catalog.xml @@ -11,7 +11,8 @@ android:id="@+id/appbar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:fitsSystemWindows="true"> + android:fitsSystemWindows="true" + app:liftOnScroll="false"> - - - - - + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" /> diff --git a/app/src/main/res/layout/item_catalog_page.xml b/app/src/main/res/layout/item_catalog_page.xml new file mode 100644 index 000000000..d43cdb9db --- /dev/null +++ b/app/src/main/res/layout/item_catalog_page.xml @@ -0,0 +1,19 @@ + + + + + +