Filter for Local storage tab on main screen

This commit is contained in:
Koitharu
2025-08-04 09:57:07 +03:00
parent 05739bb5b3
commit 6d3f8cbb3b
10 changed files with 116 additions and 44 deletions

View File

@@ -575,7 +575,7 @@ class AppRouter private constructor(
/** Public utils **/
fun isFilterSupported(): Boolean = when {
fragment != null -> fragment.activity is FilterCoordinator.Owner
fragment != null -> FilterCoordinator.find(fragment) != null
activity != null -> activity is FilterCoordinator.Owner
else -> false
}
@@ -812,6 +812,7 @@ class AppRouter private constructor(
const val KEY_FILTER = "filter"
const val KEY_ID = "id"
const val KEY_INDEX = "index"
const val KEY_IS_BOTTOMTAB = "is_btab"
const val KEY_KIND = "kind"
const val KEY_LIST_SECTION = "list_section"
const val KEY_MANGA = "manga"

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.filter.ui
import androidx.fragment.app.Fragment
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped
@@ -489,9 +490,27 @@ class FilterCoordinator @Inject constructor(
val filterCoordinator: FilterCoordinator
}
private companion object {
companion object {
const val TAGS_LIMIT = 12
val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
}
private const val TAGS_LIMIT = 12
private val MAX_YEAR = Calendar.getInstance()[Calendar.YEAR] + 1
fun find(fragment: Fragment): FilterCoordinator? {
(fragment.activity as? Owner)?.let {
return it.filterCoordinator
}
var f = fragment
while (true) {
(f as? Owner)?.let {
return it.filterCoordinator
}
f = f.parentFragment ?: break
}
return null
}
fun require(fragment: Fragment): FilterCoordinator {
return find(fragment) ?: throw IllegalStateException("FilterCoordinator cannot be found")
}
}
}

View File

@@ -55,7 +55,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
binding.scrollView.scrollIndicators = 0
}
}
val filter = requireFilter()
val filter = FilterCoordinator.require(this)
filter.sortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
filter.locale.observe(viewLifecycleOwner, this::onLocaleChanged)
filter.originalLocale.observe(viewLifecycleOwner, this::onOriginalLocaleChanged)
@@ -103,7 +103,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
}
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
val filter = requireFilter()
val filter = FilterCoordinator.require(this)
when (parent.id) {
R.id.spinner_order -> filter.setSortOrder(filter.sortOrder.value.availableItems[position])
R.id.spinner_locale -> filter.setLocale(filter.locale.value.availableItems[position])
@@ -118,7 +118,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
return
}
val intValue = value.toInt()
val filter = requireFilter()
val filter = FilterCoordinator.require(this)
when (slider.id) {
R.id.slider_year -> filter.setYear(
if (intValue <= slider.valueFrom.toIntUp()) {
@@ -134,7 +134,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
if (!fromUser) {
return
}
val filter = requireFilter()
val filter = FilterCoordinator.require(this)
when (slider.id) {
R.id.slider_yearsRange -> filter.setYearRange(
valueFrom = slider.values.firstOrNull()?.let {
@@ -148,7 +148,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
}
override fun onChipClick(chip: Chip, data: Any?) {
val filter = requireFilter()
val filter = FilterCoordinator.require(this)
when (data) {
is MangaState -> filter.toggleState(data, !chip.isChecked)
is MangaTag -> if (chip.parentView?.id == R.id.chips_genresExclude) {
@@ -356,6 +356,4 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
)
b.sliderYearsRange.setValuesRounded(currentValueFrom, currentValueTo)
}
private fun requireFilter() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
}

View File

@@ -36,7 +36,7 @@ class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(),
extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory ->
factory.create(
filter = (requireActivity() as FilterCoordinator.Owner).filterCoordinator,
filter = FilterCoordinator.require(this),
isExcludeTag = requireArguments().getBoolean(AppRouter.KEY_EXCLUDE),
)
}

View File

@@ -189,7 +189,7 @@ class CoverImageView @JvmOverloads constructor(
override fun onError(request: ImageRequest, result: ErrorResult) {
super.onError(request, result)
foreground = if (result.throwable.isNetworkError()) {
foreground = if (result.throwable.isNetworkError() && !networkState.isOnline()) {
ContextCompat.getDrawable(context, R.drawable.ic_offline)?.let {
LayerDrawable(arrayOf(it)).apply {
setLayerGravity(0, Gravity.CENTER)

View File

@@ -74,7 +74,7 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return
val filter = (activity as? FilterCoordinator.Owner)?.filterCoordinator
val filter = FilterCoordinator.find(this)
if (filter == null) {
router.openList(tag)
} else {

View File

@@ -23,7 +23,6 @@ import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.list.ui.MangaListFragment
@@ -45,13 +44,14 @@ class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner {
}
}
init {
withArgs(1) {
putString(
RemoteListFragment.ARG_SOURCE,
LocalMangaSource.name,
) // required by FilterCoordinator
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = arguments ?: Bundle(1)
args.putString(
RemoteListFragment.ARG_SOURCE,
LocalMangaSource.name,
) // required by FilterCoordinator
arguments = args
}
override val viewModel by viewModels<LocalListViewModel>()

View File

@@ -6,10 +6,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharedFlow
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.toChipModel
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
@@ -17,10 +20,13 @@ import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.domain.QuickFilterListener
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.model.QuickFilter
import org.koitharu.kotatsu.list.ui.model.TipModel
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager
@@ -52,9 +58,10 @@ class LocalListViewModel @Inject constructor(
exploreRepository = exploreRepository,
sourcesRepository = sourcesRepository,
mangaDataRepository = mangaDataRepository,
), SharedPreferences.OnSharedPreferenceChangeListener {
), SharedPreferences.OnSharedPreferenceChangeListener, QuickFilterListener {
val onMangaRemoved = MutableEventFlow<Unit>()
private val showInlineFilter: Boolean = savedStateHandle[AppRouter.KEY_IS_BOTTOMTAB] ?: false
init {
launchJob(Dispatchers.Default) {
@@ -68,29 +75,49 @@ class LocalListViewModel @Inject constructor(
override suspend fun onBuildList(list: MutableList<ListModel>) {
super.onBuildList(list)
if (localStorageManager.hasExternalStoragePermission(isReadOnly = true)) {
return
}
for (item in list) {
if (item !is MangaListModel) {
continue
if (showInlineFilter) {
createFilterHeader(maxCount = 16)?.let {
list.add(0, it)
}
val file = item.manga.url.toUriOrNull()?.toFileOrNull() ?: continue
if (localStorageManager.isOnExternalStorage(file)) {
val tip = TipModel(
key = "permission",
title = R.string.external_storage,
text = R.string.missing_storage_permission,
icon = R.drawable.ic_storage,
primaryButtonText = R.string.fix,
secondaryButtonText = R.string.settings,
)
list.add(0, tip)
return
}
if (!localStorageManager.hasExternalStoragePermission(isReadOnly = true)) {
for (item in list) {
if (item !is MangaListModel) {
continue
}
val file = item.manga.url.toUriOrNull()?.toFileOrNull() ?: continue
if (localStorageManager.isOnExternalStorage(file)) {
val tip = TipModel(
key = "permission",
title = R.string.external_storage,
text = R.string.missing_storage_permission,
icon = R.drawable.ic_storage,
primaryButtonText = R.string.fix,
secondaryButtonText = R.string.settings,
)
list.add(0, tip)
return
}
}
}
}
override fun setFilterOption(option: ListFilterOption, isApplied: Boolean) {
if (option is ListFilterOption.Tag) {
filterCoordinator.toggleTag(option.tag, isApplied)
}
}
override fun toggleFilterOption(option: ListFilterOption) {
if (option is ListFilterOption.Tag) {
val tag = option.tag
val isSelected = tag in filterCoordinator.snapshot().listFilter.tags
filterCoordinator.toggleTag(option.tag, !isSelected)
}
}
override fun clearFilter() = filterCoordinator.reset()
override fun onCleared() {
settings.unsubscribe(this)
super.onCleared()
@@ -125,4 +152,26 @@ class LocalListViewModel @Inject constructor(
actionStringRes = R.string._import,
)
}
private suspend fun createFilterHeader(maxCount: Int): QuickFilter? {
val appliedTags = filterCoordinator.snapshot().listFilter.tags
val availableTags = repository.getFilterOptions().availableTags
if (appliedTags.isEmpty() && availableTags.size < 3) {
return null
}
val result = ArrayList<ChipsView.ChipModel>(minOf(availableTags.size, maxCount))
appliedTags.mapTo(result) { tag ->
ListFilterOption.Tag(tag).toChipModel(isChecked = true)
}
for (tag in availableTags) {
if (result.size >= maxCount) {
break
}
if (tag in appliedTags) {
continue
}
result.add(ListFilterOption.Tag(tag).toChipModel(isChecked = false))
}
return QuickFilter(result)
}
}

View File

@@ -25,10 +25,12 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksFragment
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.core.util.ext.buildBundle
import org.koitharu.kotatsu.core.util.ext.setContentDescriptionAndTooltip
import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop
import org.koitharu.kotatsu.databinding.NavigationRailFabBinding
@@ -211,10 +213,13 @@ class MainNavigationDelegate(
return false
}
val fragment = instantiateFragment(fragmentClass)
val args = buildBundle(1) {
putBoolean(AppRouter.KEY_IS_BOTTOMTAB, true)
}
fragment.enterTransition = MaterialFadeThrough()
fragmentManager.beginTransaction()
.setReorderingAllowed(true)
.replace(R.id.container, fragmentClass, null, TAG_PRIMARY)
.replace(R.id.container, fragmentClass, args, TAG_PRIMARY)
.runOnCommit { onFragmentChanged(fragment, fromUser = true) }
.commit()
return true

View File

@@ -62,7 +62,7 @@ open class RemoteListViewModel @Inject constructor(
val isRandomLoading = MutableStateFlow(false)
val onOpenManga = MutableEventFlow<Manga>()
private val repository = mangaRepositoryFactory.create(source)
protected val repository = mangaRepositoryFactory.create(source)
private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(null)