Language filter support

pull/581/head
Koitharu 2 years ago
parent 357669d8b2
commit 64dc646fc5
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 602 versionCode = 603
versionName = '6.4.2' versionName = '6.5-b1'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@ -82,7 +82,7 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:0efd5437f9') { implementation('com.github.KotatsuApp:kotatsu-parsers:44c2c074a8') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }

@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.EnumMap import java.util.EnumMap
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.collections.set import kotlin.collections.set
@ -41,6 +42,8 @@ interface MangaRepository {
suspend fun getTags(): Set<MangaTag> suspend fun getTags(): Set<MangaTag>
suspend fun getLocales(): Set<Locale>
suspend fun getRelated(seed: Manga): List<Manga> suspend fun getRelated(seed: Manga): List<Manga>
@Singleton @Singleton

@ -32,6 +32,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale
class RemoteMangaRepository( class RemoteMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
@ -104,6 +105,10 @@ class RemoteMangaRepository(
parser.getAvailableTags() parser.getAvailableTags()
} }
override suspend fun getLocales(): Set<Locale> {
return parser.getAvailableLocales()
}
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFavicons() parser.getFavicons()
} }

@ -2,8 +2,10 @@ package org.koitharu.kotatsu.filter.ui
import android.content.Context import android.content.Context
import androidx.recyclerview.widget.AsyncListDiffer.ListListener import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
@ -21,6 +23,7 @@ class FilterAdapter(
addDelegate(ListItemType.FILTER_TAG, filterTagDelegate(listener)) addDelegate(ListItemType.FILTER_TAG, filterTagDelegate(listener))
addDelegate(ListItemType.FILTER_TAG_MULTI, filterTagMultipleDelegate(listener)) addDelegate(ListItemType.FILTER_TAG_MULTI, filterTagMultipleDelegate(listener))
addDelegate(ListItemType.FILTER_STATE, filterStateDelegate(listener)) addDelegate(ListItemType.FILTER_STATE, filterStateDelegate(listener))
addDelegate(ListItemType.FILTER_LANGUAGE, filterLanguageDelegate(listener))
addDelegate(ListItemType.HEADER, listHeaderAD(listener)) addDelegate(ListItemType.HEADER, listHeaderAD(listener))
addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
@ -31,10 +34,14 @@ class FilterAdapter(
override fun getSectionText(context: Context, position: Int): CharSequence? { override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items val list = items
for (i in (0..position).reversed()) { for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue val item = list.getOrNull(i) as? FilterItem ?: continue
if (item is FilterItem.Tag) { when (item) {
return item.tag.title.firstOrNull()?.toString() is FilterItem.Error -> null
} is FilterItem.Language -> item.getTitle(context.resources)
is FilterItem.Sort -> context.getString(item.order.titleRes)
is FilterItem.State -> context.getString(item.state.titleResId)
is FilterItem.Tag -> item.tag.title
}?.firstOrNull()?.uppercase()
} }
return null return null
} }

