From 6c07abec561747f03c1358a029207337416a8cfb Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 5 Dec 2023 17:14:24 +0200 Subject: [PATCH] New filter sheet draft implementation --- .../org/koitharu/kotatsu/core/model/Manga.kt | 10 + .../kotatsu/core/ui/widgets/ChipsView.kt | 8 + .../kotatsu/core/util/ext/Throwable.kt | 14 +- .../filter/ui/FilterAdapterDelegates.kt | 10 +- .../kotatsu/filter/ui/FilterCoordinator.kt | 263 ++++++++---------- .../kotatsu/filter/ui/FilterHeaderFragment.kt | 5 +- .../kotatsu/filter/ui/FilterSheetFragment.kt | 59 ---- .../koitharu/kotatsu/filter/ui/MangaFilter.kt | 13 +- .../filter/ui/OnFilterChangedListener.kt | 13 +- .../kotatsu/filter/ui/model/FilterProperty.kt | 11 + .../filter/ui/sheet/FilterSheetFragment.kt | 180 ++++++++++++ .../list/ui/preview/PreviewFragment.kt | 4 +- .../kotatsu/local/ui/LocalListFragment.kt | 6 +- .../remotelist/ui/RemoteListFragment.kt | 2 +- .../kotatsu/search/ui/MangaListActivity.kt | 2 +- app/src/main/res/layout/sheet_filter2.xml | 143 ++++++++++ app/src/main/res/layout/sheet_list_mode.xml | 1 + app/src/main/res/values/strings.xml | 2 + 18 files changed, 512 insertions(+), 234 deletions(-) delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt create mode 100644 app/src/main/res/layout/sheet_filter2.xml diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt index eea9ae360..5818fcf28 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.core.model import android.net.Uri +import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.os.LocaleListCompat import org.koitharu.kotatsu.R @@ -43,6 +44,15 @@ val MangaState.titleResId: Int MangaState.PAUSED -> R.string.state_paused } +@get:DrawableRes +val MangaState.iconResId: Int + get() = when (this) { + MangaState.ONGOING -> R.drawable.ic_state_ongoing + MangaState.FINISHED -> R.drawable.ic_state_finished + MangaState.ABANDONED -> R.drawable.ic_state_abandoned + MangaState.PAUSED -> R.drawable.ic_action_pause + } + fun Manga.findChapter(id: Long): MangaChapter? { return chapters?.findById(id) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt index c2689a1ed..884e802e4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt @@ -54,6 +54,14 @@ class ChipsView @JvmOverloads constructor( defaultChipTextColor = a.getColorStateListOrThrow(materialR.styleable.Chip_android_textColor) defaultChipIconTint = a.getColorStateListOrThrow(materialR.styleable.Chip_chipIconTint) a.recycle() + + if (isInEditMode) { + setChips( + List(5) { + ChipModel(0, "Chip $it", 0, false, false) + }, + ) + } } override fun requestLayout() { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt index c8d2a55ad..21e6e838e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt @@ -19,6 +19,11 @@ import org.koitharu.kotatsu.core.exceptions.SyncApiException import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException +import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED +import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED +import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED +import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_STATES_NOT_SUPPORTED +import org.koitharu.kotatsu.parsers.ErrorMessages.SEARCH_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException import org.koitharu.kotatsu.parsers.exception.NotFoundException @@ -28,9 +33,6 @@ import java.net.UnknownHostException private const val MSG_NO_SPACE_LEFT = "No space left on device" private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported" -private const val MULTIPLE_GENRES_NOT_SUPPORTED = "Multiple genres are not supported by this source" -private const val MULTIPLE_STATES_NOT_SUPPORTED = "Multiple states are not supported by this source" -private const val SEARCH_NOT_SUPPORTED = "Search is not supported by this source" fun Throwable.getDisplayMessage(resources: Resources): String = when (this) { is AuthRequiredException -> resources.getString(R.string.auth_required) @@ -85,9 +87,11 @@ private fun getDisplayMessage(msg: String?, resources: Resources): String? = whe msg.isNullOrEmpty() -> null msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left) msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file) - msg == MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported) - msg == MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported) + msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported) + msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported) msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported) + msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported) + msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported) else -> 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 11d423d4c..645c143fb 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 @@ -19,7 +19,7 @@ fun filterSortDelegate( ) { itemView.setOnClickListener { - listener.onSortItemClick(item) + listener.setSortOrder(item.order) } bind { payloads -> @@ -35,7 +35,7 @@ fun filterStateDelegate( ) { itemView.setOnClickListener { - listener.onStateItemClick(item) + listener.setState(item.state, !item.isChecked) } bind { payloads -> @@ -52,7 +52,7 @@ fun filterLanguageDelegate( ) { itemView.setOnClickListener { - listener.onLanguageItemClick(item) + listener.setLanguage(item.locale) } bind { payloads -> @@ -69,7 +69,7 @@ fun filterTagDelegate( ) { itemView.setOnClickListener { - listener.onTagItemClick(item, isFromChip = false) + listener.setTag(item.tag, !item.isChecked) } bind { payloads -> @@ -86,7 +86,7 @@ fun filterTagMultipleDelegate( ) { itemView.setOnClickListener { - listener.onTagItemClick(item, isFromChip = false) + listener.setTag(item.tag, !item.isChecked) } bind { payloads -> 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 271d5a80f..fe65a3bb5 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 @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.filter.ui import android.view.View -import androidx.annotation.WorkerThread import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.ViewModelLifecycle import dagger.hilt.android.scopes.ViewModelScoped @@ -14,7 +13,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.plus @@ -22,18 +23,17 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.core.util.LocaleComparator import org.koitharu.kotatsu.core.util.ext.lifecycleScope import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.require -import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel -import org.koitharu.kotatsu.filter.ui.model.FilterItem +import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.list.ui.model.ListHeader -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.MangaListFilter +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.SuspendLazy import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment @@ -55,17 +55,75 @@ class FilterCoordinator @Inject constructor( private val coroutineScope = lifecycle.lifecycleScope private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE)) - private val currentState = - MutableStateFlow(MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet())) - private var searchQuery = MutableStateFlow("") + private val currentState = MutableStateFlow( + MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet()), + ) private val localTags = SuspendLazy { 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)) + override val filterTags: StateFlow> = combine( + currentState.distinctUntilChangedBy { it.tags }, + getTagsAsFlow(), + ) { state, tags -> + FilterProperty( + availableItems = tags.items.sortedBy { it.title }, + selectedItems = state.tags, + isLoading = tags.isLoading, + error = tags.error, + ) + }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + + override val filterSortOrder: StateFlow> = combine( + currentState.distinctUntilChangedBy { it.sortOrder }, + flowOf(repository.sortOrders), + ) { state, orders -> + FilterProperty( + availableItems = orders.sortedBy { it.ordinal }, + selectedItems = setOf(state.sortOrder), + isLoading = false, + error = null, + ) + }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + + override val filterState: StateFlow> = combine( + currentState.distinctUntilChangedBy { it.states }, + flowOf(repository.states), + ) { state, states -> + FilterProperty( + availableItems = states.sortedBy { it.ordinal }, + selectedItems = state.states, + isLoading = false, + error = null, + ) + }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) + + override val filterLocale: StateFlow> = combine( + currentState.distinctUntilChangedBy { it.locale }, + getLocalesAsFlow(), + ) { state, locales -> + val list = if (locales.items.isNotEmpty()) { + val l = ArrayList(locales.items.size + 1) + l.add(null) + l.addAll(locales.items) + try { + l.sortWith(nullsFirst(LocaleComparator())) + } catch (e: IllegalArgumentException) { + e.printStackTraceDebug() + } + l + } else { + emptyList() + } + FilterProperty( + availableItems = list, + selectedItems = setOf(state.locale), + isLoading = locales.isLoading, + error = locales.error, + ) + }.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty()) override val header: StateFlow = getHeaderFlow().stateIn( scope = coroutineScope + Dispatchers.Default, @@ -78,55 +136,53 @@ class FilterCoordinator @Inject constructor( ), ) - init { - observeState() - } - override fun applyFilter(tags: Set) { setTags(tags) } - override fun onSortItemClick(item: FilterItem.Sort) { + override fun setSortOrder(value: SortOrder) { currentState.update { oldValue -> - oldValue.copy(sortOrder = item.order) + oldValue.copy(sortOrder = value) } - repository.defaultSortOrder = item.order + repository.defaultSortOrder = value } - override fun onTagItemClick(item: FilterItem.Tag, isFromChip: Boolean) { + override fun setLanguage(value: Locale?) { currentState.update { oldValue -> - val newTags = if (!item.isMultiple) { - if (isFromChip && item.isChecked) { - emptySet() + oldValue.copy(locale = value) + } + } + + override fun setTag(value: MangaTag, addOrRemove: Boolean) { + currentState.update { oldValue -> + val newTags = if (repository.isMultipleTagsSupported) { + if (addOrRemove) { + oldValue.tags + value } else { - setOf(item.tag) + oldValue.tags - value } - } else if (item.isChecked) { - oldValue.tags - item.tag } else { - oldValue.tags + item.tag + if (addOrRemove) { + setOf(value) + } else { + emptySet() + } } oldValue.copy(tags = newTags) } } - override fun onStateItemClick(item: FilterItem.State) { + override fun setState(value: MangaState, addOrRemove: Boolean) { currentState.update { oldValue -> - val newStates = if (item.isChecked) { - oldValue.states - item.state + val newStates = if (addOrRemove) { + oldValue.states + value } else { - oldValue.states + item.state + oldValue.states - value } oldValue.copy(states = newStates) } } - 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( @@ -142,7 +198,7 @@ class FilterCoordinator @Inject constructor( if (!availableTagsDeferred.isCompleted) { emit(emptySet()) } - emit(availableTagsDeferred.await()) + emit(availableTagsDeferred.await().getOrNull()) } fun observeState() = currentState.asStateFlow() @@ -161,10 +217,6 @@ class FilterCoordinator @Inject constructor( fun snapshot() = currentState.value - fun performSearch(query: String) { - searchQuery.value = query - } - private fun getHeaderFlow() = combine( observeState(), observeAvailableTags(), @@ -178,34 +230,25 @@ class FilterCoordinator @Inject constructor( ) } - private fun getItemsFlow() = combine( - getTagsAsFlow(), - getLocalesAsFlow(), - currentState, - searchQuery, - ) { tags, locales, state, query -> - buildFilterList(tags, locales, state, query) - } - private fun getTagsAsFlow() = flow { val localTags = localTags.get() - emit(PendingSet(localTags, isLoading = true, isError = false)) - val remoteTags = tryLoadTags() - if (remoteTags == null) { - emit(PendingSet(localTags, isLoading = false, isError = true)) - } else { - emit(PendingSet(mergeTags(remoteTags, localTags), isLoading = false, isError = false)) - } + emit(PendingSet(localTags, isLoading = true, error = null)) + tryLoadTags() + .onSuccess { remoteTags -> + emit(PendingSet(mergeTags(remoteTags, localTags), isLoading = false, error = null)) + }.onFailure { + emit(PendingSet(localTags, isLoading = false, error = it)) + } } 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)) - } + emit(PendingSet(emptySet(), isLoading = true, error = null)) + tryLoadLocales() + .onSuccess { locales -> + emit(PendingSet(locales, isLoading = false, error = null)) + }.onFailure { + emit(PendingSet(emptySet(), isLoading = false, error = it)) + } } private suspend fun createChipsList( @@ -255,96 +298,20 @@ class FilterCoordinator @Inject constructor( return result } - @WorkerThread - private fun buildFilterList( - 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.items).toList() - val list = ArrayList(tags.size + states.size + sortOrders.size + 4) - val isMultiTag = repository.isMultipleTagsSupported - if (query.isEmpty()) { - if (sortOrders.isNotEmpty()) { - list.add(ListHeader(R.string.sort_order)) - sortOrders.mapTo(list) { - FilterItem.Sort(it, isSelected = it == state.sortOrder) - } - } - if (states.isNotEmpty()) { - list.add( - ListHeader( - textRes = R.string.state, - buttonTextRes = if (state.states.isEmpty()) 0 else R.string.reset, - payload = R.string.state, - ), - ) - states.mapTo(list) { - 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( - textRes = R.string.genres, - buttonTextRes = if (state.tags.isEmpty()) 0 else R.string.reset, - payload = R.string.genres, - ), - ) - tags.mapTo(list) { - FilterItem.Tag(it, isMultiple = isMultiTag, isChecked = it in state.tags) - } - } - if (allTags.isError) { - list.add(FilterItem.Error(R.string.filter_load_error)) - } else if (allTags.isLoading) { - list.add(LoadingFooter()) - } - } else { - tags.mapNotNullTo(list) { - if (it.title.contains(query, ignoreCase = true)) { - FilterItem.Tag(it, isMultiple = isMultiTag, isChecked = it in state.tags) - } else { - null - } - } - if (list.isEmpty()) { - list.add(FilterItem.Error(R.string.nothing_found)) - } - } - return list - } - - private suspend fun tryLoadTags(): Set? { + private suspend fun tryLoadTags(): Result> { val shouldRetryOnError = availableTagsDeferred.isCompleted val result = availableTagsDeferred.await() - if (result == null && shouldRetryOnError) { + if (result.isFailure && shouldRetryOnError) { availableTagsDeferred = loadTagsAsync() return availableTagsDeferred.await() } return result } - private suspend fun tryLoadLocales(): Set? { + private suspend fun tryLoadLocales(): Result> { val shouldRetryOnError = availableLocalesDeferred.isCompleted val result = availableLocalesDeferred.await() - if (result == null && shouldRetryOnError) { + if (result.isFailure && shouldRetryOnError) { availableLocalesDeferred = loadLocalesAsync() return availableLocalesDeferred.await() } @@ -356,7 +323,7 @@ class FilterCoordinator @Inject constructor( repository.getTags() }.onFailure { error -> error.printStackTraceDebug() - }.getOrNull() + } } private fun loadLocalesAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { @@ -364,7 +331,7 @@ class FilterCoordinator @Inject constructor( repository.getLocales() }.onFailure { error -> error.printStackTraceDebug() - }.getOrNull() + } } private fun mergeTags(primary: Set, secondary: Set): Set { @@ -377,9 +344,11 @@ class FilterCoordinator @Inject constructor( private data class PendingSet( val items: Set, val isLoading: Boolean, - val isError: Boolean, + val error: Throwable?, ) + private fun loadingProperty() = FilterProperty(emptyList(), emptySet(), true, null) + private class TagTitleComparator(lc: String?) : Comparator { private val collator = lc?.let { Collator.getInstance(Locale(it)) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt index f622effe6..c64f08188 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt @@ -13,7 +13,7 @@ import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel -import org.koitharu.kotatsu.filter.ui.model.FilterItem +import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.parsers.model.MangaTag import com.google.android.material.R as materialR @@ -39,8 +39,7 @@ class FilterHeaderFragment : BaseFragment(), ChipsV if (tag == null) { FilterSheetFragment.show(parentFragmentManager) } else { - val filterItem = FilterItem.Tag(tag, filter.header.value.allowMultipleTags, !chip.isChecked) - filter.onTagItemClick(filterItem, isFromChip = true) + filter.setTag(tag, chip.isChecked) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt deleted file mode 100644 index bb668af73..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt +++ /dev/null @@ -1,59 +0,0 @@ -package org.koitharu.kotatsu.filter.ui - -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.FragmentManager -import androidx.recyclerview.widget.AsyncListDiffer -import androidx.recyclerview.widget.LinearLayoutManager -import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior -import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback -import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.showDistinct -import org.koitharu.kotatsu.databinding.SheetFilterBinding -import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration -import org.koitharu.kotatsu.list.ui.model.ListModel - -class FilterSheetFragment : - BaseAdaptiveSheet(), - AdaptiveSheetCallback, - AsyncListDiffer.ListListener { - - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { - return SheetFilterBinding.inflate(inflater, container, false) - } - - override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - val filter = (requireActivity() as FilterOwner).filter - addSheetCallback(this) - val adapter = FilterAdapter(filter, this) - binding.recyclerView.adapter = adapter - filter.filterItems.observe(viewLifecycleOwner, adapter) - binding.recyclerView.addItemDecoration(TypedListSpacingDecoration(binding.root.context, true)) - - if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - binding.recyclerView.scrollIndicators = 0 - } - } - - override fun onCurrentListChanged(previousList: MutableList, currentList: MutableList) { - if (currentList.size > previousList.size && view != null) { - (requireViewBinding().recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0) - } - } - - override fun onStateChanged(sheet: View, newState: Int) { - viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED - } - - companion object { - - private const val TAG = "FilterBottomSheet" - - fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt index e3aa46686..d22c8d7bc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt @@ -2,12 +2,21 @@ package org.koitharu.kotatsu.filter.ui import kotlinx.coroutines.flow.StateFlow import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel -import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.filter.ui.model.FilterProperty +import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import java.util.Locale interface MangaFilter : OnFilterChangedListener { - val filterItems: StateFlow> + val filterTags: StateFlow> + + val filterSortOrder: StateFlow> + + val filterState: StateFlow> + + val filterLocale: StateFlow> val header: StateFlow 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 3581dbf2b..136c60f05 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 @@ -1,15 +1,18 @@ package org.koitharu.kotatsu.filter.ui -import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener +import org.koitharu.kotatsu.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import java.util.Locale interface OnFilterChangedListener : ListHeaderClickListener { - fun onSortItemClick(item: FilterItem.Sort) + fun setSortOrder(value: SortOrder) - fun onTagItemClick(item: FilterItem.Tag, isFromChip: Boolean) + fun setLanguage(value: Locale?) - fun onStateItemClick(item: FilterItem.State) + fun setTag(value: MangaTag, addOrRemove: Boolean) - fun onLanguageItemClick(item: FilterItem.Language) + fun setState(value: MangaState, addOrRemove: Boolean) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt new file mode 100644 index 000000000..a05157a3d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.filter.ui.model + +data class FilterProperty( + val availableItems: List, + val selectedItems: Set, + val isLoading: Boolean, + val error: Throwable?, +) { + + fun isEmpty(): Boolean = availableItems.isEmpty() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt new file mode 100644 index 000000000..0bda210ae --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt @@ -0,0 +1,180 @@ +package org.koitharu.kotatsu.filter.ui.sheet + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.core.view.isGone +import androidx.fragment.app.FragmentManager +import com.google.android.material.chip.Chip +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.titleResId +import org.koitharu.kotatsu.core.ui.model.titleRes +import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet +import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.showDistinct +import org.koitharu.kotatsu.databinding.SheetFilter2Binding +import org.koitharu.kotatsu.filter.ui.FilterOwner +import org.koitharu.kotatsu.filter.ui.model.FilterProperty +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 +import com.google.android.material.R as materialR + +class FilterSheetFragment : + BaseAdaptiveSheet(), AdapterView.OnItemSelectedListener, ChipsView.OnChipClickListener, + ChipsView.OnChipCloseClickListener { + + override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilter2Binding { + return SheetFilter2Binding.inflate(inflater, container, false) + } + + override fun onViewBindingCreated(binding: SheetFilter2Binding, savedInstanceState: Bundle?) { + super.onViewBindingCreated(binding, savedInstanceState) + if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + binding.scrollView.scrollIndicators = 0 + } + val filter = requireFilter() + filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) + filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged) + filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged) + filter.filterState.observe(viewLifecycleOwner, this::onStateChanged) + + binding.spinnerLocale.onItemSelectedListener = this + binding.spinnerOrder.onItemSelectedListener = this + binding.chipsState.onChipClickListener = this + binding.chipsGenres.onChipClickListener = this + binding.chipsGenres.onChipCloseClickListener = this + } + + override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { + val filter = requireFilter() + when (parent.id) { + R.id.spinner_order -> filter.setSortOrder(filter.filterSortOrder.value.availableItems[position]) + R.id.spinner_locale -> filter.setLanguage(filter.filterLocale.value.availableItems[position]) + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + + override fun onChipClick(chip: Chip, data: Any?) { + val filter = requireFilter() + when (data) { + is MangaState -> filter.setState(data, chip.isChecked) + } + } + + override fun onChipCloseClick(chip: Chip, data: Any?) { + val tag = data as? MangaTag ?: return + requireFilter().setTag(tag, false) + } + + private fun onSortOrderChanged(value: FilterProperty) { + val b = viewBinding ?: return + b.textViewOrderTitle.isGone = value.isEmpty() + b.cardOrder.isGone = value.isEmpty() + if (value.isEmpty()) { + return + } + val selected = value.selectedItems.single() + b.spinnerOrder.adapter = ArrayAdapter( + b.spinnerOrder.context, + android.R.layout.simple_spinner_dropdown_item, + android.R.id.text1, + value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) }, + ) + val selectedIndex = value.availableItems.indexOf(selected) + if (selectedIndex >= 0) { + b.spinnerOrder.setSelection(selectedIndex, false) + } + } + + private fun onLocaleChanged(value: FilterProperty) { + val b = viewBinding ?: return + b.textViewLocaleTitle.isGone = value.isEmpty() + b.cardLocale.isGone = value.isEmpty() + if (value.isEmpty()) { + return + } + val selected = value.selectedItems.singleOrNull() + b.spinnerLocale.adapter = ArrayAdapter( + b.spinnerLocale.context, + android.R.layout.simple_spinner_dropdown_item, + android.R.id.text1, + value.availableItems.map { + it?.getDisplayLanguage(it)?.toTitleCase(it) + ?: b.spinnerLocale.context.getString(R.string.various_languages) + }, + ) + val selectedIndex = value.availableItems.indexOf(selected) + if (selectedIndex >= 0) { + b.spinnerLocale.setSelection(selectedIndex, false) + } + } + + private fun onTagsChanged(value: FilterProperty) { + val b = viewBinding ?: return + b.textViewGenresTitle.isGone = value.isEmpty() + b.chipsGenres.isGone = value.isEmpty() + if (value.isEmpty()) { + return + } + val chips = ArrayList(value.selectedItems.size + 1) + value.selectedItems.mapTo(chips) { tag -> + ChipsView.ChipModel( + tint = 0, + title = tag.title, + icon = 0, + isCheckable = false, + isChecked = false, + data = tag, + ) + } + chips.add( + ChipsView.ChipModel( + tint = 0, + title = getString(R.string.more), + icon = materialR.drawable.abc_ic_menu_overflow_material, + isCheckable = false, + isChecked = false, + data = null, + ), + ) + b.chipsGenres.setChips(chips) + } + + private fun onStateChanged(value: FilterProperty) { + val b = viewBinding ?: return + b.textViewStateTitle.isGone = value.isEmpty() + b.chipsState.isGone = value.isEmpty() + if (value.isEmpty()) { + return + } + val chips = value.availableItems.map { state -> + ChipsView.ChipModel( + tint = 0, + title = getString(state.titleResId), + icon = 0, + isCheckable = true, + isChecked = state in value.selectedItems, + data = state, + ) + } + b.chipsState.setChips(chips) + } + + private fun requireFilter() = (requireActivity() as FilterOwner).filter + + companion object { + + private const val TAG = "FilterSheet" + + fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt index 4982ac1cf..c3eb9f94f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt @@ -28,7 +28,6 @@ import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.FragmentPreviewBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.filter.ui.FilterOwner -import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag @@ -98,8 +97,7 @@ class PreviewFragment : BaseFragment(), View.OnClickList if (filter == null) { startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag))) } else { - val filterItem = FilterItem.Tag(tag, filter.header.value.allowMultipleTags, false) - filter.onTagItemClick(filterItem, isFromChip = false) + filter.setTag(tag, true) closeSelf() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index 44f799d45..d3f27893f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -18,8 +18,8 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.filter.ui.FilterOwner -import org.koitharu.kotatsu.filter.ui.FilterSheetFragment import org.koitharu.kotatsu.filter.ui.MangaFilter +import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment @@ -94,7 +94,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner { Snackbar.make( requireViewBinding().recyclerView, R.string.removal_completed, - Snackbar.LENGTH_SHORT + Snackbar.LENGTH_SHORT, ).show() } @@ -103,7 +103,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner { fun newInstance() = LocalListFragment().withArgs(1) { putSerializable( RemoteListFragment.ARG_SOURCE, - MangaSource.LOCAL + MangaSource.LOCAL, ) // required by FilterCoordinator } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 008dadcb2..90a7bb5aa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -21,8 +21,8 @@ import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.filter.ui.FilterOwner -import org.koitharu.kotatsu.filter.ui.FilterSheetFragment import org.koitharu.kotatsu.filter.ui.MangaFilter +import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.MangaSource diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt index 9c7511751..21ebed9f4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt @@ -32,8 +32,8 @@ import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.ActivityMangaListBinding import org.koitharu.kotatsu.filter.ui.FilterHeaderFragment import org.koitharu.kotatsu.filter.ui.FilterOwner -import org.koitharu.kotatsu.filter.ui.FilterSheetFragment import org.koitharu.kotatsu.filter.ui.MangaFilter +import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.preview.PreviewFragment import org.koitharu.kotatsu.local.ui.LocalListFragment import org.koitharu.kotatsu.main.ui.owners.AppBarOwner diff --git a/app/src/main/res/layout/sheet_filter2.xml b/app/src/main/res/layout/sheet_filter2.xml new file mode 100644 index 000000000..77200c6f6 --- /dev/null +++ b/app/src/main/res/layout/sheet_filter2.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/sheet_list_mode.xml b/app/src/main/res/layout/sheet_list_mode.xml index 1c831c94f..4ff390586 100644 --- a/app/src/main/res/layout/sheet_list_mode.xml +++ b/app/src/main/res/layout/sheet_list_mode.xml @@ -119,6 +119,7 @@ android:layout_marginHorizontal="16dp" android:layout_marginTop="@dimen/margin_normal" android:visibility="gone" + app:cardBackgroundColor="?m3ColorBackground" app:shapeAppearance="?shapeAppearanceCornerMedium" app:strokeColor="@color/m3_button_outline_color_selector" app:strokeWidth="@dimen/m3_comp_outlined_button_outline_width" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8099a38d1..0bcf03e6f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -540,4 +540,6 @@ This manga These settings can be applied globally or only to the current manga. If applied globally, individual settings will not be overridden. Apply + Filtering by both genres and locale is not supported by this source + Filtering by both genres and states is not supported by this source