Migrate favorites to ViewPager2

This commit is contained in:
Koitharu
2024-01-16 11:15:56 +02:00
parent 313013dccd
commit ae8b48d733
15 changed files with 245 additions and 127 deletions

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.core.ui.util
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
class PopupMenuMediator(
private val provider: MenuProvider,
) : View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener {
override fun onLongClick(v: View): Boolean {
val menu = PopupMenu(v.context, v)
provider.onCreateMenu(menu.menu, menu.menuInflater)
provider.onPrepareMenu(menu.menu)
if (!menu.menu.hasVisibleItems()) {
return false
}
menu.setOnMenuItemClickListener(this)
menu.setOnDismissListener(this)
menu.show()
return true
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return provider.onMenuItemSelected(item)
}
override fun onDismiss(menu: PopupMenu) {
provider.onMenuClosed(menu.menu)
}
fun attach(view: View) {
view.setOnLongClickListener(this)
view.setOnContextClickListenerCompat(this)
}
}

View File

@@ -365,6 +365,7 @@ class DetailsActivity :
private fun initPager() {
viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false
val adapter = DetailsPagerAdapter(this)
viewBinding.pager.offscreenPageLimit = 1
viewBinding.pager.adapter = adapter
TabLayoutMediator(viewBinding.tabs, viewBinding.pager, adapter).attach()
viewBinding.pager.setCurrentItem(settings.defaultDetailsTab, false)

View File

@@ -4,7 +4,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
data class FavouriteTabModel(
val id: Long,
val title: String,
val title: String?,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {

View File

@@ -0,0 +1,54 @@
package org.koitharu.kotatsu.favourites.ui.container
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
class FavouriteTabPopupMenuProvider(
private val context: Context,
private val viewModel: FavouritesContainerViewModel,
private val categoryId: Long
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
val menuResId = if (categoryId == NO_ID) {
R.menu.popup_fav_tab_all
} else {
R.menu.popup_fav_tab
}
menuInflater.inflate(menuResId, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_hide -> viewModel.hide(categoryId)
R.id.action_edit -> context.startActivity(
FavouritesCategoryEditActivity.newIntent(context, categoryId),
)
R.id.action_delete -> confirmDelete()
else -> return false
}
return true
}
private fun confirmDelete() {
MaterialAlertDialogBuilder(
context,
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered,
).setMessage(R.string.categories_delete_confirm)
.setTitle(R.string.remove_category)
.setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.remove) { _, _ ->
viewModel.deleteCategory(categoryId)
}.show()
}
}

View File

@@ -1,33 +1,46 @@
package org.koitharu.kotatsu.favourites.ui.container
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentStatePagerAdapter
import androidx.recyclerview.widget.AdapterListUpdateCallback
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.viewpager2.adapter.FragmentStateAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
import org.koitharu.kotatsu.parsers.util.replaceWith
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import kotlin.coroutines.suspendCoroutine
@Suppress("DEPRECATION")
class FavouritesContainerAdapter(
fm: FragmentManager
) : FragmentStatePagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT),
class FavouritesContainerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment),
FlowCollector<List<FavouriteTabModel>> {
private val dataSet = ArrayList<FavouriteTabModel>()
private val differ = AsyncListDiffer(
AdapterListUpdateCallback(this),
AsyncDifferConfig.Builder(ListModelDiffCallback<FavouriteTabModel>())
.setBackgroundThreadExecutor(Dispatchers.Default.limitedParallelism(2).asExecutor())
.build(),
)
override fun getCount(): Int = dataSet.size
override fun getItemCount(): Int = differ.currentList.size
override fun getItem(position: Int): Fragment {
val item = dataSet[position]
override fun getItemId(position: Int): Long {
return differ.currentList[position].id
}
override fun containsItem(itemId: Long): Boolean {
return differ.currentList.any { x -> x.id == itemId }
}
override fun createFragment(position: Int): Fragment {
val item = differ.currentList[position]
return FavouritesListFragment.newInstance(item.id)
}
override fun getPageTitle(position: Int): CharSequence {
return dataSet[position].title
override suspend fun emit(value: List<FavouriteTabModel>) = suspendCoroutine { cont ->
differ.submitList(value, ContinuationResumeRunnable(cont))
}
override suspend fun emit(value: List<FavouriteTabModel>) {
dataSet.replaceWith(value)
notifyDataSetChanged()
}
fun getItem(position: Int): FavouriteTabModel = differ.currentList[position]
}

View File

@@ -1,55 +0,0 @@
package org.koitharu.kotatsu.favourites.ui.container
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.AdapterListUpdateCallback
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import kotlin.coroutines.suspendCoroutine
// FIXME migrate to ViewPager2 in FavouritesContainerFragment
class FavouritesContainerAdapter2(fragment: Fragment) :
FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle),
TabConfigurationStrategy,
FlowCollector<List<FavouriteTabModel>> {
private val differ = AsyncListDiffer(
AdapterListUpdateCallback(this),
AsyncDifferConfig.Builder(ListModelDiffCallback<FavouriteTabModel>())
.setBackgroundThreadExecutor(Dispatchers.Default.limitedParallelism(2).asExecutor())
.build(),
)
override fun getItemCount(): Int = differ.currentList.size
override fun getItemId(position: Int): Long {
return differ.currentList[position].id
}
override fun containsItem(itemId: Long): Boolean {
return differ.currentList.any { x -> x.id == itemId }
}
override fun createFragment(position: Int): Fragment {
val item = differ.currentList[position]
return FavouritesListFragment.newInstance(item.id)
}
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = differ.currentList[position]
tab.text = item.title
tab.tag = item
}
override suspend fun emit(value: List<FavouriteTabModel>) = suspendCoroutine { cont ->
differ.submitList(value, ContinuationResumeRunnable(cont))
}
}

View File

@@ -12,14 +12,18 @@ import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import coil.ImageLoader
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.setTabsEnabled
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.databinding.FragmentFavouritesContainerBinding
@@ -43,15 +47,21 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesContainerBind
override fun onViewBindingCreated(binding: FragmentFavouritesContainerBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val adapter = FavouritesContainerAdapter(childFragmentManager)
binding.pager.adapter = adapter
binding.tabs.setupWithViewPager(binding.pager)
binding.pager.offscreenPageLimit = 1
val pagerAdapter = FavouritesContainerAdapter(this)
binding.pager.adapter = pagerAdapter
binding.pager.offscreenPageLimit = 99 // FIXME
binding.pager.recyclerView?.isNestedScrollingEnabled = false
TabLayoutMediator(
binding.tabs,
binding.pager,
FavouritesTabConfigurationStrategy(pagerAdapter, viewModel),
).attach()
binding.stubEmpty.setOnInflateListener(this)
actionModeDelegate.addListener(this)
viewModel.categories.observe(viewLifecycleOwner, adapter)
viewModel.categories.observe(viewLifecycleOwner, pagerAdapter)
viewModel.isEmpty.observe(viewLifecycleOwner, ::onEmptyStateChanged)
addMenuProvider(FavouritesContainerMenuProvider(binding.root.context))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.pager))
}
override fun onDestroyView() {

View File

@@ -4,30 +4,80 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import javax.inject.Inject
@HiltViewModel
class FavouritesContainerViewModel @Inject constructor(
favouritesRepository: FavouritesRepository,
private val settings: AppSettings,
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>()
private val categoriesStateFlow = favouritesRepository.observeCategoriesForLibrary()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val categories = categoriesStateFlow.filterNotNull()
.mapItems { FavouriteTabModel(it.id, it.title) }
.distinctUntilChanged()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val categories = combine(
categoriesStateFlow.filterNotNull(),
observeAllFavouritesVisibility(),
) { list, showAll ->
list.toUi(showAll)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val isEmpty = categoriesStateFlow.map {
it?.isEmpty() == true
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
private fun List<FavouriteCategory>.toUi(showAll: Boolean): List<FavouriteTabModel> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<FavouriteTabModel>(if (showAll) size + 1 else size)
if (showAll) {
result.add(FavouriteTabModel(NO_ID, null))
}
mapTo(result) { FavouriteTabModel(it.id, it.title) }
return result
}
fun hide(categoryId: Long) {
launchJob(Dispatchers.Default) {
if (categoryId == NO_ID) {
settings.isAllFavouritesVisible = false
} else {
favouritesRepository.updateCategory(categoryId, isVisibleInLibrary = false)
val reverse = ReversibleHandle {
favouritesRepository.updateCategory(categoryId, isVisibleInLibrary = true)
}
onActionDone.call(ReversibleAction(R.string.category_hidden_done, reverse))
}
}
}
fun deleteCategory(categoryId: Long) {
launchJob(Dispatchers.Default) {
favouritesRepository.removeCategories(setOf(categoryId))
}
}
private fun observeAllFavouritesVisibility() = settings.observeAsFlow(
key = AppSettings.KEY_ALL_FAVOURITES_VISIBLE,
valueProducer = { isAllFavouritesVisible },
)
}

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.favourites.ui.container
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator
class FavouritesTabConfigurationStrategy(
private val adapter: FavouritesContainerAdapter,
private val viewModel: FavouritesContainerViewModel,
) : TabConfigurationStrategy {
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = adapter.getItem(position)
tab.text = item.title ?: tab.view.context.getString(R.string.all_favourites)
tab.tag = item
PopupMenuMediator(FavouriteTabPopupMenuProvider(tab.view.context, viewModel, item.id)).attach(tab.view)
}
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.favourites.ui.list
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
@@ -11,10 +10,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -29,13 +26,6 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
val categoryId
get() = viewModel.categoryId
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
if (viewModel.categoryId != NO_ID) {
addMenuProvider(FavouritesListMenuProvider(binding.root.context, viewModel))
}
}
override fun onScrolledToEnd() = Unit
override fun onFilterClick(view: View?) {

View File

@@ -1,30 +0,0 @@
package org.koitharu.kotatsu.favourites.ui.list
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
class FavouritesListMenuProvider(
private val context: Context,
private val viewModel: FavouritesListViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_favourites, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_edit -> {
context.startActivity(FavouritesCategoryEditActivity.newIntent(context, viewModel.categoryId))
true
}
else -> false
}
}
}

View File

@@ -14,7 +14,7 @@
app:tabGravity="start"
app:tabMode="scrollable" />
<org.koitharu.kotatsu.core.ui.widgets.EnhancedViewPager
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent"

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_edit"
android:title="@string/edit_category"
android:titleCondensed="@string/edit" />
<item
android:id="@+id/action_delete"
android:title="@string/delete" />
<item
android:id="@+id/action_hide"
android:title="@string/hide" />
</menu>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_hide"
android:title="@string/hide" />
</menu>

View File

@@ -558,4 +558,5 @@
<string name="default_tab">Default tab</string>
<string name="mark_as_completed">Mark as completed</string>
<string name="mark_as_completed_prompt">Mark selected manga as completely read?\n\nWarning: current reading progress will be lost.</string>
</resources>
<string name="category_hidden_done">This category was hidden from the main screen and is accessible through Menu → Manage categories</string>
</resources>