@ -44,6 +44,23 @@ fun filterStateDelegate(
} }
} }
fun filterLanguageDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Language, ListModel, ItemCheckableSingleBinding>(
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onLanguageItemClick(item)
}
bind { payloads ->
binding.root.text = item.getTitle(context.resources)
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
fun filterTagDelegate( fun filterTagDelegate(
listener: OnFilterChangedListener, listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableSingleBinding>( ) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableSingleBinding>(

@ -62,6 +62,7 @@ class FilterCoordinator @Inject constructor(
dataRepository.findTags(repository.source) dataRepository.findTags(repository.source)
} }
private var availableTagsDeferred = loadTagsAsync() private var availableTagsDeferred = loadTagsAsync()
private var availableLocalesDeferred = loadLocalesAsync()
override val filterItems: StateFlow<List<ListModel>> = getItemsFlow() override val filterItems: StateFlow<List<ListModel>> = getItemsFlow()
.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) .stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
@ -120,12 +121,18 @@ class FilterCoordinator @Inject constructor(
} }
} }
override fun onLanguageItemClick(item: FilterItem.Language) {
currentState.update { oldValue ->
oldValue.copy(locale = item.locale)
}
}
override fun onListHeaderClick(item: ListHeader, view: View) { override fun onListHeaderClick(item: ListHeader, view: View) {
currentState.update { oldValue -> currentState.update { oldValue ->
oldValue.copy( oldValue.copy(
sortOrder = oldValue.sortOrder, sortOrder = oldValue.sortOrder,
tags = if (item.payload == R.string.genres) emptySet() else oldValue.tags, tags = if (item.payload == R.string.genres) emptySet() else oldValue.tags,
locale = null, locale = if (item.payload == R.string.language) null else oldValue.locale,
states = if (item.payload == R.string.state) emptySet() else oldValue.states, states = if (item.payload == R.string.state) emptySet() else oldValue.states,
) )
} }
@ -173,20 +180,31 @@ class FilterCoordinator @Inject constructor(
private fun getItemsFlow() = combine( private fun getItemsFlow() = combine(
getTagsAsFlow(), getTagsAsFlow(),
getLocalesAsFlow(),
currentState, currentState,
searchQuery, searchQuery,
) { tags, state, query -> ) { tags, locales, state, query ->
buildFilterList(tags, state, query) buildFilterList(tags, locales, state, query)
} }
private fun getTagsAsFlow() = flow { private fun getTagsAsFlow() = flow {
val localTags = localTags.get() val localTags = localTags.get()
emit(TagsWrapper(localTags, isLoading = true, isError = false)) emit(PendingSet(localTags, isLoading = true, isError = false))
val remoteTags = tryLoadTags() val remoteTags = tryLoadTags()
if (remoteTags == null) { if (remoteTags == null) {
emit(TagsWrapper(localTags, isLoading = false, isError = true)) emit(PendingSet(localTags, isLoading = false, isError = true))
} else { } else {
emit(TagsWrapper(mergeTags(remoteTags, localTags), isLoading = false, isError = false)) emit(PendingSet(mergeTags(remoteTags, localTags), isLoading = false, isError = false))
}
}
private fun getLocalesAsFlow(): Flow<PendingSet<Locale>> = flow {
emit(PendingSet(emptySet(), isLoading = true, isError = false))
val locales = tryLoadLocales()
if (locales == null) {
emit(PendingSet(emptySet(), isLoading = false, isError = true))
} else {
emit(PendingSet(locales, isLoading = false, isError = false))
} }
} }
@ -239,13 +257,14 @@ class FilterCoordinator @Inject constructor(
@WorkerThread @WorkerThread
private fun buildFilterList( private fun buildFilterList(
allTags: TagsWrapper, allTags: PendingSet<MangaTag>,
allLocales: PendingSet<Locale>,
state: MangaListFilter.Advanced, state: MangaListFilter.Advanced,
query: String, query: String,
): List<ListModel> { ): List<ListModel> {
val sortOrders = repository.sortOrders.sortedByOrdinal() val sortOrders = repository.sortOrders.sortedByOrdinal()
val states = repository.states val states = repository.states
val tags = mergeTags(state.tags, allTags.tags).toList() val tags = mergeTags(state.tags, allTags.items).toList()
val list = ArrayList<ListModel>(tags.size + states.size + sortOrders.size + 4) val list = ArrayList<ListModel>(tags.size + states.size + sortOrders.size + 4)
val isMultiTag = repository.isMultipleTagsSupported val isMultiTag = repository.isMultipleTagsSupported
if (query.isEmpty()) { if (query.isEmpty()) {
@ -267,6 +286,19 @@ class FilterCoordinator @Inject constructor(
FilterItem.State(it, isChecked = it in state.states) FilterItem.State(it, isChecked = it in state.states)
} }
} }
if (allLocales.items.isNotEmpty()) {
list.add(
ListHeader(
textRes = R.string.language,
buttonTextRes = if (state.locale == null) 0 else R.string.reset,
payload = R.string.language,
),
)
list.add(FilterItem.Language(null, isChecked = state.locale == null))
allLocales.items.mapTo(list) {
FilterItem.Language(it, isChecked = state.locale == it)
}
}
if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) { if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) {
list.add( list.add(
ListHeader( ListHeader(
@ -309,6 +341,16 @@ class FilterCoordinator @Inject constructor(
return result return result
} }
private suspend fun tryLoadLocales(): Set<Locale>? {
val shouldRetryOnError = availableLocalesDeferred.isCompleted
val result = availableLocalesDeferred.await()
if (result == null && shouldRetryOnError) {
availableLocalesDeferred = loadLocalesAsync()
return availableLocalesDeferred.await()
}
return result
}
private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
runCatchingCancellable { runCatchingCancellable {
repository.getTags() repository.getTags()
@ -317,6 +359,14 @@ class FilterCoordinator @Inject constructor(
}.getOrNull() }.getOrNull()
} }
private fun loadLocalesAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
runCatchingCancellable {
repository.getLocales()
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
}
private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> { private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
val result = TreeSet(TagTitleComparator(repository.source.locale)) val result = TreeSet(TagTitleComparator(repository.source.locale))
result.addAll(secondary) result.addAll(secondary)
@ -324,8 +374,8 @@ class FilterCoordinator @Inject constructor(
return result return result
} }
private data class TagsWrapper( private data class PendingSet<T>(
val tags: Set<MangaTag>, val items: Set<T>,
val isLoading: Boolean, val isLoading: Boolean,
val isError: Boolean, val isError: Boolean,
) )

@ -10,4 +10,6 @@ interface OnFilterChangedListener : ListHeaderClickListener {
fun onTagItemClick(item: FilterItem.Tag, isFromChip: Boolean) fun onTagItemClick(item: FilterItem.Tag, isFromChip: Boolean)
fun onStateItemClick(item: FilterItem.State) fun onStateItemClick(item: FilterItem.State)
fun onLanguageItemClick(item: FilterItem.Language)
} }

@ -1,11 +1,15 @@
package org.koitharu.kotatsu.filter.ui.model package org.koitharu.kotatsu.filter.ui.model
import android.content.res.Resources
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
sealed interface FilterItem : ListModel { sealed interface FilterItem : ListModel {
@ -64,6 +68,28 @@ sealed interface FilterItem : ListModel {
} }
} }
data class Language(
val locale: Locale?,
val isChecked: Boolean,
) : FilterItem {
private val displayText = locale?.getDisplayLanguage(locale)?.toTitleCase(locale)
fun getTitle(resources: Resources) = displayText ?: resources.getString(R.string.various_languages)
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Language && other.locale == locale
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is Language && previousState.isChecked != isChecked) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}
data class Error( data class Error(
@StringRes val textResId: Int, @StringRes val textResId: Int,
) : FilterItem { ) : FilterItem {

@ -6,6 +6,7 @@ enum class ListItemType {
FILTER_TAG, FILTER_TAG,
FILTER_TAG_MULTI, FILTER_TAG_MULTI,
FILTER_STATE, FILTER_STATE,
FILTER_LANGUAGE,
HEADER, HEADER,
MANGA_LIST, MANGA_LIST,
MANGA_LIST_DETAILED, MANGA_LIST_DETAILED,

@ -31,6 +31,7 @@ class TypedListSpacingDecoration(
ListItemType.FILTER_TAG, ListItemType.FILTER_TAG,
ListItemType.FILTER_TAG_MULTI, ListItemType.FILTER_TAG_MULTI,
ListItemType.FILTER_STATE, ListItemType.FILTER_STATE,
ListItemType.FILTER_LANGUAGE,
-> outRect.set(0) -> outRect.set(0)
ListItemType.HEADER, ListItemType.HEADER,

@ -35,6 +35,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File import java.io.File
import java.util.EnumSet import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -160,6 +161,8 @@ class LocalMangaRepository @Inject constructor(
override suspend fun getTags() = emptySet<MangaTag>() override suspend fun getTags() = emptySet<MangaTag>()
override suspend fun getLocales() = emptySet<Locale>()
override suspend fun getRelated(seed: Manga): List<Manga> = emptyList() override suspend fun getRelated(seed: Manga): List<Manga> = emptyList()
suspend fun getOutputDir(manga: Manga): File? { suspend fun getOutputDir(manga: Manga): File? {

Loading…
Cancel
Save