diff --git a/app/build.gradle b/app/build.gradle index 684244f28..8338abc2d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 34 - versionCode = 602 - versionName = '6.4.2' + versionCode = 603 + versionName = '6.5-b1' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { @@ -82,7 +82,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:0efd5437f9') { + implementation('com.github.KotatsuApp:kotatsu-parsers:44c2c074a8') { exclude group: 'org.json', module: 'json' } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt index 8f66ff6be..4b5e3c906 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt @@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import java.lang.ref.WeakReference import java.util.EnumMap +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton import kotlin.collections.set @@ -41,6 +42,8 @@ interface MangaRepository { suspend fun getTags(): Set + suspend fun getLocales(): Set + suspend fun getRelated(seed: Manga): List @Singleton diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt index fffe8ef06..a3bdb3a5e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt @@ -32,6 +32,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.util.Locale class RemoteMangaRepository( private val parser: MangaParser, @@ -104,6 +105,10 @@ class RemoteMangaRepository( parser.getAvailableTags() } + override suspend fun getLocales(): Set { + return parser.getAvailableLocales() + } + suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { parser.getFavicons() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapter.kt index 67280aaa2..df204fabb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapter.kt @@ -2,8 +2,10 @@ package org.koitharu.kotatsu.filter.ui import android.content.Context 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.list.fastscroll.FastScroller +import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD @@ -21,6 +23,7 @@ class FilterAdapter( addDelegate(ListItemType.FILTER_TAG, filterTagDelegate(listener)) addDelegate(ListItemType.FILTER_TAG_MULTI, filterTagMultipleDelegate(listener)) addDelegate(ListItemType.FILTER_STATE, filterStateDelegate(listener)) + addDelegate(ListItemType.FILTER_LANGUAGE, filterLanguageDelegate(listener)) addDelegate(ListItemType.HEADER, listHeaderAD(listener)) addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) @@ -31,10 +34,14 @@ class FilterAdapter( override fun getSectionText(context: Context, position: Int): CharSequence? { val list = items for (i in (0..position).reversed()) { - val item = list.getOrNull(i) ?: continue - if (item is FilterItem.Tag) { - return item.tag.title.firstOrNull()?.toString() - } + val item = list.getOrNull(i) as? FilterItem ?: continue + when (item) { + 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 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapterDelegates.kt index b205a336a..11d423d4c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapterDelegates.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapterDelegates.kt @@ -44,6 +44,23 @@ fun filterStateDelegate( } } + +fun filterLanguageDelegate( + listener: OnFilterChangedListener, +) = adapterDelegateViewBinding( + { 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( listener: OnFilterChangedListener, ) = adapterDelegateViewBinding( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index 784cc4330..271d5a80f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -62,6 +62,7 @@ class FilterCoordinator @Inject constructor( dataRepository.findTags(repository.source) } private var availableTagsDeferred = loadTagsAsync() + private var availableLocalesDeferred = loadLocalesAsync() override val filterItems: StateFlow> = getItemsFlow() .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) { currentState.update { oldValue -> oldValue.copy( sortOrder = oldValue.sortOrder, 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, ) } @@ -173,20 +180,31 @@ class FilterCoordinator @Inject constructor( private fun getItemsFlow() = combine( getTagsAsFlow(), + getLocalesAsFlow(), currentState, searchQuery, - ) { tags, state, query -> - buildFilterList(tags, state, query) + ) { tags, locales, state, query -> + buildFilterList(tags, locales, state, query) } private fun getTagsAsFlow() = flow { val localTags = localTags.get() - emit(TagsWrapper(localTags, isLoading = true, isError = false)) + emit(PendingSet(localTags, isLoading = true, isError = false)) val remoteTags = tryLoadTags() if (remoteTags == null) { - emit(TagsWrapper(localTags, isLoading = false, isError = true)) + emit(PendingSet(localTags, isLoading = false, isError = true)) } else { - emit(TagsWrapper(mergeTags(remoteTags, localTags), isLoading = false, isError = false)) + emit(PendingSet(mergeTags(remoteTags, localTags), isLoading = false, isError = false)) + } + } + + private fun getLocalesAsFlow(): Flow> = 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 private fun buildFilterList( - allTags: TagsWrapper, + allTags: PendingSet, + allLocales: PendingSet, state: MangaListFilter.Advanced, query: String, ): List { val sortOrders = repository.sortOrders.sortedByOrdinal() val states = repository.states - val tags = mergeTags(state.tags, allTags.tags).toList() + val tags = mergeTags(state.tags, allTags.items).toList() val list = ArrayList(tags.size + states.size + sortOrders.size + 4) val isMultiTag = repository.isMultipleTagsSupported if (query.isEmpty()) { @@ -267,6 +286,19 @@ class FilterCoordinator @Inject constructor( 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()) { list.add( ListHeader( @@ -309,6 +341,16 @@ class FilterCoordinator @Inject constructor( return result } + private suspend fun tryLoadLocales(): Set? { + 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) { runCatchingCancellable { repository.getTags() @@ -317,6 +359,14 @@ class FilterCoordinator @Inject constructor( }.getOrNull() } + private fun loadLocalesAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { + runCatchingCancellable { + repository.getLocales() + }.onFailure { error -> + error.printStackTraceDebug() + }.getOrNull() + } + private fun mergeTags(primary: Set, secondary: Set): Set { val result = TreeSet(TagTitleComparator(repository.source.locale)) result.addAll(secondary) @@ -324,8 +374,8 @@ class FilterCoordinator @Inject constructor( return result } - private data class TagsWrapper( - val tags: Set, + private data class PendingSet( + val items: Set, val isLoading: Boolean, val isError: Boolean, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt index 0b8116512..3581dbf2b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt @@ -10,4 +10,6 @@ interface OnFilterChangedListener : ListHeaderClickListener { fun onTagItemClick(item: FilterItem.Tag, isFromChip: Boolean) fun onStateItemClick(item: FilterItem.State) + + fun onLanguageItemClick(item: FilterItem.Language) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterItem.kt index 536f6f6dc..799475401 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterItem.kt @@ -1,11 +1,15 @@ package org.koitharu.kotatsu.filter.ui.model +import android.content.res.Resources import androidx.annotation.StringRes +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.toTitleCase +import java.util.Locale 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( @StringRes val textResId: Int, ) : FilterItem { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt index af92052b0..9603e4304 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt @@ -6,6 +6,7 @@ enum class ListItemType { FILTER_TAG, FILTER_TAG_MULTI, FILTER_STATE, + FILTER_LANGUAGE, HEADER, MANGA_LIST, MANGA_LIST_DETAILED, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt index 3251cd0f8..1ec2dacd8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt @@ -31,6 +31,7 @@ class TypedListSpacingDecoration( ListItemType.FILTER_TAG, ListItemType.FILTER_TAG_MULTI, ListItemType.FILTER_STATE, + ListItemType.FILTER_LANGUAGE, -> outRect.set(0) ListItemType.HEADER, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index ad427fb09..6a3b17028 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -35,6 +35,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import java.util.EnumSet +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -160,6 +161,8 @@ class LocalMangaRepository @Inject constructor( override suspend fun getTags() = emptySet() + override suspend fun getLocales() = emptySet() + override suspend fun getRelated(seed: Manga): List = emptyList() suspend fun getOutputDir(manga: Manga): File? {