Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions android/app/src/main/java/com/noop/ui/AppRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand All @@ -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(
Expand Down
94 changes: 94 additions & 0 deletions android/app/src/test/java/com/noop/ui/BottomBarBehaviorTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}