diff --git a/android/app/src/main/java/com/noop/ui/AppRoot.kt b/android/app/src/main/java/com/noop/ui/AppRoot.kt index f16e99fcf9..3f50d788ed 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -118,6 +118,37 @@ enum class BottomBarBehavior(val storageValue: String, val label: String) { } } +/** + * Whether the bar should be treated as collapsed for the given behavior + live reactive-scroll flag. + * COMPACT is always collapsed; EXPANDED never; REACTIVE follows the scroll flag. Pure so the + * bottom-bar state machine (#86) is testable without Compose — see [BottomBarBehaviorTest]. + */ +internal fun isBarEffectivelyCollapsed(behavior: BottomBarBehavior, reactiveCollapsed: Boolean): Boolean = + behavior == BottomBarBehavior.COMPACT || + (behavior == BottomBarBehavior.REACTIVE && reactiveCollapsed) + +/** + * Resolve the 0..1 bar-collapse fraction from behavior, accessibility state and the two animation + * inputs. `settled` is the already-eased target (the `animateFloatAsState` output); `precompression` + * is the scroll-linked pre-settle drift. Reduce Motion snaps to 0/1; a non-reactive mode rides the + * settle only; REACTIVE-while-collapsed takes `max(precompression, settled)` so the scroll-linked + * drift never regresses below the eased settle, and REACTIVE-while-expanding follows precompression + * until it drops to 0. Pure — mirrors the inline composition in [AppRoot]; see [BottomBarBehaviorTest]. + */ +internal fun resolveBarCollapse( + behavior: BottomBarBehavior, + reduceMotion: Boolean, + reactiveCollapsed: Boolean, + precompression: Float, + settled: Float, +): Float = when { + reduceMotion -> if (isBarEffectivelyCollapsed(behavior, reactiveCollapsed)) 1f else 0f + behavior != BottomBarBehavior.REACTIVE -> settled + reactiveCollapsed -> maxOf(precompression, settled) + precompression > 0f -> precompression + else -> settled +} + /** Persisted bottom-navigation behavior, mirrored in snapshot state so Settings updates the shell live. */ object BottomBarPrefs { const val KEY = "noop.bottomBarBehavior" @@ -356,8 +387,7 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { barPrecompression = 0f barCollapsed = false } - val effectiveBarCollapsed = bottomBarBehavior == BottomBarBehavior.COMPACT || - (bottomBarBehavior == BottomBarBehavior.REACTIVE && barCollapsed) + val effectiveBarCollapsed = isBarEffectivelyCollapsed(bottomBarBehavior, barCollapsed) val barSettledCollapse by animateFloatAsState( targetValue = if (effectiveBarCollapsed) 1f else 0f, animationSpec = if (reduceMotion) { @@ -369,17 +399,13 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { }, label = "barSettledCollapse", ) - val barCollapse = if (reduceMotion) { - if (effectiveBarCollapsed) 1f else 0f - } else if (bottomBarBehavior != BottomBarBehavior.REACTIVE) { - barSettledCollapse - } else if (barCollapsed) { - maxOf(barPrecompression, barSettledCollapse) - } else if (barPrecompression > 0f) { - barPrecompression - } else { - barSettledCollapse - } + val barCollapse = resolveBarCollapse( + behavior = bottomBarBehavior, + reduceMotion = reduceMotion, + reactiveCollapsed = barCollapsed, + precompression = barPrecompression, + settled = barSettledCollapse, + ) run { Scaffold( diff --git a/android/app/src/test/java/com/noop/ui/BottomBarBehaviorTest.kt b/android/app/src/test/java/com/noop/ui/BottomBarBehaviorTest.kt new file mode 100644 index 0000000000..d322e6f578 --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/BottomBarBehaviorTest.kt @@ -0,0 +1,94 @@ +package com.noop.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure-logic coverage for the scroll-reactive bottom-bar behavior (#86): storage-string resolution, + * the COMPACT / EXPANDED / REACTIVE collapse decision, and the collapse-fraction composition that + * blends scroll-linked pre-compression with the eased settle. No Compose / Robolectric — these are + * the pure functions extracted from AppRoot so the state machine is testable without a device. + */ +class BottomBarBehaviorTest { + + private val eps = 1e-6f + + @Test + fun fromStorage_resolvesKnownValues() { + assertEquals(BottomBarBehavior.REACTIVE, BottomBarBehavior.fromStorage("reactive")) + assertEquals(BottomBarBehavior.EXPANDED, BottomBarBehavior.fromStorage("expanded")) + assertEquals(BottomBarBehavior.COMPACT, BottomBarBehavior.fromStorage("compact")) + } + + @Test + fun fromStorage_defaultsToReactiveForNullOrUnknown() { + assertEquals(BottomBarBehavior.REACTIVE, BottomBarBehavior.fromStorage(null)) + assertEquals(BottomBarBehavior.REACTIVE, BottomBarBehavior.fromStorage("")) + assertEquals(BottomBarBehavior.REACTIVE, BottomBarBehavior.fromStorage("nonsense")) + } + + @Test + fun storageValue_roundTripsThroughFromStorage() { + for (b in BottomBarBehavior.entries) { + assertEquals(b, BottomBarBehavior.fromStorage(b.storageValue)) + } + } + + @Test + fun effectivelyCollapsed_compactAlways_expandedNever() { + assertTrue(isBarEffectivelyCollapsed(BottomBarBehavior.COMPACT, reactiveCollapsed = false)) + assertTrue(isBarEffectivelyCollapsed(BottomBarBehavior.COMPACT, reactiveCollapsed = true)) + assertFalse(isBarEffectivelyCollapsed(BottomBarBehavior.EXPANDED, reactiveCollapsed = false)) + assertFalse(isBarEffectivelyCollapsed(BottomBarBehavior.EXPANDED, reactiveCollapsed = true)) + } + + @Test + fun effectivelyCollapsed_reactiveFollowsScrollFlag() { + assertFalse(isBarEffectivelyCollapsed(BottomBarBehavior.REACTIVE, reactiveCollapsed = false)) + assertTrue(isBarEffectivelyCollapsed(BottomBarBehavior.REACTIVE, reactiveCollapsed = true)) + } + + @Test + fun resolveBarCollapse_reduceMotionSnapsToEndpoints() { + // Reduce Motion ignores precompression/settled and snaps to 0 or 1 by the effective state. + assertEquals(1f, resolveBarCollapse(BottomBarBehavior.COMPACT, reduceMotion = true, + reactiveCollapsed = false, precompression = 0.1f, settled = 0.3f), eps) + assertEquals(0f, resolveBarCollapse(BottomBarBehavior.EXPANDED, reduceMotion = true, + reactiveCollapsed = true, precompression = 0.1f, settled = 0.3f), eps) + assertEquals(1f, resolveBarCollapse(BottomBarBehavior.REACTIVE, reduceMotion = true, + reactiveCollapsed = true, precompression = 0f, settled = 0f), eps) + assertEquals(0f, resolveBarCollapse(BottomBarBehavior.REACTIVE, reduceMotion = true, + reactiveCollapsed = false, precompression = 0.1f, settled = 0.9f), eps) + } + + @Test + fun resolveBarCollapse_nonReactiveUsesSettledValue() { + // EXPANDED/COMPACT ride the eased settle only — no scroll-linked precompression. + assertEquals(0.42f, resolveBarCollapse(BottomBarBehavior.EXPANDED, reduceMotion = false, + reactiveCollapsed = false, precompression = 0.9f, settled = 0.42f), eps) + assertEquals(0.42f, resolveBarCollapse(BottomBarBehavior.COMPACT, reduceMotion = false, + reactiveCollapsed = true, precompression = 0.9f, settled = 0.42f), eps) + } + + @Test + fun resolveBarCollapse_reactiveCollapsedTakesMaxOfPrecompressionAndSettle() { + // Once collapsed, the fraction never dips below the eased settle even if precompression lags. + assertEquals(0.5f, resolveBarCollapse(BottomBarBehavior.REACTIVE, reduceMotion = false, + reactiveCollapsed = true, precompression = 0.16f, settled = 0.5f), eps) + // ...and never below precompression while the settle is still catching up from 0. + assertEquals(0.16f, resolveBarCollapse(BottomBarBehavior.REACTIVE, reduceMotion = false, + reactiveCollapsed = true, precompression = 0.16f, settled = 0.0f), eps) + } + + @Test + fun resolveBarCollapse_reactiveExpandingFollowsPrecompressionThenSettle() { + // Not yet collapsed but mid pre-compression → follow the scroll-linked drift. + assertEquals(0.1f, resolveBarCollapse(BottomBarBehavior.REACTIVE, reduceMotion = false, + reactiveCollapsed = false, precompression = 0.1f, settled = 0.0f), eps) + // Fully expanded, no precompression → the settle value (animating back toward 0). + assertEquals(0.0f, resolveBarCollapse(BottomBarBehavior.REACTIVE, reduceMotion = false, + reactiveCollapsed = false, precompression = 0f, settled = 0.0f), eps) + } +}