Files
michas-droid/src/main/kotlin/nya/kitsunyan/foxydroid/screen/TabsFragment.kt
T
2026-03-05 18:25:52 +01:00

642 lines
25 KiB
Kotlin

package nya.kitsunyan.foxydroid.screen
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.SearchView
import android.widget.TextView
import android.widget.Toolbar
import androidx.core.os.BundleCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import nya.kitsunyan.foxydroid.R
import nya.kitsunyan.foxydroid.content.Preferences
import nya.kitsunyan.foxydroid.database.Database
import nya.kitsunyan.foxydroid.entity.ProductItem
import nya.kitsunyan.foxydroid.service.Connection
import nya.kitsunyan.foxydroid.service.SyncService
import nya.kitsunyan.foxydroid.utility.RxUtils
import nya.kitsunyan.foxydroid.utility.Utils
import nya.kitsunyan.foxydroid.utility.extension.android.*
import nya.kitsunyan.foxydroid.utility.extension.resources.*
import nya.kitsunyan.foxydroid.widget.DividerItemDecoration
import nya.kitsunyan.foxydroid.widget.FocusSearchView
import nya.kitsunyan.foxydroid.widget.StableRecyclerAdapter
import kotlin.math.*
class TabsFragment: ScreenFragment() {
companion object {
private const val STATE_SEARCH_FOCUSED = "searchFocused"
private const val STATE_SEARCH_QUERY = "searchQuery"
private const val STATE_SHOW_SECTIONS = "showSections"
private const val STATE_SECTIONS = "sections"
private const val STATE_SECTION = "section"
}
private class Layout(view: View) {
val tabs = view.findViewById<LinearLayout>(R.id.tabs)!!
val sectionLayout = view.findViewById<ViewGroup>(R.id.section_layout)!!
val sectionChange = view.findViewById<View>(R.id.section_change)!!
val sectionName = view.findViewById<TextView>(R.id.section_name)!!
val sectionIcon = view.findViewById<ImageView>(R.id.section_icon)!!
}
private var searchMenuItem: MenuItem? = null
private var sortOrderMenu: Pair<MenuItem, List<MenuItem>>? = null
private var syncRepositoriesMenuItem: MenuItem? = null
private var layout: Layout? = null
private var sectionsList: RecyclerView? = null
private var viewPager: ViewPager2? = null
private var showSections = false
set(value) {
if (field != value) {
field = value
val layout = layout
layout?.tabs?.let { tabs -> (0 until tabs.childCount)
.forEach { index -> tabs.getChildAt(index)!!.isEnabled = !value } }
layout?.sectionIcon?.scaleY = if (value) -1f else 1f
if (((sectionsList?.parent as? View)?.height ?: 0) > 0) {
animateSectionsList()
}
}
}
private var searchQuery = ""
private var sections = listOf<ProductItem.Section>(ProductItem.Section.All)
private var section: ProductItem.Section = ProductItem.Section.All
private val syncConnection = Connection(SyncService::class.java, onBind = { _, _ ->
viewPager?.let { pager ->
val source = ProductsFragment.Source.entries[pager.currentItem]
updateUpdateNotificationBlocker(source)
}
})
private var sortOrderDisposable: Disposable? = null
private var categoriesDisposable: Disposable? = null
private var repositoriesDisposable: Disposable? = null
private var sectionsAnimator: ValueAnimator? = null
private var needSelectUpdates = false
private val productFragments: Sequence<ProductsFragment>
get() = if (host == null) emptySequence() else
childFragmentManager.fragments.asSequence().mapNotNull { it as? ProductsFragment }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
syncConnection.bind(requireContext())
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)!!
screenActivity.onToolbarCreated(toolbar)
toolbar.setTitle(R.string.application_name)
// Move focus from SearchView to Toolbar
toolbar.isFocusableInTouchMode = true
val searchView = FocusSearchView(toolbar.context)
searchView.allowFocus = savedInstanceState?.getBoolean(STATE_SEARCH_FOCUSED) == true
searchView.maxWidth = Int.MAX_VALUE
searchView.queryHint = getString(R.string.search)
searchView.setOnQueryTextListener(object: SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
searchView.clearFocus()
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
if (isResumed) {
searchQuery = newText.orEmpty()
productFragments.forEach { it.setSearchQuery(newText.orEmpty()) }
}
return true
}
})
toolbar.menu.apply {
if (Android.sdk(28) && !Android.Device.isHuaweiEmui) {
setGroupDividerEnabled(true)
}
searchMenuItem = add(0, R.id.toolbar_search, 0, R.string.search)
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_search))
.setActionView(searchView)
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW)
sortOrderMenu = addSubMenu(0, 0, 0, R.string.sorting_order)
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_sort))
.let { menu ->
menu.item.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
val items = Preferences.Key.SortOrder.default.value.values
.map { sortOrder -> menu
.add(sortOrder.order.titleResId)
.setOnMenuItemClickListener {
Preferences[Preferences.Key.SortOrder] = sortOrder
true
} }
menu.setGroupCheckable(0, true, true)
Pair(menu.item, items)
}
syncRepositoriesMenuItem = add(0, 0, 0, R.string.sync_repositories)
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_sync))
.setOnMenuItemClickListener {
syncConnection.binder?.sync(SyncService.SyncRequest.MANUAL)
true
}
add(1, 0, 0, R.string.repositories)
.setOnMenuItemClickListener {
view.post { screenActivity.navigateRepositories() }
true
}
add(1, 0, 0, R.string.preferences)
.setOnMenuItemClickListener {
view.post { screenActivity.navigatePreferences() }
true
}
}
searchQuery = savedInstanceState?.getString(STATE_SEARCH_QUERY).orEmpty()
productFragments.forEach { it.setSearchQuery(searchQuery) }
val toolbarExtra = view.findViewById<FrameLayout>(R.id.toolbar_extra)!!
toolbarExtra.addView(toolbarExtra.inflate(R.layout.tabs_toolbar))
val layout = Layout(view)
this.layout = layout
layout.tabs.background = TabsBackgroundDrawable(layout.tabs.context,
layout.tabs.layoutDirection == View.LAYOUT_DIRECTION_RTL)
ProductsFragment.Source.entries.forEach { source ->
val tab = TextView(layout.tabs.context)
val selectedColor = tab.context.getColorFromAttr(android.R.attr.textColorPrimary).defaultColor
val normalColor = tab.context.getColorFromAttr(android.R.attr.textColorSecondary).defaultColor
tab.gravity = Gravity.CENTER
tab.typeface = TypefaceExtra.medium
tab.setTextColor(ColorStateList(arrayOf(intArrayOf(android.R.attr.state_selected), intArrayOf()),
intArrayOf(selectedColor, normalColor)))
tab.setTextSizeScaled(14)
tab.isAllCaps = true
tab.text = getString(source.titleResId)
tab.background = tab.context.getDrawableFromAttr(android.R.attr.selectableItemBackground)
tab.setOnClickListener {
setSelectedTab(source)
viewPager!!.setCurrentItem(source.ordinal, Utils.areAnimationsEnabled(tab.context))
}
layout.tabs.addView(tab, 0, LinearLayout.LayoutParams.MATCH_PARENT)
(tab.layoutParams as LinearLayout.LayoutParams).weight = 1f
}
showSections = (savedInstanceState?.getByte(STATE_SHOW_SECTIONS)?.toInt() ?: 0) != 0
sections = savedInstanceState?.let { BundleCompat.getParcelableArrayList(it, STATE_SECTIONS, ProductItem.Section::class.java) }.orEmpty()
section = savedInstanceState?.let { BundleCompat.getParcelable(it, STATE_SECTION, ProductItem.Section::class.java) } ?: ProductItem.Section.All
layout.sectionChange.setOnClickListener { showSections = sections
.any { it !is ProductItem.Section.All } && !showSections }
updateOrder()
sortOrderDisposable = Preferences.observable.subscribe { key ->
if (key == Preferences.Key.SortOrder) {
updateOrder()
}
}
val content = view.findViewById<FrameLayout>(R.id.fragment_content)!!
viewPager = ViewPager2(content.context).apply {
id = R.id.fragment_pager
adapter = object: FragmentStateAdapter(this@TabsFragment) {
override fun getItemCount(): Int = ProductsFragment.Source.entries.size
override fun createFragment(position: Int): Fragment = ProductsFragment(ProductsFragment.Source.entries[position])
}
content.addView(this, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
registerOnPageChangeCallback(pageChangeCallback)
offscreenPageLimit = 1
}
categoriesDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Products))
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { signal -> Database.CategoryAdapter.getAll(signal) } }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result -> setSectionsAndUpdate(result.asSequence().sorted()
.map(ProductItem.Section::Category).toList(), null) }
repositoriesDisposable = Observable.just(Unit)
.concatWith(Database.observable(Database.Subject.Repositories))
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { signal -> Database.RepositoryAdapter.getAll(signal) } }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result ->
setSectionsAndUpdate(null, result.asSequence().filter { it.enabled }
.map { ProductItem.Section.Repository(it.id, it.name) }.toList()) }
updateSection()
val sectionsList = RecyclerView(toolbar.context).apply {
id = R.id.sections_list
layoutManager = LinearLayoutManager(context)
isMotionEventSplittingEnabled = false
isVerticalScrollBarEnabled = false
setHasFixedSize(true)
val adapter = SectionsAdapter({ sections }) { newSection ->
if (showSections) {
showSections = false
section = newSection
updateSection()
}
}
this.adapter = adapter
addItemDecoration(DividerItemDecoration(context, adapter::configureDivider))
setBackgroundColor(context.getColorFromAttr(android.R.attr.colorPrimaryDark).defaultColor)
elevation = resources.sizeScaled(4).toFloat()
content.addView(this, FrameLayout.LayoutParams.MATCH_PARENT, 0)
visibility = View.GONE
}
this.sectionsList = sectionsList
val lastContentHeight = -1
content.viewTreeObserver.addOnGlobalLayoutListener {
if (this.view != null) {
val initial = true
val contentHeight = content.height
if (lastContentHeight != contentHeight) {
if (initial) {
sectionsList.layoutParams.height = if (showSections) contentHeight else 0
sectionsList.visibility = if (showSections) View.VISIBLE else View.GONE
sectionsList.requestLayout()
} else {
animateSectionsList()
}
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
searchMenuItem = null
sortOrderMenu = null
syncRepositoriesMenuItem = null
layout = null
sectionsList = null
viewPager = null
syncConnection.unbind(requireContext())
sortOrderDisposable?.dispose()
sortOrderDisposable = null
categoriesDisposable?.dispose()
categoriesDisposable = null
repositoriesDisposable?.dispose()
repositoriesDisposable = null
sectionsAnimator?.cancel()
sectionsAnimator = null
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(STATE_SEARCH_FOCUSED, searchMenuItem?.actionView?.hasFocus() == true)
outState.putString(STATE_SEARCH_QUERY, searchQuery)
outState.putByte(STATE_SHOW_SECTIONS, if (showSections) 1 else 0)
outState.putParcelableArrayList(STATE_SECTIONS, ArrayList(sections))
outState.putParcelable(STATE_SECTION, section)
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
(searchMenuItem?.actionView as FocusSearchView).allowFocus = true
if (needSelectUpdates) {
needSelectUpdates = false
selectUpdatesInternal(false)
}
}
@Deprecated("Deprecated in Java")
override fun onAttachFragment(childFragment: Fragment) {
@Suppress("DEPRECATION")
super.onAttachFragment(childFragment)
if (view != null && childFragment is ProductsFragment) {
childFragment.setSearchQuery(searchQuery)
childFragment.setSection(section)
childFragment.setOrder(Preferences[Preferences.Key.SortOrder].order)
}
}
override fun onBackPressed(): Boolean {
return when {
searchMenuItem?.isActionViewExpanded == true -> {
searchMenuItem?.collapseActionView()
true
}
showSections -> {
showSections = false
true
}
else -> {
super.onBackPressed()
}
}
}
private fun setSelectedTab(source: ProductsFragment.Source) {
val layout = layout!!
(0 until layout.tabs.childCount).forEach { index -> layout.tabs.getChildAt(index).isSelected = index == source.ordinal }
}
internal fun selectUpdates() = selectUpdatesInternal(true)
private fun selectUpdatesInternal(allowSmooth: Boolean) {
if (view != null) {
val viewPager = viewPager
viewPager?.setCurrentItem(ProductsFragment.Source.UPDATES.ordinal, allowSmooth && viewPager.isLaidOut)
} else {
needSelectUpdates = true
}
}
private fun updateUpdateNotificationBlocker(activeSource: ProductsFragment.Source) {
val blockerFragment = if (activeSource == ProductsFragment.Source.UPDATES) {
productFragments.find { it.source == activeSource }
} else {
null
}
syncConnection.binder?.setUpdateNotificationBlocker(blockerFragment)
}
private fun updateOrder() {
val order = Preferences[Preferences.Key.SortOrder].order
sortOrderMenu!!.second[order.ordinal].isChecked = true
productFragments.forEach { it.setOrder(order) }
}
private inline fun <reified T: ProductItem.Section> collectOldSections(list: List<T>?): List<T>? {
val oldList = sections.mapNotNull { it as? T }
return if (list == null || oldList == list) oldList else null
}
private fun setSectionsAndUpdate(categories: List<ProductItem.Section.Category>?,
repositories: List<ProductItem.Section.Repository>?) {
val oldCategories = collectOldSections(categories)
val oldRepositories = collectOldSections(repositories)
if (oldCategories == null || oldRepositories == null) {
val oldSections = sections
sections = listOf(ProductItem.Section.All) +
(categories ?: oldCategories).orEmpty() +
(repositories ?: oldRepositories).orEmpty()
val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldSections.size
override fun getNewListSize(): Int = sections.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
oldSections[oldItemPosition] == sections[newItemPosition]
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
oldSections[oldItemPosition] == sections[newItemPosition]
})
updateSection()
sectionsList?.adapter?.let { adapter -> diffResult.dispatchUpdatesTo(adapter) }
}
}
private fun updateSection() {
if (section !in sections) {
section = ProductItem.Section.All
}
layout?.sectionName?.text = when (val s = section) {
is ProductItem.Section.All -> getString(R.string.all_applications)
is ProductItem.Section.Category -> s.name
is ProductItem.Section.Repository -> s.name
}
layout?.sectionIcon?.visibility = if (sections.any { it !is ProductItem.Section.All }) View.VISIBLE else View.GONE
productFragments.forEach { it.setSection(section) }
sectionsList?.adapter?.let { adapter ->
val index = sections.indexOf(section)
if (index >= 0) {
adapter.notifyItemRangeChanged(0, sections.size)
}
}
}
private fun animateSectionsList() {
val sectionsList = sectionsList!!
val value = if (sectionsList.visibility != View.VISIBLE) 0f else
sectionsList.height.toFloat() / (sectionsList.parent as View).height
val target = if (showSections) 1f else 0f
sectionsAnimator?.cancel()
sectionsAnimator = null
if (value != target) {
sectionsAnimator = ValueAnimator.ofFloat(value, target).apply {
duration = (250 * abs(target - value)).toLong()
interpolator = if (target >= 1f) AccelerateInterpolator(2f) else DecelerateInterpolator(2f)
addUpdateListener { animator ->
val newValue = animator.animatedValue as Float
sectionsList.apply {
val h = ((parent as View).height * newValue).toInt()
val visible = h > 0
if ((isVisible) != visible) {
visibility = if (visible) View.VISIBLE else View.GONE
}
if (layoutParams.height != h) {
layoutParams.height = h
requestLayout()
}
}
if (target <= 0f && newValue <= 0f || target >= 1f && newValue >= 1f) {
sectionsAnimator = null
}
}
start()
}
}
}
private val pageChangeCallback = object: ViewPager2.OnPageChangeCallback() {
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
val l = layout!!
val fromSections = ProductsFragment.Source.entries[position].sections
val toSections = if (positionOffset <= 0f) fromSections else
ProductsFragment.Source.entries[position + 1].sections
val offset = if (fromSections != toSections) {
if (fromSections) 1f - positionOffset else positionOffset
} else {
if (fromSections) 1f else 0f
}
(l.tabs.background as TabsBackgroundDrawable)
.update(position + positionOffset, l.tabs.childCount)
assert(l.sectionLayout.childCount == 1)
val child = l.sectionLayout.getChildAt(0)
val h = child.layoutParams.height
assert(h > 0)
val currentHeight = (offset * h).roundToInt()
if (l.sectionLayout.layoutParams.height != currentHeight) {
l.sectionLayout.layoutParams.height = currentHeight
l.sectionLayout.requestLayout()
}
}
override fun onPageSelected(position: Int) {
val source = ProductsFragment.Source.entries[position]
updateUpdateNotificationBlocker(source)
sortOrderMenu!!.first.isVisible = source.order
syncRepositoriesMenuItem!!.setShowAsActionFlags(if (!source.order ||
resources.configuration.screenWidthDp >= 400) MenuItem.SHOW_AS_ACTION_ALWAYS else 0)
setSelectedTab(source)
if (showSections && !source.sections) {
showSections = false
}
}
override fun onPageScrollStateChanged(state: Int) {
val source = ProductsFragment.Source.entries[viewPager!!.currentItem]
layout!!.sectionChange.isEnabled = state != ViewPager2.SCROLL_STATE_DRAGGING && source.sections
if (state == ViewPager2.SCROLL_STATE_IDLE) {
// onPageSelected can be called earlier than fragments created
updateUpdateNotificationBlocker(source)
}
}
}
private class TabsBackgroundDrawable(context: Context, private val rtl: Boolean): Drawable() {
private val h = context.resources.sizeScaled(2)
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = context.getColorFromAttr(android.R.attr.colorAccent).defaultColor
}
private var position = 0f
private var total = 0
fun update(position: Float, total: Int) {
this.position = position
this.total = total
invalidateSelf()
}
override fun draw(canvas: Canvas) {
if (total > 0) {
val b = bounds
val w = b.width() / total.toFloat()
val x = w * position
if (rtl) {
canvas.drawRect(b.right - w - x, (b.bottom - h).toFloat(),
b.right - x, b.bottom.toFloat(), paint)
} else {
canvas.drawRect(b.left + x, (b.bottom - h).toFloat(),
b.left + x + w, b.bottom.toFloat(), paint)
}
}
}
override fun setAlpha(alpha: Int) = Unit
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}
private class SectionsAdapter(private val sections: () -> List<ProductItem.Section>,
private val onClick: (ProductItem.Section) -> Unit): StableRecyclerAdapter<SectionsAdapter.ViewType,
RecyclerView.ViewHolder>() {
enum class ViewType { SECTION }
private class SectionViewHolder(context: Context): RecyclerView.ViewHolder(TextView(context)) {
val title: TextView
get() = itemView as TextView
init {
itemView as TextView
(itemView as TextView).gravity = Gravity.CENTER_VERTICAL
itemView.resources.sizeScaled(16).let { itemView.setPadding(it, 0, it, 0) }
(itemView as TextView).setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
(itemView as TextView).setTextSizeScaled(16)
itemView.background = context.getDrawableFromAttr(android.R.attr.selectableItemBackground)
itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
itemView.resources.sizeScaled(48))
}
}
fun configureDivider(context: Context, position: Int, configuration: DividerItemDecoration.Configuration) {
val currentSection = sections()[position]
val nextSection = sections().getOrNull(position + 1)
when {
nextSection != null && currentSection.javaClass != nextSection.javaClass -> {
val padding = context.resources.sizeScaled(16)
configuration.set(
needDivider = true,
toTop = false,
paddingStart = padding,
paddingEnd = padding
)
}
else -> {
configuration.set(needDivider = false, toTop = false, paddingStart = 0, paddingEnd = 0)
}
}
}
override val viewTypeClass: Class<ViewType>
get() = ViewType::class.java
override fun getItemCount(): Int = sections().size
override fun getItemDescriptor(position: Int): String = sections()[position].toString()
override fun getItemEnumViewType(position: Int): ViewType = ViewType.SECTION
override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder {
return SectionViewHolder(parent.context).apply {
itemView.setOnClickListener {
val pos = bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
onClick(sections()[pos])
}
}
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
holder as SectionViewHolder
val section = sections()[position]
val previousSection = sections().getOrNull(position - 1)
val nextSection = sections().getOrNull(position + 1)
val margin = holder.itemView.resources.sizeScaled(8)
val layoutParams = holder.itemView.layoutParams as RecyclerView.LayoutParams
layoutParams.topMargin = if (previousSection == null ||
section.javaClass != previousSection.javaClass) margin else 0
layoutParams.bottomMargin = if (nextSection == null ||
section.javaClass != nextSection.javaClass) margin else 0
holder.title.text = when (section) {
is ProductItem.Section.All -> holder.itemView.resources.getString(R.string.all_applications)
is ProductItem.Section.Category -> section.name
is ProductItem.Section.Repository -> section.name
}
}
}
}