diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt index 777b0a6fc2..e48298b44d 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/AppSettings.kt @@ -179,6 +179,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true) set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) } + val usePillNavigation: Boolean + get() = prefs.getBoolean("use_pill_navigation", true) + val isTrackerEnabled: Boolean get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ColorScheme.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ColorScheme.kt index 0cf650cb84..ff335a3ce8 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ColorScheme.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/prefs/ColorScheme.kt @@ -13,6 +13,7 @@ enum class ColorScheme( @StringRes val titleResId: Int, ) { + ONYX_GOLD(R.style.ThemeOverlay_Futon_OnyxGold, R.string.theme_name_onyx_gold), DEFAULT(R.style.ThemeOverlay_Futon_Totoro, R.string.theme_name_totoro), MONET(R.style.ThemeOverlay_Futon_Monet, R.string.theme_name_dynamic), EXPRESSIVE(R.style.ThemeOverlay_Futon_Expressive, R.string.theme_name_expressive), @@ -32,7 +33,7 @@ enum class ColorScheme( get() = if (DynamicColors.isDynamicColorAvailable()) { MONET } else { - DEFAULT + ONYX_GOLD } fun getAvailableList(): List { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/widgets/HideBottomNavigationOnScrollBehavior.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/widgets/HideBottomNavigationOnScrollBehavior.kt index c39cf913f3..fffaa3b0a1 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/widgets/HideBottomNavigationOnScrollBehavior.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/widgets/HideBottomNavigationOnScrollBehavior.kt @@ -8,105 +8,104 @@ import android.view.animation.DecelerateInterpolator import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ViewCompat import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.navigation.NavigationBarView import io.github.landwarderer.futon.R import io.github.landwarderer.futon.core.util.ext.getAnimationDuration import io.github.landwarderer.futon.core.util.ext.measureHeight class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor( - context: Context? = null, - attrs: AttributeSet? = null, -) : CoordinatorLayout.Behavior(context, attrs) { + context: Context? = null, + attrs: AttributeSet? = null, +) : CoordinatorLayout.Behavior(context, attrs) { - @ViewCompat.NestedScrollType - private var lastStartedType: Int = 0 + @ViewCompat.NestedScrollType + private var lastStartedType: Int = 0 - private var offsetAnimator: ValueAnimator? = null + private var offsetAnimator: ValueAnimator? = null - private var dyRatio = 1F + private var dyRatio = 1F - var isPinned: Boolean = false - set(value) { - field = value - if (value) { - offsetAnimator?.cancel() - offsetAnimator = null - } - } + var isPinned: Boolean = false + set(value) { + field = value + if (value) { + offsetAnimator?.cancel() + offsetAnimator = null + } + } - override fun layoutDependsOn(parent: CoordinatorLayout, child: NavigationBarView, dependency: View): Boolean { - return dependency is AppBarLayout - } + override fun layoutDependsOn(parent: CoordinatorLayout, child: View, dependency: View): Boolean { + return dependency is AppBarLayout + } - override fun onDependentViewChanged( - parent: CoordinatorLayout, - child: NavigationBarView, - dependency: View, - ): Boolean { - val appBarSize = dependency.measureHeight() - dyRatio = if (appBarSize > 0) { - child.measureHeight().toFloat() / appBarSize - } else { - 1F - } - return false - } + override fun onDependentViewChanged( + parent: CoordinatorLayout, + child: View, + dependency: View, + ): Boolean { + val appBarSize = dependency.measureHeight() + dyRatio = if (appBarSize > 0) { + child.measureHeight().toFloat() / appBarSize + } else { + 1F + } + return false + } - override fun onStartNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: NavigationBarView, - directTargetChild: View, - target: View, - axes: Int, - type: Int, - ): Boolean { - if (isPinned || axes != ViewCompat.SCROLL_AXIS_VERTICAL) { - return false - } - lastStartedType = type - offsetAnimator?.cancel() - return true - } + override fun onStartNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: View, + directTargetChild: View, + target: View, + axes: Int, + type: Int, + ): Boolean { + if (isPinned || axes != ViewCompat.SCROLL_AXIS_VERTICAL) { + return false + } + lastStartedType = type + offsetAnimator?.cancel() + return true + } - override fun onNestedPreScroll( - coordinatorLayout: CoordinatorLayout, - child: NavigationBarView, - target: View, - dx: Int, - dy: Int, - consumed: IntArray, - type: Int, - ) { - super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) - if (!isPinned) { - child.translationY = (child.translationY + (dy * dyRatio)).coerceIn(0F, child.height.toFloat()) - } - } + override fun onNestedPreScroll( + coordinatorLayout: CoordinatorLayout, + child: View, + target: View, + dx: Int, + dy: Int, + consumed: IntArray, + type: Int, + ) { + super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) + if (!isPinned) { + child.translationY = (child.translationY + (dy * dyRatio)).coerceIn(0F, child.height.toFloat()) + } + } - override fun onStopNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: NavigationBarView, - target: View, - type: Int, - ) { - if (!isPinned && (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH)) { - animateBottomNavigationVisibility(child, child.translationY < child.height / 2) - } - } + override fun onStopNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: View, + target: View, + type: Int, + ) { + if (!isPinned && (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH)) { + animateBottomNavigationVisibility(child, child.translationY < child.height / 2) + } + } - private fun animateBottomNavigationVisibility(child: NavigationBarView, isVisible: Boolean) { - offsetAnimator?.cancel() - offsetAnimator = ValueAnimator().apply { - interpolator = DecelerateInterpolator() - duration = child.context.getAnimationDuration(R.integer.config_shorterAnimTime) - addUpdateListener { - child.translationY = it.animatedValue as Float - } - } - offsetAnimator?.setFloatValues( - child.translationY, - if (isVisible) 0F else child.height.toFloat(), - ) - offsetAnimator?.start() - } + private fun animateBottomNavigationVisibility(child: View, isVisible: Boolean) { + offsetAnimator?.cancel() + offsetAnimator = ValueAnimator().apply { + interpolator = DecelerateInterpolator() + duration = child.context.getAnimationDuration(R.integer.config_shorterAnimTime) + addUpdateListener { + child.translationY = it.animatedValue as Float + } + } + offsetAnimator?.setFloatValues( + child.translationY, + if (isVisible) 0F else child.height.toFloat(), + ) + offsetAnimator?.start() + } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/widgets/PillNavigationView.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/widgets/PillNavigationView.kt new file mode 100644 index 0000000000..dd2d162e44 --- /dev/null +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/widgets/PillNavigationView.kt @@ -0,0 +1,351 @@ +package io.github.landwarderer.futon.core.ui.widgets + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.animation.TimeInterpolator +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewPropertyAnimator +import android.view.ViewTreeObserver +import android.view.animation.OvershootInterpolator +import android.widget.FrameLayout +import android.widget.ImageButton +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.isVisible +import androidx.customview.view.AbsSavedState +import androidx.interpolator.view.animation.FastOutLinearInInterpolator +import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import androidx.interpolator.view.animation.LinearOutSlowInInterpolator +import com.google.android.material.bottomnavigation.BottomNavigationView +import io.github.landwarderer.futon.R +import io.github.landwarderer.futon.core.util.ext.applySystemAnimatorScale +import io.github.landwarderer.futon.core.util.ext.measureHeight +import kotlin.math.abs + +private const val STATE_DOWN = 1 +private const val STATE_UP = 2 + +private const val SLIDE_UP_ANIMATION_DURATION = 225L +private const val SLIDE_DOWN_ANIMATION_DURATION = 175L + +private const val INDICATOR_SLIDE_DURATION = 300L +private const val INDICATOR_STRETCH_PEAK = 1.4f +private const val INDICATOR_SQUASH_PEAK = 0.85f +private const val ICON_BOUNCE_SCALE = 1.2f +private const val ICON_BOUNCE_DURATION = 300L + +class PillNavigationView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr), CoordinatorLayout.AttachedBehavior { + + private var currentAnimator: ViewPropertyAnimator? = null + private var currentState = STATE_UP + private var behavior = HideBottomNavigationOnScrollBehavior() + + private val indicator: View + val bottomNav: BottomNavigationView + val fab: ImageButton + + private var lastSelectedId = -1 + private var indicatorAnimatorSet: AnimatorSet? = null + private var previousSelectedIndex = -1 + + init { + LayoutInflater.from(context).inflate(R.layout.layout_pill_navigation, this, true) + indicator = findViewById(R.id.pill_indicator) + bottomNav = findViewById(R.id.pill_bottom_nav) + fab = findViewById(R.id.pill_fab) + + bottomNav.itemActiveIndicatorColor = ColorStateList.valueOf(Color.TRANSPARENT) + bottomNav.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + val selectedId = bottomNav.selectedItemId + if (selectedId != lastSelectedId) { + if (updateIndicatorPosition(selectedId, animate = lastSelectedId != -1)) { + lastSelectedId = selectedId + } + } + return true + } + }) + + bottomNav.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (lastSelectedId != -1) { + updateIndicatorPosition(lastSelectedId, animate = false) + } + } + } + + private fun updateIndicatorPosition(itemId: Int, animate: Boolean): Boolean { + val menuView = bottomNav.getChildAt(0) as? ViewGroup ?: return false + val itemsCount = bottomNav.menu.size() + for (i in 0 until itemsCount) { + if (bottomNav.menu.getItem(i).itemId == itemId) { + val itemView = menuView.getChildAt(i) ?: return false + if (itemView.width == 0) return false + + val targetX = menuView.left + itemView.left + (itemView.width - indicator.width) / 2f + if (animate && indicator.isVisible) { + animateIndicatorTo(targetX, i, menuView) + } else { + cancelIndicatorAnimation() + indicator.translationX = targetX + indicator.scaleX = 1f + indicator.scaleY = 1f + indicator.isVisible = true + } + previousSelectedIndex = i + return true + } + } + return false + } + + private fun animateIndicatorTo(targetX: Float, targetIndex: Int, menuView: ViewGroup) { + cancelIndicatorAnimation() + + val startX = indicator.translationX + val distance = abs(targetX - startX) + val indicatorWidth = indicator.width.toFloat() + + // Scale stretch amount based on travel distance relative to indicator size + val distanceRatio = (distance / indicatorWidth).coerceIn(0f, 3f) + val stretchAmount = 1f + (INDICATOR_STRETCH_PEAK - 1f) * (distanceRatio / 3f).coerceAtLeast(0.3f) + val squashAmount = 1f - (1f - INDICATOR_SQUASH_PEAK) * (distanceRatio / 3f).coerceAtLeast(0.3f) + + // Phase 1: Stretch out and start moving + val stretchScaleX = ObjectAnimator.ofFloat(indicator, View.SCALE_X, 1f, stretchAmount).apply { + duration = INDICATOR_SLIDE_DURATION * 2 / 5 + interpolator = FastOutSlowInInterpolator() + } + val squashScaleY = ObjectAnimator.ofFloat(indicator, View.SCALE_Y, 1f, squashAmount).apply { + duration = INDICATOR_SLIDE_DURATION * 2 / 5 + interpolator = FastOutSlowInInterpolator() + } + + // Phase 2: Move to target position (full duration) + val slideX = ObjectAnimator.ofFloat(indicator, View.TRANSLATION_X, startX, targetX).apply { + duration = INDICATOR_SLIDE_DURATION + interpolator = FastOutSlowInInterpolator() + } + + // Phase 3: Snap back to normal scale with overshoot (starts at 50% of slide) + val settleScaleX = ObjectAnimator.ofFloat(indicator, View.SCALE_X, stretchAmount, 1f).apply { + duration = INDICATOR_SLIDE_DURATION * 3 / 5 + startDelay = INDICATOR_SLIDE_DURATION * 2 / 5 + interpolator = OvershootInterpolator(2f) + } + val settleScaleY = ObjectAnimator.ofFloat(indicator, View.SCALE_Y, squashAmount, 1f).apply { + duration = INDICATOR_SLIDE_DURATION * 3 / 5 + startDelay = INDICATOR_SLIDE_DURATION * 2 / 5 + interpolator = OvershootInterpolator(2f) + } + + val animatorSet = AnimatorSet() + animatorSet.playTogether(stretchScaleX, squashScaleY, slideX, settleScaleX, settleScaleY) + animatorSet.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + indicatorAnimatorSet = null + // Ensure clean state + indicator.scaleX = 1f + indicator.scaleY = 1f + } + + override fun onAnimationCancel(animation: Animator) { + indicatorAnimatorSet = null + } + }) + + indicatorAnimatorSet = animatorSet + animatorSet.start() + + // Bounce the target icon + bounceIcon(targetIndex, menuView) + } + + private fun bounceIcon(targetIndex: Int, menuView: ViewGroup) { + val targetItemView = menuView.getChildAt(targetIndex) ?: return + // Find the icon view inside the menu item (typically the first ImageView) + val iconView = findIconView(targetItemView) ?: return + + iconView.animate().cancel() + iconView.animate() + .scaleX(ICON_BOUNCE_SCALE) + .scaleY(ICON_BOUNCE_SCALE) + .setDuration(ICON_BOUNCE_DURATION / 2) + .setInterpolator(FastOutSlowInInterpolator()) + .withEndAction { + iconView.animate() + .scaleX(1f) + .scaleY(1f) + .setDuration(ICON_BOUNCE_DURATION / 2) + .setInterpolator(OvershootInterpolator(2f)) + .start() + } + .start() + } + + private fun findIconView(itemView: View): View? { + if (itemView !is ViewGroup) return null + // Navigate into the BottomNavigationItemView to find the icon + for (i in 0 until itemView.childCount) { + val child = itemView.getChildAt(i) + if (child is android.widget.ImageView) { + return child + } + // Check nested ViewGroups (icon container) + if (child is ViewGroup) { + for (j in 0 until child.childCount) { + val nested = child.getChildAt(j) + if (nested is android.widget.ImageView) { + return nested + } + } + } + } + return null + } + + private fun cancelIndicatorAnimation() { + indicatorAnimatorSet?.cancel() + indicatorAnimatorSet = null + indicator.animate().cancel() + } + + var isPinned: Boolean + get() = behavior.isPinned + set(value) { + behavior.isPinned = value + if (value) { + translationY = 0f + } + } + + val isShownOrShowing: Boolean + get() = isVisible && currentState == STATE_UP + + override fun getBehavior(): CoordinatorLayout.Behavior<*> { + return behavior + } + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + return SavedState(superState, currentState, translationY) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is SavedState) { + super.onRestoreInstanceState(state.superState) + super.setTranslationY(state.translationY) + currentState = state.currentState + } else { + super.onRestoreInstanceState(state) + } + } + + override fun setTranslationY(translationY: Float) { + if (currentState != STATE_DOWN) { + super.setTranslationY(translationY) + } + } + + fun show() { + if (currentState == STATE_UP) { + return + } + currentAnimator?.cancel() + clearAnimation() + + currentState = STATE_UP + animateTranslation( + 0F, + SLIDE_UP_ANIMATION_DURATION, + LinearOutSlowInInterpolator(), + ) + } + + fun hide() { + if (currentState == STATE_DOWN) { + return + } + currentAnimator?.cancel() + clearAnimation() + + currentState = STATE_DOWN + val target = measureHeight() + if (target == 0) { + return + } + animateTranslation( + target.toFloat(), + SLIDE_DOWN_ANIMATION_DURATION, + FastOutLinearInInterpolator(), + ) + } + + fun showOrHide(show: Boolean) { + if (show) { + show() + } else { + hide() + } + } + + private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) { + currentAnimator = animate() + .translationY(targetY) + .setInterpolator(interpolator) + .setDuration(duration) + .applySystemAnimatorScale(context) + .setListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + currentAnimator = null + postInvalidate() + } + }, + ) + } + + internal class SavedState : AbsSavedState { + + var currentState = STATE_UP + var translationY = 0F + + constructor(superState: Parcelable?, currentState: Int, translationY: Float) : super(superState!!) { + this.currentState = currentState + this.translationY = translationY + } + + constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) { + currentState = source.readInt() + translationY = source.readFloat() + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeInt(currentState) + out.writeFloat(translationY) + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + } +} + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/widgets/SlidingBottomNavigationView.kt b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/widgets/SlidingBottomNavigationView.kt index 86354800ff..00124480d6 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/widgets/SlidingBottomNavigationView.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/core/ui/widgets/SlidingBottomNavigationView.kt @@ -21,8 +21,15 @@ import com.google.android.material.bottomnavigation.BottomNavigationMenuView import com.google.android.material.navigation.NavigationBarView import io.github.landwarderer.futon.core.util.ext.applySystemAnimatorScale import io.github.landwarderer.futon.core.util.ext.measureHeight +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.widget.FrameLayout import kotlin.math.max import com.google.android.material.R as materialR +import android.content.res.ColorStateList +import android.graphics.Color +import android.view.Gravity +import io.github.landwarderer.futon.core.util.ext.resolveDp private const val STATE_DOWN = 1 private const val STATE_UP = 2 @@ -41,16 +48,77 @@ class SlidingBottomNavigationView @JvmOverloads constructor( CoordinatorLayout.AttachedBehavior { private var currentAnimator: ViewPropertyAnimator? = null - private var currentState = STATE_UP private var behavior = HideBottomNavigationOnScrollBehavior() + private val indicator = android.view.View(context).apply { + layoutParams = FrameLayout.LayoutParams( + resources.resolveDp(64), + resources.resolveDp(32) + ).apply { + gravity = Gravity.CENTER_VERTICAL or Gravity.START + } + setBackgroundResource(io.github.landwarderer.futon.R.drawable.shape_pill_indicator) + isVisible = false + } + + private var lastSelectedId = -1 + + init { + itemActiveIndicatorColor = ColorStateList.valueOf(Color.TRANSPARENT) + addView(indicator, 0) + + viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + val selectedId = selectedItemId + if (selectedId != lastSelectedId) { + if (updateIndicatorPosition(selectedId, animate = lastSelectedId != -1)) { + lastSelectedId = selectedId + } + } + return true + } + }) + + addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (lastSelectedId != -1) { + updateIndicatorPosition(lastSelectedId, animate = false) + } + } + } + + private fun updateIndicatorPosition(itemId: Int, animate: Boolean): Boolean { + val menuView = getChildAt(1) as? ViewGroup ?: return false + val itemsCount = menu.size() + for (i in 0 until itemsCount) { + if (menu.getItem(i).itemId == itemId) { + val itemView = menuView.getChildAt(i) ?: return false + if (itemView.width == 0) return false + + val targetX = menuView.left + itemView.left + (itemView.width - indicator.layoutParams.width) / 2f + if (animate && indicator.isVisible) { + indicator.animate() + .translationX(targetX) + .setInterpolator(FastOutLinearInInterpolator()) + .setDuration(225L) + .start() + } else { + indicator.animate().cancel() + indicator.translationX = targetX + indicator.isVisible = true + } + return true + } + } + return false + } + var isPinned: Boolean get() = behavior.isPinned set(value) { behavior.isPinned = value if (value) { - translationX = 0f + translationY = 0f } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreFragment.kt index 2139eb8f84..e1f6bb6d7a 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreFragment.kt @@ -77,6 +77,7 @@ class ExploreFragment : with(binding.recyclerView) { adapter = exploreAdapter setHasFixedSize(true) + setItemViewCacheSize(20) SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach() addItemDecoration(TypedListSpacingDecoration(context, false)) checkNotNull(sourceSelectionController).attachToRecyclerView(this) diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt index bc518f9fd5..0fef55ce80 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/ExploreViewModel.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapLatest @@ -76,7 +77,8 @@ class ExploreViewModel @Inject constructor( } else { createContentFlow() } - }.stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, getLoadingStateList()) + }.distinctUntilChanged() + .stateIn(viewModelScope + Dispatchers.IO, SharingStarted.Eagerly, getLoadingStateList()) init { launchJob(Dispatchers.IO) { diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/adapter/ExploreAdapterDelegates.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/adapter/ExploreAdapterDelegates.kt index 23aca97250..843bd7943d 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/adapter/ExploreAdapterDelegates.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/adapter/ExploreAdapterDelegates.kt @@ -28,6 +28,7 @@ import io.github.landwarderer.futon.list.ui.adapter.ListItemType import io.github.landwarderer.futon.list.ui.model.ListModel import io.github.landwarderer.futon.list.ui.model.MangaCompactListModel import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource fun exploreButtonsAD( clickListener: View.OnClickListener, @@ -99,12 +100,16 @@ fun exploreSourceListItemAD( AdapterDelegateClickListenerAdapter(this, listener).attach(itemView) val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small) + var boundSource: MangaSource? = null - bind { + bind { payloads -> binding.textViewTitle.text = item.source.getTitle(context) binding.textViewTitle.drawableStart = if (item.source.isPinned) iconPinned else null binding.textViewSubtitle.text = item.source.getSummary(context) - binding.imageViewIcon.setImageAsync(item.source) + if (payloads.isEmpty() || boundSource?.name != item.source.name) { + binding.imageViewIcon.setImageAsync(item.source) + boundSource = item.source.mangaSource + } } } @@ -123,8 +128,9 @@ fun exploreSourceGridItemAD( AdapterDelegateClickListenerAdapter(this, listener).attach(itemView) val iconPinned = ContextCompat.getDrawable(context, R.drawable.ic_pin_small) + var boundSource: MangaSource? = null - bind { + bind { payloads -> val title = item.source.getTitle(context) val summary = item.source.getSummary(context) itemView.setTooltipCompat( @@ -140,6 +146,9 @@ fun exploreSourceGridItemAD( ) binding.textViewTitle.text = title binding.textViewTitle.drawableStart = if (item.source.isPinned) iconPinned else null - binding.imageViewIcon.setImageAsync(item.source) + if (payloads.isEmpty() || boundSource?.name != item.source.name) { + binding.imageViewIcon.setImageAsync(item.source) + boundSource = item.source.mangaSource + } } } diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/model/MangaSourceItem.kt b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/model/MangaSourceItem.kt index b041562aa5..fabb1790ca 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/model/MangaSourceItem.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/explore/ui/model/MangaSourceItem.kt @@ -1,6 +1,7 @@ package io.github.landwarderer.futon.explore.ui.model import io.github.landwarderer.futon.core.model.MangaSourceInfo +import io.github.landwarderer.futon.list.ui.ListModelDiffCallback import io.github.landwarderer.futon.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.util.longHashCode @@ -12,6 +13,15 @@ data class MangaSourceItem( val id: Long = source.name.longHashCode() override fun areItemsTheSame(other: ListModel): Boolean { - return other is MangaSourceItem && other.source == source + return other is MangaSourceItem && other.source.name == source.name + } + + override fun getChangePayload(previousState: ListModel): Any? { + return if (previousState is MangaSourceItem && previousState.source.name == source.name) { + ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED + } else { + super.getChangePayload(previousState) + } } } + diff --git a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainActivity.kt b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainActivity.kt index f68ad31fdc..5810fa933d 100644 --- a/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/io/github/landwarderer/futon/main/ui/MainActivity.kt @@ -2,6 +2,7 @@ package io.github.landwarderer.futon.main.ui import android.Manifest import android.content.Intent +import android.content.SharedPreferences import android.content.pm.PackageManager.PERMISSION_GRANTED import android.os.Build import android.os.Bundle @@ -24,6 +25,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.withResumed +import androidx.preference.PreferenceManager import androidx.recyclerview.widget.ItemTouchHelper import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS @@ -84,7 +86,8 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav SearchSuggestionItemCallback.SuggestionItemListener, MainNavigationDelegate.OnFragmentChangedListener, View.OnLayoutChangeListener, - SearchView.TransitionListener { + SearchView.TransitionListener, + SharedPreferences.OnSharedPreferenceChangeListener { @Inject lateinit var settings: AppSettings @@ -103,10 +106,11 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav get() = viewBinding.appbar override val bottomNav: SlidingBottomNavigationView? - get() = viewBinding.bottomNav + get() = if (settings.usePillNavigation) null else viewBinding.bottomNav override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this) setContentView(ActivityMainBinding.inflate(layoutInflater)) setSupportActionBar(viewBinding.searchBar) @@ -115,8 +119,18 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav fadingAppbarMediator = FadingAppbarMediator(viewBinding.appbar, viewBinding.layoutSearch ?: viewBinding.searchBar) + if (settings.usePillNavigation) { + viewBinding.pillNav?.isVisible = true + viewBinding.bottomNav?.isVisible = false + viewBinding.fab?.isVisible = false + viewBinding.pillNav?.fab?.setOnClickListener(this) + } else { + viewBinding.pillNav?.isVisible = false + viewBinding.bottomNav?.isVisible = true + } + navigationDelegate = MainNavigationDelegate( - navBar = checkNotNull(bottomNav ?: viewBinding.navRail), + navBar = checkNotNull(if (settings.usePillNavigation) viewBinding.pillNav?.bottomNav else bottomNav ?: viewBinding.navRail), fragmentManager = supportFragmentManager, settings = settings, ) @@ -142,7 +156,11 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav onFirstStart() } - viewBinding.bottomNav?.addOnLayoutChangeListener(this) + if (settings.usePillNavigation) { + viewBinding.pillNav?.addOnLayoutChangeListener(this) + } else { + viewBinding.bottomNav?.addOnLayoutChangeListener(this) + } viewBinding.searchView.addTransitionListener(this) viewBinding.searchView.addTransitionListener(exitCallback) @@ -163,6 +181,17 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } } + override fun onDestroy() { + PreferenceManager.getDefaultSharedPreferences(this).unregisterOnSharedPreferenceChangeListener(this) + super.onDestroy() + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + if (key == "use_pill_navigation") { + recreate() + } + } + override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) adjustSearchUI(viewBinding.searchView.isShowing) @@ -186,7 +215,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav override fun onClick(v: View) { when (v.id) { - R.id.fab, R.id.railFab -> viewModel.openLastReader() + R.id.fab, R.id.railFab, R.id.pill_fab -> viewModel.openLastReader() } } @@ -202,11 +231,19 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav searchBarDefaultMargin + barsInsets.start(v) } } - viewBinding.bottomNav?.updatePadding( - left = barsInsets.left, - right = barsInsets.right, - bottom = barsInsets.bottom, - ) + if (settings.usePillNavigation) { + viewBinding.pillNav?.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + bottom = barsInsets.bottom, + ) + } else { + viewBinding.bottomNav?.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + bottom = barsInsets.bottom, + ) + } viewBinding.navRail?.updateLayoutParams { marginStart = barsInsets.start(v) topMargin = barsInsets.top @@ -253,7 +290,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav override fun onSupportActionModeStarted(mode: ActionMode) { super.onSupportActionModeStarted(mode) adjustFabVisibility() - bottomNav?.hide() + if (settings.usePillNavigation) { viewBinding.pillNav?.hide() } else { bottomNav?.hide() } (viewBinding.layoutSearch ?: viewBinding.searchBar).isInvisible = true updateContainerBottomMargin() } @@ -261,7 +298,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav override fun onSupportActionModeFinished(mode: ActionMode) { super.onSupportActionModeFinished(mode) adjustFabVisibility() - bottomNav?.show() + if (settings.usePillNavigation) { viewBinding.pillNav?.show() } else { bottomNav?.show() } (viewBinding.layoutSearch ?: viewBinding.searchBar).isInvisible = false updateContainerBottomMargin() } @@ -287,7 +324,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } private fun onLoadingStateChanged(isLoading: Boolean) { - val fab = viewBinding.fab ?: viewBinding.navRail?.headerView ?: return + val fab = if (settings.usePillNavigation) viewBinding.pillNav?.fab ?: viewBinding.navRail?.headerView ?: return else viewBinding.fab ?: viewBinding.navRail?.headerView ?: return fab.isEnabled = !isLoading } @@ -330,14 +367,26 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav isSearchOpened: Boolean = viewBinding.searchView.isShowing, ) { navigationDelegate.navRailHeader?.railFab?.isVisible = isResumeEnabled - val fab = viewBinding.fab ?: return - if (isResumeEnabled && !actionModeDelegate.isActionModeStarted && !isSearchOpened && topFragment is HistoryListFragment) { + val fab = if (settings.usePillNavigation) viewBinding.pillNav?.fab ?: return else viewBinding.fab ?: return + val isDefaultVisible = if (settings.usePillNavigation) true else topFragment is HistoryListFragment + + val shouldShow = if (settings.usePillNavigation) { + !actionModeDelegate.isActionModeStarted && !isSearchOpened + } else { + isResumeEnabled && !actionModeDelegate.isActionModeStarted && !isSearchOpened && isDefaultVisible + } + + if (shouldShow) { if (!fab.isVisible) { - fab.show() + if (fab is com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton) { fab.show() } else { fab.isVisible = true } + } + if (settings.usePillNavigation) { + fab.isEnabled = isResumeEnabled + fab.alpha = if (isResumeEnabled) 1.0f else 0.5f } } else { if (fab.isVisible) { - fab.hide() + if (fab is com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton) { fab.hide() } else { fab.isVisible = false } } } } @@ -352,7 +401,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav scrollFlags = appBarScrollFlags } adjustFabVisibility(isSearchOpened = isOpened) - bottomNav?.showOrHide(!isOpened) + if (settings.usePillNavigation) { viewBinding.pillNav?.showOrHide(!isOpened) } else { bottomNav?.showOrHide(!isOpened) } updateContainerBottomMargin() } @@ -405,8 +454,12 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } private fun setNavbarPinned(isPinned: Boolean) { - val bottomNavBar = viewBinding.bottomNav - bottomNavBar?.isPinned = isPinned + if (settings.usePillNavigation) { + viewBinding.pillNav?.isPinned = isPinned + } else { + val bottomNavBar = viewBinding.bottomNav + bottomNavBar?.isPinned = isPinned + } for (view in viewBinding.appbar.children) { val lp = view.layoutParams as? AppBarLayout.LayoutParams ?: continue val scrollFlags = if (isPinned) { @@ -423,8 +476,14 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } private fun updateContainerBottomMargin() { - val bottomNavBar = viewBinding.bottomNav ?: return - val newMargin = if (bottomNavBar.isPinned && bottomNavBar.isShownOrShowing) bottomNavBar.height else 0 + val newMargin: Int + if (settings.usePillNavigation) { + val pillNav = viewBinding.pillNav ?: return + newMargin = if (pillNav.isPinned && pillNav.isShownOrShowing) pillNav.height else 0 + } else { + val bottomNavBar = viewBinding.bottomNav ?: return + newMargin = if (bottomNavBar.isPinned && bottomNavBar.isShownOrShowing) bottomNavBar.height else 0 + } with(viewBinding.container) { val params = layoutParams as MarginLayoutParams if (params.bottomMargin != newMargin) { diff --git a/app/src/main/res/drawable/shape_circle.xml b/app/src/main/res/drawable/shape_circle.xml new file mode 100644 index 0000000000..34f2dd483a --- /dev/null +++ b/app/src/main/res/drawable/shape_circle.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/shape_pill_indicator.xml b/app/src/main/res/drawable/shape_pill_indicator.xml new file mode 100644 index 0000000000..c16e93621e --- /dev/null +++ b/app/src/main/res/drawable/shape_pill_indicator.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout-w600dp-land/activity_main.xml b/app/src/main/res/layout-w600dp-land/activity_main.xml index 6fd6364973..9dfddc0778 100644 --- a/app/src/main/res/layout-w600dp-land/activity_main.xml +++ b/app/src/main/res/layout-w600dp-land/activity_main.xml @@ -103,6 +103,14 @@ - + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 3f25679b4f..d380c91b29 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -86,4 +86,12 @@ android:layout_gravity="bottom" android:fitsSystemWindows="false" /> + + diff --git a/app/src/main/res/layout/layout_pill_navigation.xml b/app/src/main/res/layout/layout_pill_navigation.xml new file mode 100644 index 0000000000..7f6bad80ce --- /dev/null +++ b/app/src/main/res/layout/layout_pill_navigation.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors_themed.xml b/app/src/main/res/values/colors_themed.xml index 2d99fb48f2..5ffd8ae6c1 100644 --- a/app/src/main/res/values/colors_themed.xml +++ b/app/src/main/res/values/colors_themed.xml @@ -432,4 +432,52 @@ #FAEAED #F5E4E7 #EFDFE1 + + #ffcc92 + #472a00 + #fea628 + #6a4000 + #f8b993 + #4c260b + #673c1f + #e4a883 + #d4dd58 + #303300 + #b8c13e + #494d00 + #ffb4ab + #690005 + #93000a + #ffdad6 + #131313 + #e5e2e1 + #131313 + #e5e2e1 + #353534 + #d8c3ae + #a08d7a + #534434 + #000000 + #e5e2e1 + #313030 + #865300 + #ffddb9 + #2b1700 + #ffb963 + #663e00 + #ffdbc8 + #321300 + #f8b993 + #673c1f + #e2eb64 + #1b1d00 + #c5ce4b + #464a00 + #131313 + #3a3939 + #0e0e0e + #1c1b1b + #201f1f + #2a2a2a + #353534 diff --git a/app/src/main/res/values/dimens_pill.xml b/app/src/main/res/values/dimens_pill.xml new file mode 100644 index 0000000000..1c84dc69ed --- /dev/null +++ b/app/src/main/res/values/dimens_pill.xml @@ -0,0 +1,4 @@ + + + 16dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 13701a44ce..d4fe610486 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -871,10 +871,13 @@ Allows you to keep multiple readers with different manga open at the same time Itsuka Totoro + Onyx Gold Yellowish background (blue filter) Local storage cleanup Failed to create backup Main screen + Use pill navigation bar + Use the new pill-shaped bottom navigation bar instead of the default one. Show floating Continue button Allows to continue reading in a one click. This button will not appear in incognito mode or when the history is empty Corrupted ZIP archive (%s) diff --git a/app/src/main/res/values/themes_colored.xml b/app/src/main/res/values/themes_colored.xml index e8903ff533..44832aac43 100644 --- a/app/src/main/res/values/themes_colored.xml +++ b/app/src/main/res/values/themes_colored.xml @@ -442,4 +442,53 @@ @color/sakura_surfaceContainerHigh @color/sakura_surfaceContainerHighest + + diff --git a/app/src/main/res/xml/pref_appearance.xml b/app/src/main/res/xml/pref_appearance.xml index 32f4aa844f..ab3a63b268 100644 --- a/app/src/main/res/xml/pref_appearance.xml +++ b/app/src/main/res/xml/pref_appearance.xml @@ -122,6 +122,11 @@ android:title="@string/exit_confirmation" /> + android:defaultValue="true" android:key="dynamic_shortcuts" android:summary="@string/history_shortcuts_summary"