diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt index 39b9b701b..8eb440a59 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt @@ -40,6 +40,8 @@ import org.koitharu.kotatsu.core.util.IncognitoModeIndicator import org.koitharu.kotatsu.core.util.ext.activityManager import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.isLowRamDevice +import org.koitharu.kotatsu.list.domain.ListExtraProvider +import org.koitharu.kotatsu.list.domain.ListExtraProviderImpl import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.LocalStorageChanges @@ -64,6 +66,9 @@ interface AppModule { @Binds fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter + @Binds + fun bindListExtraProvider(impl: ListExtraProviderImpl): ListExtraProvider + companion object { @Provides 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 36bbbc0c7..8fab1b77c 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 @@ -22,6 +22,8 @@ interface MangaRepository { val sortOrders: Set + var defaultSortOrder: SortOrder + suspend fun getList(offset: Int, query: String): List suspend fun getList(offset: Int, tags: Set?, sortOrder: SortOrder?): List 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 65758fd99..3df9988bd 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 @@ -39,8 +39,8 @@ class RemoteMangaRepository( override val sortOrders: Set get() = parser.sortOrders - var defaultSortOrder: SortOrder? - get() = getConfig().defaultSortOrder ?: sortOrders.firstOrNull() + override var defaultSortOrder: SortOrder + get() = getConfig().defaultSortOrder ?: sortOrders.first() set(value) { getConfig().defaultSortOrder = value } 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 3ad0838ad..c2c526e91 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 @@ -6,6 +6,7 @@ import android.content.res.ColorStateList import android.util.AttributeSet import android.view.View.OnClickListener import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat import androidx.core.content.res.getColorStateListOrThrow import androidx.core.view.children @@ -101,6 +102,13 @@ class ChipsView @JvmOverloads constructor( chip.setTextColor(tint ?: defaultChipTextColor) chip.isClickable = onChipClickListener != null || model.isCheckable chip.isCheckable = model.isCheckable + if (model.icon == 0) { + chip.chipIcon = null + chip.isChipIconVisible = false + } else { + chip.setChipIconResource(model.icon) + chip.isChipIconVisible = true + } chip.isChecked = model.isChecked chip.tag = model.data } @@ -134,6 +142,7 @@ class ChipsView @JvmOverloads constructor( class ChipModel( @ColorRes val tint: Int, val title: CharSequence, + @DrawableRes val icon: Int, val isCheckable: Boolean, val isChecked: Boolean, val data: Any? = null, @@ -147,6 +156,7 @@ class ChipsView @JvmOverloads constructor( if (tint != other.tint) return false if (title != other.title) return false + if (icon != other.icon) return false if (isCheckable != other.isCheckable) return false if (isChecked != other.isChecked) return false return data == other.data @@ -155,6 +165,7 @@ class ChipsView @JvmOverloads constructor( override fun hashCode(): Int { var result = tint.hashCode() result = 31 * result + title.hashCode() + result = 31 * result + icon.hashCode() result = 31 * result + isCheckable.hashCode() result = 31 * result + isChecked.hashCode() result = 31 * result + (data?.hashCode() ?: 0) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt index cf5645f1c..b14e842c7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.cancel import kotlin.coroutines.CoroutineContext class RetainedLifecycleCoroutineScope( - private val lifecycle: RetainedLifecycle, + val lifecycle: RetainedLifecycle, ) : CoroutineScope, RetainedLifecycle.OnClearedListener { override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt index 632030e09..35e2a1e56 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coroutines.kt @@ -8,9 +8,11 @@ import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.lifecycle.RetainedLifecycle import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine +import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -18,6 +20,9 @@ import kotlin.coroutines.resumeWithException val processLifecycleScope: LifecycleCoroutineScope inline get() = ProcessLifecycleOwner.get().lifecycleScope +val RetainedLifecycle.lifecycleScope: RetainedLifecycleCoroutineScope + inline get() = RetainedLifecycleCoroutineScope(this) + suspend fun Lifecycle.awaitStateAtLeast(state: Lifecycle.State) { if (currentState.isAtLeast(state)) { return diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ViewModel.kt index 27108dc26..8fe5f8f47 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ViewModel.kt @@ -1,10 +1,12 @@ package org.koitharu.kotatsu.core.util.ext +import android.annotation.SuppressLint import androidx.annotation.MainThread import androidx.fragment.app.Fragment import androidx.fragment.app.createViewModelLazy import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore import androidx.lifecycle.viewmodel.CreationExtras @MainThread @@ -17,3 +19,7 @@ inline fun Fragment.parentFragmentViewModels( extrasProducer = { extrasProducer?.invoke() ?: requireParentFragment().defaultViewModelCreationExtras }, factoryProducer = factoryProducer ?: { requireParentFragment().defaultViewModelProviderFactory }, ) + +val ViewModelStore.values: Collection + @SuppressLint("RestrictedApi") + get() = this.keys().mapNotNull { get(it) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index dbd6318e3..14b964316 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -284,6 +284,7 @@ class DetailsFragment : ChipsView.ChipModel( title = tag.title, tint = tagHighlighter.getTint(tag), + icon = 0, data = tag, isCheckable = false, isChecked = false, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapter.kt similarity index 93% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapter.kt index 0be19df8c..74d7459fc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapter.kt @@ -1,9 +1,10 @@ -package org.koitharu.kotatsu.list.ui.filter +package org.koitharu.kotatsu.filter.ui import android.content.Context import androidx.recyclerview.widget.AsyncListDiffer.ListListener import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller +import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.list.ui.adapter.listSimpleHeaderAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapterDelegates.kt similarity index 94% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapterDelegates.kt index 3a6d26892..c2125070c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapterDelegates.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.list.ui.filter +package org.koitharu.kotatsu.filter.ui import android.widget.TextView import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding +import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.list.ui.model.ListModel fun filterSortDelegate( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt similarity index 68% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index fa3db4b0b..f4442dbd8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -1,7 +1,9 @@ -package org.koitharu.kotatsu.list.ui.filter +package org.koitharu.kotatsu.filter.ui import androidx.annotation.WorkerThread -import kotlinx.coroutines.CoroutineScope +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.ViewModelLifecycle +import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -17,7 +19,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.core.util.ext.lifecycleScope +import org.koitharu.kotatsu.core.util.ext.require +import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel +import org.koitharu.kotatsu.filter.ui.model.FilterItem +import org.koitharu.kotatsu.filter.ui.model.FilterState import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter @@ -25,17 +33,26 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment +import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.util.ext.printStackTraceDebug import java.text.Collator +import java.util.LinkedList import java.util.Locale import java.util.TreeSet +import javax.inject.Inject -class FilterCoordinator( - private val repository: RemoteMangaRepository, +@ViewModelScoped +class FilterCoordinator @Inject constructor( + savedStateHandle: SavedStateHandle, + mangaRepositoryFactory: MangaRepository.Factory, dataRepository: MangaDataRepository, - private val coroutineScope: CoroutineScope, -) : OnFilterChangedListener { + private val searchRepository: MangaSearchRepository, + lifecycle: ViewModelLifecycle, +) : FilterOwner { + private val coroutineScope = lifecycle.lifecycleScope + private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE)) private val currentState = MutableStateFlow(FilterState(repository.defaultSortOrder, emptySet())) private var searchQuery = MutableStateFlow("") private val localTags = SuspendLazy { @@ -43,13 +60,23 @@ class FilterCoordinator( } private var availableTagsDeferred = loadTagsAsync() - val items: StateFlow> = getItemsFlow() - .stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) + override val filterItems: StateFlow> = getItemsFlow() + .stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) + + override val header: StateFlow = getHeaderFlow().stateIn( + scope = coroutineScope + Dispatchers.Default, + started = SharingStarted.Lazily, + initialValue = FilterHeaderModel(emptyList(), repository.defaultSortOrder, false), + ) init { observeState() } + override fun applyFilter(tags: Set) { + setTags(tags) + } + override fun onSortItemClick(item: FilterItem.Sort) { currentState.update { oldValue -> FilterState(item.order, oldValue.tags) @@ -95,6 +122,14 @@ class FilterCoordinator( searchQuery.value = query } + private fun getHeaderFlow() = combine( + observeState(), + observeAvailableTags(), + ) { state, available -> + val chips = createChipsList(state, available.orEmpty()) + FilterHeaderModel(chips, state.sortOrder, state.tags.isNotEmpty()) + } + private fun getItemsFlow() = combine( getTagsAsFlow(), currentState, @@ -114,6 +149,48 @@ class FilterCoordinator( } } + private suspend fun createChipsList( + filterState: FilterState, + availableTags: Set, + ): List { + val selectedTags = filterState.tags.toMutableSet() + var tags = searchRepository.getTagsSuggestion("", 6, repository.source) + if (tags.isEmpty()) { + tags = availableTags.take(6) + } + if (tags.isEmpty() && selectedTags.isEmpty()) { + return emptyList() + } + val result = LinkedList() + for (tag in tags) { + val model = ChipsView.ChipModel( + tint = 0, + title = tag.title, + icon = 0, + isCheckable = true, + isChecked = selectedTags.remove(tag), + data = tag, + ) + if (model.isChecked) { + result.addFirst(model) + } else { + result.addLast(model) + } + } + for (tag in selectedTags) { + val model = ChipsView.ChipModel( + tint = 0, + title = tag.title, + icon = 0, + isCheckable = true, + isChecked = true, + data = tag, + ) + result.addFirst(model) + } + return result + } + @WorkerThread private fun buildFilterList( allTags: TagsWrapper, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterDiffCallback.kt similarity index 93% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterDiffCallback.kt index 72004fdc8..d3319c431 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterDiffCallback.kt @@ -1,6 +1,7 @@ -package org.koitharu.kotatsu.list.ui.filter +package org.koitharu.kotatsu.filter.ui import androidx.recyclerview.widget.DiffUtil +import org.koitharu.kotatsu.filter.ui.model.FilterItem import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel 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 new file mode 100644 index 000000000..4f50e9df5 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt @@ -0,0 +1,71 @@ +package org.koitharu.kotatsu.filter.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.graphics.Insets +import androidx.core.view.isVisible +import com.google.android.material.chip.Chip +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.BaseFragment +import org.koitharu.kotatsu.core.ui.widgets.ChipsView +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.parsers.model.MangaTag +import com.google.android.material.R as materialR + +class FilterHeaderFragment : BaseFragment(), ChipsView.OnChipClickListener { + + private val owner by lazy(LazyThreadSafetyMode.NONE) { + FilterOwner.from(requireActivity()) + } + + override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFilterHeaderBinding { + return FragmentFilterHeaderBinding.inflate(inflater, container, false) + } + + override fun onViewBindingCreated(binding: FragmentFilterHeaderBinding, savedInstanceState: Bundle?) { + super.onViewBindingCreated(binding, savedInstanceState) + binding.chipsTags.onChipClickListener = this + owner.header.observe(viewLifecycleOwner, ::onDataChanged) + } + + override fun onWindowInsetsChanged(insets: Insets) = Unit + + override fun onChipClick(chip: Chip, data: Any?) { + val tag = data as? MangaTag + if (tag == null) { + FilterSheetFragment.show(parentFragmentManager) + } else { + owner.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked)) + } + } + + private fun onDataChanged(header: FilterHeaderModel) { + val binding = viewBinding ?: return + val chips = header.chips + if (chips.isEmpty()) { + binding.chipsTags.setChips(emptyList()) + binding.root.isVisible = false + return + } + if (binding.root.context.isAnimationsEnabled) { + binding.scrollView.smoothScrollTo(0, 0) + } else { + binding.scrollView.scrollTo(0, 0) + } + binding.chipsTags.setChips(header.chips + moreTagsChip()) + binding.root.isVisible = true + } + + private fun moreTagsChip() = ChipsView.ChipModel( + tint = 0, + title = getString(R.string.more), + icon = materialR.drawable.abc_ic_menu_overflow_material, + isCheckable = false, + isChecked = false, + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt new file mode 100644 index 000000000..b302e7692 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterOwner.kt @@ -0,0 +1,32 @@ +package org.koitharu.kotatsu.filter.ui + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import kotlinx.coroutines.flow.StateFlow +import org.koitharu.kotatsu.core.util.ext.values +import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.model.MangaTag + +interface FilterOwner : OnFilterChangedListener { + + val filterItems: StateFlow> + + val header: StateFlow + + fun applyFilter(tags: Set) + + companion object { + + fun from(activity: FragmentActivity): FilterOwner { + for (f in activity.supportFragmentManager.fragments) { + return find(f) ?: continue + } + error("Cannot find FilterOwner") + } + + fun find(fragment: Fragment): FilterOwner? { + return fragment.viewModelStore.values.firstNotNullOfOrNull { it as? FilterOwner } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt similarity index 74% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterSheetFragment.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt index aa3240c27..c9734d9c7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterSheetFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt @@ -1,5 +1,6 @@ -package org.koitharu.kotatsu.list.ui.filter +package org.koitharu.kotatsu.filter.ui +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -10,20 +11,18 @@ 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.ui.util.CollapseActionViewCallback import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.parentFragmentViewModels import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel class FilterSheetFragment : BaseAdaptiveSheet(), AdaptiveSheetCallback, AsyncListDiffer.ListListener { - private val viewModel by parentFragmentViewModels() - private var collapsibleActionViewCallback: CollapseActionViewCallback? = null + private val owner by lazy(LazyThreadSafetyMode.NONE) { + FilterOwner.from(requireActivity()) + } override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { return SheetFilterBinding.inflate(inflater, container, false) @@ -32,14 +31,13 @@ class FilterSheetFragment : override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) addSheetCallback(this) - val adapter = FilterAdapter(viewModel, this) + val adapter = FilterAdapter(owner, this) binding.recyclerView.adapter = adapter - viewModel.filterItems.observe(viewLifecycleOwner, adapter::setItems) - } + owner.filterItems.observe(viewLifecycleOwner, adapter::setItems) - override fun onDestroyView() { - super.onDestroyView() - collapsibleActionViewCallback = null + if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + binding.recyclerView.scrollIndicators = 0 + } } override fun onCurrentListChanged(previousList: MutableList, currentList: MutableList) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt similarity index 56% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt index a28596c9f..bf3f15f93 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt @@ -1,8 +1,10 @@ -package org.koitharu.kotatsu.list.ui.filter +package org.koitharu.kotatsu.filter.ui + +import org.koitharu.kotatsu.filter.ui.model.FilterItem interface OnFilterChangedListener { fun onSortItemClick(item: FilterItem.Sort) fun onTagItemClick(item: FilterItem.Tag) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader2.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt similarity index 80% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader2.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt index dab335ee2..c9733a3b3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader2.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt @@ -1,9 +1,10 @@ -package org.koitharu.kotatsu.list.ui.model +package org.koitharu.kotatsu.filter.ui.model import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.SortOrder -class ListHeader2( +class FilterHeaderModel( val chips: Collection, val sortOrder: SortOrder?, val hasSelectedTags: Boolean, @@ -13,7 +14,7 @@ class ListHeader2( if (this === other) return true if (javaClass != other?.javaClass) return false - other as ListHeader2 + other as FilterHeaderModel if (chips != other.chips) return false return sortOrder == other.sortOrder diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterItem.kt similarity index 97% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterItem.kt index 3ca5825b9..a2ad2cddb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterItem.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.list.ui.filter +package org.koitharu.kotatsu.filter.ui.model import androidx.annotation.StringRes import org.koitharu.kotatsu.list.ui.model.ListModel diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterState.kt similarity index 84% rename from app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterState.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterState.kt index d9e387b89..b4ab41c7a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/filter/FilterState.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterState.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.list.ui.filter +package org.koitharu.kotatsu.filter.ui.model import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder @@ -15,9 +15,7 @@ class FilterState( other as FilterState if (sortOrder != other.sortOrder) return false - if (tags != other.tags) return false - - return true + return tags == other.tags } override fun hashCode(): Int { @@ -25,4 +23,4 @@ class FilterState( result = 31 * result + tags.hashCode() return result } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListExtraProviderImpl.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListExtraProviderImpl.kt new file mode 100644 index 000000000..c972feb7e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListExtraProviderImpl.kt @@ -0,0 +1,32 @@ +package org.koitharu.kotatsu.list.domain + +import dagger.Reusable +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.history.data.HistoryRepository +import org.koitharu.kotatsu.history.data.PROGRESS_NONE +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import javax.inject.Inject + +@Reusable +class ListExtraProviderImpl @Inject constructor( + private val settings: AppSettings, + private val trackingRepository: TrackingRepository, + private val historyRepository: HistoryRepository, +) : ListExtraProvider { + + override suspend fun getCounter(mangaId: Long): Int { + return if (settings.isTrackerEnabled) { + trackingRepository.getNewChaptersCount(mangaId) + } else { + 0 + } + } + + override suspend fun getProgress(mangaId: Long): Float { + return if (settings.isReadingIndicatorsEnabled) { + historyRepository.getProgress(mangaId) + } else { + PROGRESS_NONE + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeader2AD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeader2AD.kt index 4a0f8f2fe..518983bd4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeader2AD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListHeader2AD.kt @@ -1,24 +1,20 @@ package org.koitharu.kotatsu.list.ui.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled -import org.koitharu.kotatsu.core.util.ext.setTextAndVisible -import org.koitharu.kotatsu.databinding.ItemHeader2Binding -import org.koitharu.kotatsu.list.ui.model.ListHeader2 +import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding +import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.MangaTag +@Deprecated("") fun listHeader2AD( listener: MangaListListener, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemHeader2Binding.inflate(layoutInflater, parent, false) }, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> FragmentFilterHeaderBinding.inflate(layoutInflater, parent, false) }, ) { var ignoreChecking = false - binding.textViewFilter.setOnClickListener { - listener.onFilterClick(it) - } binding.chipsTags.setOnCheckedStateChangeListener { _, _ -> if (!ignoreChecking) { listener.onUpdateFilter(binding.chipsTags.getCheckedData(MangaTag::class.java)) @@ -36,6 +32,5 @@ fun listHeader2AD( ignoreChecking = true binding.chipsTags.setChips(item.chips) // TODO use recyclerview ignoreChecking = false - binding.textViewFilter.setTextAndVisible(item.sortOrder?.titleRes ?: 0) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt index f471cc99a..09a4809d6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -5,8 +5,8 @@ import androidx.recyclerview.widget.DiffUtil import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import org.koitharu.kotatsu.core.ui.model.DateTimeAgo +import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel import org.koitharu.kotatsu.list.ui.model.ListHeader -import org.koitharu.kotatsu.list.ui.model.ListHeader2 import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.MangaGridModel @@ -82,7 +82,7 @@ open class MangaListAdapter( } } - is ListHeader2 -> Unit + is FilterHeaderModel -> Unit else -> super.getChangePayload(oldItem, newItem) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt index ea24b01fa..172fe3aeb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt @@ -43,6 +43,7 @@ fun Manga.toListDetailedModel( ChipsView.ChipModel( tint = tagHighlighter?.getTint(it) ?: 0, title = it.title, + icon = 0, isCheckable = false, isChecked = false, data = it, 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 7ac0dc282..1bd6dceb8 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 @@ -13,6 +13,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.CompositeMutex import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.local.data.input.LocalMangaInput @@ -28,6 +29,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.util.ext.printStackTraceDebug import java.io.File +import java.util.EnumSet import javax.inject.Inject import javax.inject.Singleton @@ -37,11 +39,20 @@ private const val MAX_PARALLELISM = 4 class LocalMangaRepository @Inject constructor( private val storageManager: LocalStorageManager, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, + private val settings: AppSettings, ) : MangaRepository { override val source = MangaSource.LOCAL private val locks = CompositeMutex() + override val sortOrders: Set = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST) + + override var defaultSortOrder: SortOrder + get() = settings.localListOrder + set(value) { + settings.localListOrder = value + } + override suspend fun getList(offset: Int, query: String): List { if (offset > 0) { return emptyList() @@ -137,8 +148,6 @@ class LocalMangaRepository @Inject constructor( }.firstOrNull()?.getManga() } - override val sortOrders = setOf(SortOrder.ALPHABETICAL, SortOrder.RATING) - override suspend fun getPageUrl(page: MangaPage) = page.url override suspend fun getTags() = emptySet() 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 3f8fd2bb2..1cf6c2a79 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 @@ -5,7 +5,6 @@ import android.view.Menu import android.view.MenuItem import android.view.View import androidx.appcompat.view.ActionMode -import androidx.appcompat.widget.PopupMenu import androidx.core.net.toFile import androidx.core.net.toUri import androidx.fragment.app.viewModels @@ -16,11 +15,14 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController 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.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding +import org.koitharu.kotatsu.filter.ui.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment -class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener { +class LocalListFragment : MangaListFragment() { override val viewModel by viewModels() @@ -35,11 +37,7 @@ class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener } override fun onFilterClick(view: View?) { - super.onFilterClick(view) - val menu = PopupMenu(requireContext(), view ?: requireViewBinding().recyclerView) - menu.inflate(R.menu.popup_order) - menu.setOnMenuItemClickListener(this) - menu.show() + FilterSheetFragment.show(childFragmentManager) } override fun onScrolledToEnd() = Unit @@ -67,17 +65,6 @@ class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener } } - override fun onMenuItemClick(item: MenuItem): Boolean { - val order = when (item.itemId) { - R.id.action_order_new -> SortOrder.NEWEST - R.id.action_order_abs -> SortOrder.ALPHABETICAL - R.id.action_order_rating -> SortOrder.RATING - else -> return false - } - viewModel.setSortOrder(order) - return true - } - private fun showDeletionConfirm(ids: Set, mode: ActionMode) { MaterialAlertDialogBuilder(context ?: return) .setTitle(R.string.delete_manga) @@ -96,6 +83,8 @@ class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener companion object { - fun newInstance() = LocalListFragment() + fun newInstance() = LocalListFragment().withArgs(1) { + putSerializable(RemoteListFragment.ARG_SOURCE, MangaSource.LOCAL) // required by FilterCoordinator + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 80836cdd7..65f7a393e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -1,123 +1,57 @@ package org.koitharu.kotatsu.local.ui -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaTagHighlighter import org.koitharu.kotatsu.core.prefs.AppSettings -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.download.ui.worker.DownloadWorker -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.history.data.PROGRESS_NONE +import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.list.domain.ListExtraProvider -import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.ListHeader2 -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.list.ui.model.toUi -import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.model.LocalManga -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import java.util.LinkedList +import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import javax.inject.Inject @HiltViewModel class LocalListViewModel @Inject constructor( - private val repository: LocalMangaRepository, - private val historyRepository: HistoryRepository, - private val trackingRepository: TrackingRepository, - private val settings: AppSettings, - private val tagHighlighter: MangaTagHighlighter, - @LocalStorageChanges private val localStorageChanges: SharedFlow, - private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, + savedStateHandle: SavedStateHandle, + mangaRepositoryFactory: MangaRepository.Factory, + filter: FilterCoordinator, + tagHighlighter: MangaTagHighlighter, + settings: AppSettings, downloadScheduler: DownloadWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler), ListExtraProvider { + listExtraProvider: ListExtraProvider, + private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, + @LocalStorageChanges private val localStorageChanges: SharedFlow, +) : RemoteListViewModel( + savedStateHandle, + mangaRepositoryFactory, + filter, + tagHighlighter, + settings, + listExtraProvider, + downloadScheduler, +) { val onMangaRemoved = MutableEventFlow() - val sortOrder = MutableStateFlow(settings.localListOrder) - private val listError = MutableStateFlow(null) - private val mangaList = MutableStateFlow?>(null) - private val selectedTags = MutableStateFlow>(emptySet()) - private var refreshJob: Job? = null - - override val content = combine( - mangaList, - listMode, - sortOrder, - selectedTags, - listError, - ) { list, mode, order, tags, error -> - when { - error != null -> listOf(error.toErrorState(canRetry = true)) - list == null -> listOf(LoadingState) - list.isEmpty() -> listOf( - EmptyState( - icon = R.drawable.ic_empty_local, - textPrimary = R.string.text_local_holder_primary, - textSecondary = R.string.text_local_holder_secondary, - actionStringRes = R.string._import, - ), - ) - - else -> buildList(list.size + 1) { - add(createHeader(list, tags, order)) - list.toUi(this, mode, this@LocalListViewModel, tagHighlighter) - } - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) init { - onRefresh() launchJob(Dispatchers.Default) { localStorageChanges - .collectLatest { - if (refreshJob?.isActive != true) { - doRefresh() - } + .collect { + loadList(filter.snapshot(), append = false).join() } } } - override fun onUpdateFilter(tags: Set) { - selectedTags.value = tags - onRefresh() - } - - override fun onRefresh() { - val prevJob = refreshJob - refreshJob = launchLoadingJob(Dispatchers.Default) { - prevJob?.cancelAndJoin() - doRefresh() - } - } - - override fun onRetry() = onRefresh() - - fun setSortOrder(value: SortOrder) { - sortOrder.value = value - settings.localListOrder = value - onRefresh() - } - fun delete(ids: Set) { launchLoadingJob(Dispatchers.Default) { deleteLocalMangaUseCase(ids) @@ -125,60 +59,12 @@ class LocalListViewModel @Inject constructor( } } - private suspend fun doRefresh() { - try { - listError.value = null - mangaList.value = repository.getList(0, selectedTags.value, sortOrder.value) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - listError.value = e - } - } - - private fun createHeader(mangaList: List, selectedTags: Set, order: SortOrder): ListHeader2 { - val tags = HashMap() - for (item in mangaList) { - for (tag in item.tags) { - tags[tag] = tags[tag]?.plus(1) ?: 1 - } - } - val topTags = tags.entries.sortedByDescending { it.value }.take(6) - val chips = LinkedList() - for ((tag, _) in topTags) { - val model = ChipsView.ChipModel( - tint = 0, - title = tag.title, - isCheckable = true, - isChecked = tag in selectedTags, - data = tag, - ) - if (model.isChecked) { - chips.addFirst(model) - } else { - chips.addLast(model) - } - } - return ListHeader2( - chips = chips, - sortOrder = order, - hasSelectedTags = selectedTags.isNotEmpty(), + override fun createEmptyState(canResetFilter: Boolean): EmptyState { + return EmptyState( + icon = R.drawable.ic_empty_local, + textPrimary = R.string.text_local_holder_primary, + textSecondary = R.string.text_local_holder_secondary, + actionStringRes = R.string._import, ) } - - override suspend fun getCounter(mangaId: Long): Int { - return if (settings.isTrackerEnabled) { - trackingRepository.getNewChaptersCount(mangaId) - } else { - 0 - } - } - - override suspend fun getProgress(mangaId: Long): Float { - return if (settings.isReadingIndicatorsEnabled) { - historyRepository.getProgress(mangaId) - } else { - PROGRESS_NONE - } - } } 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 f02a86c7c..fed94e8c9 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 @@ -15,8 +15,8 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding +import org.koitharu.kotatsu.filter.ui.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.list.ui.filter.FilterSheetFragment import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.search.ui.SearchActivity @@ -25,7 +25,7 @@ import org.koitharu.kotatsu.settings.SettingsActivity @AndroidEntryPoint class RemoteListFragment : MangaListFragment() { - public override val viewModel by viewModels() + override val viewModel by viewModels() override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 3379c7463..130a34233 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce @@ -18,23 +17,18 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaTagHighlighter -import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.download.ui.worker.DownloadWorker +import org.koitharu.kotatsu.filter.ui.FilterCoordinator +import org.koitharu.kotatsu.filter.ui.FilterOwner +import org.koitharu.kotatsu.filter.ui.model.FilterState +import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.filter.FilterCoordinator -import org.koitharu.kotatsu.list.ui.filter.FilterItem -import org.koitharu.kotatsu.list.ui.filter.FilterState -import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.ListHeader2 -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.list.ui.model.toErrorFooter @@ -43,50 +37,42 @@ import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.util.ext.printStackTraceDebug -import java.util.LinkedList import javax.inject.Inject private const val FILTER_MIN_INTERVAL = 250L @HiltViewModel -class RemoteListViewModel @Inject constructor( +open class RemoteListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, mangaRepositoryFactory: MangaRepository.Factory, - private val searchRepository: MangaSearchRepository, - settings: AppSettings, - dataRepository: MangaDataRepository, + private val filter: FilterCoordinator, private val tagHighlighter: MangaTagHighlighter, + settings: AppSettings, + listExtraProvider: ListExtraProvider, downloadScheduler: DownloadWorker.Scheduler, -) : MangaListViewModel(settings, downloadScheduler), OnFilterChangedListener { +) : MangaListViewModel(settings, downloadScheduler), FilterOwner by filter { val source = savedStateHandle.require(RemoteListFragment.ARG_SOURCE) - private val repository = mangaRepositoryFactory.create(source) as RemoteMangaRepository - private val filter = FilterCoordinator(repository, dataRepository, viewModelScope) + private val repository = mangaRepositoryFactory.create(source) private val mangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) private val listError = MutableStateFlow(null) private var loadingJob: Job? = null - val filterItems: StateFlow> - get() = filter.items - override val content = combine( mangaList, listMode, - createHeaderFlow(), listError, hasNextPage, - ) { list, mode, header, error, hasNext -> + ) { list, mode, error, hasNext -> buildList(list?.size?.plus(2) ?: 2) { - add(header) when { list.isNullOrEmpty() && error != null -> add(error.toErrorState(canRetry = true)) list == null -> add(LoadingState) - list.isEmpty() -> add(createEmptyState(header.hasSelectedTags)) + list.isEmpty() -> add(createEmptyState(header.value.hasSelectedTags)) else -> { - list.toUi(this, mode, tagHighlighter) + list.toUi(this, mode, listExtraProvider, tagHighlighter) when { error != null -> add(error.toErrorFooter()) hasNext -> add(LoadingFooter()) @@ -117,37 +103,23 @@ class RemoteListViewModel @Inject constructor( loadList(filter.snapshot(), append = !mangaList.value.isNullOrEmpty()) } - override fun onSortItemClick(item: FilterItem.Sort) { - filter.onSortItemClick(item) - } - - override fun onTagItemClick(item: FilterItem.Tag) { - filter.onTagItemClick(item) - } - fun loadNextPage() { if (hasNextPage.value && listError.value == null) { loadList(filter.snapshot(), append = true) } } - fun filterSearch(query: String) = filter.performSearch(query) - fun resetFilter() = filter.reset() override fun onUpdateFilter(tags: Set) { applyFilter(tags) } - fun applyFilter(tags: Set) { - filter.setTags(tags) - } - - private fun loadList(filterState: FilterState, append: Boolean) { - if (loadingJob?.isActive == true) { - return + protected fun loadList(filterState: FilterState, append: Boolean): Job { + loadingJob?.let { + if (it.isActive) return it } - loadingJob = launchLoadingJob(Dispatchers.Default) { + return launchLoadingJob(Dispatchers.Default) { try { listError.value = null val list = repository.getList( @@ -170,61 +142,13 @@ class RemoteListViewModel @Inject constructor( errorEvent.call(e) } } - } + }.also { loadingJob = it } } - private fun createEmptyState(canResetFilter: Boolean) = EmptyState( + protected open fun createEmptyState(canResetFilter: Boolean) = EmptyState( icon = R.drawable.ic_empty_common, textPrimary = R.string.nothing_found, textSecondary = 0, actionStringRes = if (canResetFilter) R.string.reset_filter else 0, ) - - private fun createHeaderFlow() = combine( - filter.observeState(), - filter.observeAvailableTags(), - ) { state, available -> - val chips = createChipsList(state, available.orEmpty()) - ListHeader2(chips, state.sortOrder, state.tags.isNotEmpty()) - } - - private suspend fun createChipsList( - filterState: FilterState, - availableTags: Set, - ): List { - val selectedTags = filterState.tags.toMutableSet() - var tags = searchRepository.getTagsSuggestion("", 6, repository.source) - if (tags.isEmpty()) { - tags = availableTags.take(6) - } - if (tags.isEmpty() && selectedTags.isEmpty()) { - return emptyList() - } - val result = LinkedList() - for (tag in tags) { - val model = ChipsView.ChipModel( - tint = 0, - title = tag.title, - isCheckable = true, - isChecked = selectedTags.remove(tag), - data = tag, - ) - if (model.isChecked) { - result.addFirst(model) - } else { - result.addLast(model) - } - } - for (tag in selectedTags) { - val model = ChipsView.ChipModel( - tint = 0, - title = tag.title, - isCheckable = true, - isChecked = true, - data = tag, - ) - result.addFirst(model) - } - return result - } } 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 1dd93c2a2..898b33237 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 @@ -3,7 +3,10 @@ package org.koitharu.kotatsu.search.ui import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.View +import android.view.ViewGroup.MarginLayoutParams import androidx.core.graphics.Insets +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.commit import com.google.android.material.appbar.AppBarLayout @@ -11,9 +14,16 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat -import org.koitharu.kotatsu.databinding.ActivityContainerBinding +import org.koitharu.kotatsu.core.util.ext.observe +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.list.ui.MangaListFragment import org.koitharu.kotatsu.local.ui.LocalListFragment import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.MangaSource @@ -22,15 +32,15 @@ import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment @AndroidEntryPoint class MangaListActivity : - BaseActivity(), - AppBarOwner { + BaseActivity(), + AppBarOwner, View.OnClickListener { override val appBar: AppBarLayout get() = viewBinding.appbar override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) + setContentView(ActivityMangaListBinding.inflate(layoutInflater)) val tags = intent.getParcelableExtraCompat(EXTRA_TAGS)?.tags supportActionBar?.setDisplayHomeAsUpEnabled(true) val source = intent.getSerializableExtraCompat(EXTRA_SOURCE) ?: tags?.firstOrNull()?.source @@ -38,7 +48,28 @@ class MangaListActivity : finishAfterTransition() return } + viewBinding.chipSort?.setOnClickListener(this) title = if (source == MangaSource.LOCAL) getString(R.string.local_storage) else source.title + initList(source, tags) + } + + override fun onWindowInsetsChanged(insets: Insets) { + viewBinding.root.updatePadding( + left = insets.left, + right = insets.right, + ) + viewBinding.cardFilter?.updateLayoutParams { + bottomMargin = marginStart + insets.bottom + } + } + + override fun onClick(v: View) { + when (v.id) { + R.id.chip_sort -> FilterSheetFragment.show(supportFragmentManager) + } + } + + private fun initList(source: MangaSource, tags: Set?) { val fm = supportFragmentManager if (fm.findFragmentById(R.id.container) == null) { fm.commit { @@ -52,24 +83,46 @@ class MangaListActivity : if (!tags.isNullOrEmpty() && fragment is RemoteListFragment) { runOnCommit(ApplyFilterRunnable(fragment, tags)) } + runOnCommit { initFilter() } + } + } else { + initFilter() + } + } + + private fun initFilter() { + if (viewBinding.containerFilter != null) { + if (supportFragmentManager.findFragmentById(R.id.container_filter) == null) { + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.container_filter, FilterSheetFragment::class.java, null) + } + } + } else if (viewBinding.containerFilterHeader != null) { + if (supportFragmentManager.findFragmentById(R.id.container_filter_header) == null) { + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.container_filter_header, FilterHeaderFragment::class.java, null) + } + } + } + val chipSort = viewBinding.chipSort + if (chipSort != null) { + FilterOwner.from(this).header.observe(this) { + chipSort.setTextAndVisible(it.sortOrder?.titleRes ?: 0) } } } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - private class ApplyFilterRunnable( - private val fragment: RemoteListFragment, + private val fragment: MangaListFragment, private val tags: Set, ) : Runnable { override fun run() { - fragment.viewModel.applyFilter(tags) + checkNotNull(FilterOwner.find(fragment)) { + "Cannot find FilterOwner" + }.applyFilter(tags) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt index a01a2d31f..d44357b08 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionViewModel.kt @@ -135,6 +135,7 @@ class SearchSuggestionViewModel @Inject constructor( ChipsView.ChipModel( tint = 0, title = tag.title, + icon = 0, data = tag, isCheckable = false, isChecked = false, diff --git a/app/src/main/res/drawable/ic_sort.xml b/app/src/main/res/drawable/ic_sort.xml new file mode 100644 index 000000000..944c99fd4 --- /dev/null +++ b/app/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout-w600dp/activity_manga_list.xml b/app/src/main/res/layout-w600dp/activity_manga_list.xml new file mode 100644 index 000000000..c3daad6ac --- /dev/null +++ b/app/src/main/res/layout-w600dp/activity_manga_list.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_manga_list.xml b/app/src/main/res/layout/activity_manga_list.xml new file mode 100644 index 000000000..d2fe1e037 --- /dev/null +++ b/app/src/main/res/layout/activity_manga_list.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_filter_header.xml b/app/src/main/res/layout/fragment_filter_header.xml new file mode 100644 index 000000000..0ffd785ee --- /dev/null +++ b/app/src/main/res/layout/fragment_filter_header.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/app/src/main/res/layout/item_header_2.xml b/app/src/main/res/layout/item_header_2.xml deleted file mode 100644 index adf261685..000000000 --- a/app/src/main/res/layout/item_header_2.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/menu/popup_order.xml b/app/src/main/res/menu/popup_order.xml deleted file mode 100644 index 3125387e7..000000000 --- a/app/src/main/res/menu/popup_order.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - -