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() { private fun initPager() {
viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false
val adapter = DetailsPagerAdapter(this) val adapter = DetailsPagerAdapter(this)
viewBinding.pager.offscreenPageLimit = 1
viewBinding.pager.adapter = adapter viewBinding.pager.adapter = adapter
TabLayoutMediator(viewBinding.tabs, viewBinding.pager, adapter).attach() TabLayoutMediator(viewBinding.tabs, viewBinding.pager, adapter).attach()
viewBinding.pager.setCurrentItem(settings.defaultDetailsTab, false) viewBinding.pager.setCurrentItem(settings.defaultDetailsTab, false)

View File

@@ -4,7 +4,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
data class FavouriteTabModel( data class FavouriteTabModel(
val id: Long, val id: Long,
val title: String, val title: String?,
) : ListModel { ) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean { 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 package org.koitharu.kotatsu.favourites.ui.container
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.AdapterListUpdateCallback
import androidx.fragment.app.FragmentStatePagerAdapter 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 kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment 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(fragment: Fragment) : FragmentStateAdapter(fragment),
class FavouritesContainerAdapter(
fm: FragmentManager
) : FragmentStatePagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT),
FlowCollector<List<FavouriteTabModel>> { 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 { override fun getItemId(position: Int): Long {
val item = dataSet[position] 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) return FavouritesListFragment.newInstance(item.id)
} }
override fun getPageTitle(position: Int): CharSequence { override suspend fun emit(value: List<FavouriteTabModel>) = suspendCoroutine { cont ->
return dataSet[position].title differ.submitList(value, ContinuationResumeRunnable(cont))
} }
override suspend fun emit(value: List<FavouriteTabModel>) { fun getItem(position: Int): FavouriteTabModel = differ.currentList[position]
dataSet.replaceWith(value)
notifyDataSetChanged()
}
} }

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

View File

@@ -4,30 +4,80 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus 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.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.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class FavouritesContainerViewModel @Inject constructor( class FavouritesContainerViewModel @Inject constructor(
favouritesRepository: FavouritesRepository, private val settings: AppSettings,
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() { ) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>()
private val categoriesStateFlow = favouritesRepository.observeCategoriesForLibrary() private val categoriesStateFlow = favouritesRepository.observeCategoriesForLibrary()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val categories = categoriesStateFlow.filterNotNull() val categories = combine(
.mapItems { FavouriteTabModel(it.id, it.title) } categoriesStateFlow.filterNotNull(),
.distinctUntilChanged() observeAllFavouritesVisibility(),
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) ) { list, showAll ->
list.toUi(showAll)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val isEmpty = categoriesStateFlow.map { val isEmpty = categoriesStateFlow.map {
it?.isEmpty() == true it?.isEmpty() == true
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) }.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 package org.koitharu.kotatsu.favourites.ui.list
import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
@@ -11,10 +10,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.ListSelectionController 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.sortedByOrdinal
import org.koitharu.kotatsu.core.util.ext.withArgs 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.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -29,13 +26,6 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
val categoryId val categoryId
get() = viewModel.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 onScrolledToEnd() = Unit
override fun onFilterClick(view: View?) { 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:tabGravity="start"
app:tabMode="scrollable" /> app:tabMode="scrollable" />
<org.koitharu.kotatsu.core.ui.widgets.EnhancedViewPager <androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager" android:id="@+id/pager"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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="default_tab">Default tab</string>
<string name="mark_as_completed">Mark as completed</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> <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>