From 61f8c58c4938da3f165dd8ab6bd87593947da6c6 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Wed, 8 Jul 2026 23:51:51 +1200 Subject: [PATCH 1/6] =?UTF-8?q?PROTOTYPE:=20scroll-reactive=20bottom=20bar?= =?UTF-8?q?=20=E2=80=94=20recede=20on=20scroll-down,=20return=20on=20up=20?= =?UTF-8?q?(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instagram-style: the floating GlassBottomBar subtly recedes (shrink + fade + slide toward the edge) as you scroll DOWN into content and returns when you scroll UP, so the app feels more dynamic without hiding the tabs. - One NestedScrollConnection on the NavHost drives it, so every screen gets the behaviour with zero per-screen wiring. onPreScroll only flips a Boolean on a direction change; a 220ms tween animates a single `collapse` float. - The bar applies `collapse` as a graphicsLayer transform ONLY (scale ~0.88, alpha to 0.6, translateY 22dp, bottom-centre pivot). No relayout — the Scaffold keeps the bar's reserved space — so scrolling stays smooth (GPU transform per frame). - Reduce Motion keeps the bar fully shown (no animation). Subtle by design; the transform constants are the tuning knobs. Android only for now. Android compiles locally. --- .../app/src/main/java/com/noop/ui/AppRoot.kt | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) 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 ec399d6c23..822c454320 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -3,6 +3,12 @@ package com.noop.ui import androidx.annotation.StringRes import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.core.tween @@ -273,6 +279,31 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { val updateStore = remember { UpdateStore.from(context) } var showUpdatesInbox by remember { mutableStateOf(false) } + // #86 (prototype): Instagram-style scroll-reactive bottom bar. It recedes (subtle shrink + fade + + // slide) when you scroll DOWN into content and returns when you scroll UP — driven by a single + // NestedScrollConnection on the NavHost, so every screen gets it with no per-screen wiring. Reduce + // Motion keeps the bar fully shown (no animation). The bar keeps its reserved layout space (Scaffold + // measured it full-size), so only a cheap graphicsLayer transform changes per frame — no relayout. + val reduceMotion = rememberReduceMotion() + var barCollapsed by remember { mutableStateOf(false) } + val barNestedScroll = remember(reduceMotion) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (!reduceMotion) { + // available.y < 0 = content moving up (finger swipes up, reading down) → collapse. + if (available.y < -3f) barCollapsed = true + else if (available.y > 3f) barCollapsed = false + } + return Offset.Zero + } + } + } + val barCollapse by animateFloatAsState( + targetValue = if (barCollapsed && !reduceMotion) 1f else 0f, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + label = "barCollapse", + ) + run { Scaffold( containerColor = Palette.surfaceBase, @@ -284,6 +315,7 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { // destination is lost without the drawer. GlassBottomBar( current = current, + collapse = barCollapse, // #86: scroll-reactive recede onTabSelected = { dest -> if (dest.route != currentRoute) nav.navigateTopLevel(dest.route) }, @@ -293,7 +325,8 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { NavHost( navController = nav, startDestination = Destination.Today.route, - modifier = Modifier.padding(inner), + // #86: observe scroll direction here (one place → every screen) to drive the bar recede. + modifier = Modifier.padding(inner).nestedScroll(barNestedScroll), // README motion: top-level destinations crossfade (~240ms) on the calm, // decelerating global easing — nothing slides or bounces between tabs. The // same fade is used for back (pop) so the bar never feels jerky. Drill-ins @@ -695,11 +728,24 @@ private val barTrailingTabs = listOf( private fun GlassBottomBar( current: Destination, onTabSelected: (Destination) -> Unit, + // #86 (prototype): 0 = fully shown, 1 = collapsed (scrolled into content). A cheap GPU transform — + // subtle shrink + fade + slide toward the bottom edge — kept partial so the tabs stay reachable. + collapse: Float = 0f, ) { val barShape = RoundedCornerShape(50) Box( modifier = Modifier .fillMaxWidth() + // #86: recede on scroll. graphicsLayer only (no relayout); pivots at the bottom-centre so it + // shrinks toward the edge like the Instagram bar. Subtle by design (scale ~0.88, fade to 0.6). + .graphicsLayer { + val s = 1f - collapse * 0.12f + scaleX = s + scaleY = s + alpha = 1f - collapse * 0.40f + translationY = collapse * 22.dp.toPx() + transformOrigin = TransformOrigin(0.5f, 1f) + } // Clear the gesture-nav bar (home indicator) first, then add breathing room so the capsule // floats free of the bottom edge rather than jamming against it — iOS clears the home-indicator // safe area + 4pt; here navigationBarsPadding + 12dp gives the same lift. From 2456c794b505e98cc5d520e697fc5a8e0f899643 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Thu, 9 Jul 2026 00:06:32 +1200 Subject: [PATCH 2/6] =?UTF-8?q?PROTOTYPE:=20retune=20the=20scroll-reactive?= =?UTF-8?q?=20bar=20=E2=80=94=20fade=20in=20place,=20don't=20slide=20off?= =?UTF-8?q?=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback: the collapsed bar slid too far down (into the gesture-nav area) and didn't read as transparent. Retuned: fade toward see-through (alpha to ~0.35), gentler shrink (0.92), and only a hair of drift (22dp -> 6dp) so it recedes IN PLACE rather than falling off the bottom. Android compiles. --- android/app/src/main/java/com/noop/ui/AppRoot.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 822c454320..21ad2ef17e 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -736,14 +736,16 @@ private fun GlassBottomBar( Box( modifier = Modifier .fillMaxWidth() - // #86: recede on scroll. graphicsLayer only (no relayout); pivots at the bottom-centre so it - // shrinks toward the edge like the Instagram bar. Subtle by design (scale ~0.88, fade to 0.6). + // #86: recede on scroll. graphicsLayer only (no relayout). It fades toward TRANSPARENT and + // shrinks slightly IN PLACE rather than sliding off the bottom — a big translate looked like the + // bar was falling off / going under the gesture nav. Gentle shrink (0.92), fades to ~0.35 (clearly + // see-through), and only a hair of downward drift (6dp). Bottom-centre pivot. .graphicsLayer { - val s = 1f - collapse * 0.12f + val s = 1f - collapse * 0.08f scaleX = s scaleY = s - alpha = 1f - collapse * 0.40f - translationY = collapse * 22.dp.toPx() + alpha = 1f - collapse * 0.65f + translationY = collapse * 6.dp.toPx() transformOrigin = TransformOrigin(0.5f, 1f) } // Clear the gesture-nav bar (home indicator) first, then add breathing room so the capsule From 2340ca39ee8a7619797b8b899654f91147784799 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Thu, 9 Jul 2026 00:23:47 +1200 Subject: [PATCH 3/6] =?UTF-8?q?PROTOTYPE:=20make=20the=20bottom=20bar=20pi?= =?UTF-8?q?ll=20transparent=20=E2=80=94=20no=20fill=20behind=20the=20tabs?= =?UTF-8?q?=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback (correctly, twice): the pill's dark translucent fill read as a solid "bar" behind the tabs and blocked the content. Drop the fill entirely — the pill is now just the hairline capsule outline + tab icons floating over the content, which shows straight through. Also removed the drop shadow (also read as a slab). Android compiles. --- android/app/src/main/java/com/noop/ui/AppRoot.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 21ad2ef17e..81f92b8c8f 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -758,12 +758,12 @@ private fun GlassBottomBar( ) { Surface( shape = barShape, - // "Glass": a translucent raised surface — a frosted island, not a hard slab. Compose has no - // cheap blur, so translucency (≈0.80) + a hairline rim is the Liquid-Glass stand-in. A soft, - // low drop shadow reads as floating without a glow. - color = Palette.surfaceRaised.copy(alpha = 0.80f), - tonalElevation = 2.dp, - shadowElevation = 4.dp, + // #86: NO fill. The dark translucent fill read as a solid "bar" behind the tabs; the content + // should show straight through. Keep the pill SHAPE via the hairline outline only (a floating + // capsule of tabs over the content), no fill and no drop shadow (which also read as a slab). + color = Color.Transparent, + tonalElevation = 0.dp, + shadowElevation = 0.dp, modifier = Modifier .fillMaxWidth() // Cap the width so the pill stays a centred floating island on tablets, not a full-bleed bar. From 6e7e496620b1ccb4b2ce1f25a1efea6ee91c7356 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Thu, 9 Jul 2026 00:50:41 +1200 Subject: [PATCH 4/6] =?UTF-8?q?PROTOTYPE:=20float=20the=20tab=20bar=20over?= =?UTF-8?q?=20content=20=E2=80=94=20removes=20the=20reserved=20"bar"=20ban?= =?UTF-8?q?d=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-width band behind the pill was the Scaffold's RESERVED bottomBar region (filled with the flat app background), sitting below the tinted content. Moved the bar out of the bottomBar slot and overlay it (Box + align bottom-centre) on full-bleed content, so the content's background fills continuously behind the now-transparent pill — no band. NestedScroll moves to the full-bleed NavHost. Note: content now scrolls UNDER the floating transparent pill (iOS-style); screens keep their 28dp bottom padding, so the last item can sit partly behind the bar at the very bottom of a scroll — a bottom-padding bump is the follow-up if that bites. Android compiles. --- .../app/src/main/java/com/noop/ui/AppRoot.kt | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) 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 81f92b8c8f..e853431240 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -307,26 +307,17 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { run { Scaffold( containerColor = Palette.surfaceBase, - bottomBar = { - // One unified "glass" bar: four evenly-spaced tabs — Today · Trends · Sleep · More - // (matches the iOS FloatingTabBar). The quick-action "+" lives in the Today header's - // top-right (balancing the avatar), so the bar is clean tabs only. "More" navigates to - // its own page (mirroring the iOS More tab) that reaches every grouped destination, so no - // destination is lost without the drawer. - GlassBottomBar( - current = current, - collapse = barCollapse, // #86: scroll-reactive recede - onTabSelected = { dest -> - if (dest.route != currentRoute) nav.navigateTopLevel(dest.route) - }, - ) - }, + // #86: the tab bar is NO LONGER a reserved bottomBar slot (that reserved region, filled with the + // flat app background, was the "bar" band behind the pill). It's overlaid in the Box below so the + // content's background fills continuously behind the floating pill. ) { inner -> + Box(modifier = Modifier.padding(inner).fillMaxSize()) { NavHost( navController = nav, startDestination = Destination.Today.route, - // #86: observe scroll direction here (one place → every screen) to drive the bar recede. - modifier = Modifier.padding(inner).nestedScroll(barNestedScroll), + // #86: full-bleed so content (and its background) fills behind the floating bar. The scroll + // direction is observed here (one place → every screen) to drive the bar recede. + modifier = Modifier.fillMaxSize().nestedScroll(barNestedScroll), // README motion: top-level destinations crossfade (~240ms) on the calm, // decelerating global easing — nothing slides or bounces between tabs. The // same fade is used for back (pop) so the bar never feels jerky. Drill-ins @@ -468,6 +459,17 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { MoreScreen(onNavigate = { nav.navigateTopLevel(it) }) } } + // #86: the floating tab bar, overlaid on the content (not a reserved slot) and aligned to the + // bottom — so the content's background fills continuously behind the transparent pill. + GlassBottomBar( + current = current, + collapse = barCollapse, // #86: scroll-reactive recede + onTabSelected = { dest -> + if (dest.route != currentRoute) nav.navigateTopLevel(dest.route) + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } } // Quick-actions sheet, opened by the raised gold centre FAB. Each row routes to an @@ -731,10 +733,13 @@ private fun GlassBottomBar( // #86 (prototype): 0 = fully shown, 1 = collapsed (scrolled into content). A cheap GPU transform — // subtle shrink + fade + slide toward the bottom edge — kept partial so the tabs stay reachable. collapse: Float = 0f, + // #86: the bar now FLOATS over the content (overlaid, not a reserved Scaffold slot), so the content's + // background fills continuously behind it — no reserved "bar" band. The caller aligns it bottom-centre. + modifier: Modifier = Modifier, ) { val barShape = RoundedCornerShape(50) Box( - modifier = Modifier + modifier = modifier .fillMaxWidth() // #86: recede on scroll. graphicsLayer only (no relayout). It fades toward TRANSPARENT and // shrinks slightly IN PLACE rather than sliding off the bottom — a big translate looked like the From 6b1c1db6f509695485fd167247857e570eb67a7e Mon Sep 17 00:00:00 2001 From: Quai <92884265+Quaii@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:23:26 +0100 Subject: [PATCH 5/6] =?UTF-8?q?Scroll-reactive=20bottom=20navigation=20?= =?UTF-8?q?=E2=80=94=20hybrid=20collapse=20with=20Reactive=20/=20E?= =?UTF-8?q?=E2=80=A6=20(#354)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Scroll-reactive bottom navigation — hybrid collapse with Reactive / Expanded / Compact setting (#86) Co-Authored-By: Claude Sonnet 4.6 * Fix: preserve bar collapse state across tab/route switches (#86) Co-Authored-By: Claude Sonnet 4.6 * Fix NestedScrollSource.UserInput → Drag for Compose BOM 2024.06 (#86) UserInput was added in Compose 1.7 (BOM 2024.09+); Drag is the pre-1.7 equivalent for active user-gesture scrolls and compiles cleanly against the current BOM 2024.06.00. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../Sources/StrandDesign/Motion.swift | 9 ++ Strand/Liquid/LiquidTodayView.swift | 25 +++- Strand/Screens/ScreenScaffold.swift | 73 +++++++++ Strand/Screens/SettingsView.swift | 21 +++ StrandiOS/App/RootTabView.swift | 141 +++++++++++++++--- .../app/src/main/java/com/noop/ui/AppRoot.kt | 135 +++++++++++++---- .../src/main/java/com/noop/ui/Components.kt | 11 +- .../src/main/java/com/noop/ui/MainActivity.kt | 1 + .../main/java/com/noop/ui/SettingsScreen.kt | 18 +++ .../app/src/main/java/com/noop/ui/Theme.kt | 3 + 10 files changed, 387 insertions(+), 50 deletions(-) diff --git a/Packages/StrandDesign/Sources/StrandDesign/Motion.swift b/Packages/StrandDesign/Sources/StrandDesign/Motion.swift index b56a263120..34db735179 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Motion.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Motion.swift @@ -30,6 +30,9 @@ public enum StrandMotion { /// Slow / draw-in (ring arc, waveform ignite). public static let durationSlow: Double = 0.9 + /// Deliberately slower restoration for scroll-reactive navigation — the bar returns calmly rather than popping in. + public static let durationNavigationExpand: Double = 0.42 + /// One breath cycle for ambient pulsing (bloom, listening flatline). public static let breathPeriod: Double = 3.2 @@ -63,6 +66,12 @@ public enum StrandMotion { /// Standard fade. public static let fade = Animation.easeInOut(duration: durationStandard) + + /// Smooth, non-bouncy contraction after scroll-reactive navigation crosses its compact threshold. + public static let navigationBarCollapse = Animation.easeInOut(duration: durationStandard) + + /// A deliberately slower restoration so the full bar returns calmly instead of appearing to pop in. + public static let navigationBarExpand = Animation.easeInOut(duration: durationNavigationExpand) } #if DEBUG diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index 46e5d8e5aa..a3d8f9de1e 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -21,6 +21,10 @@ struct LiquidTodayView: View { @EnvironmentObject var router: NavRouter @EnvironmentObject var profile: ProfileStore @Environment(\.accessibilityReduceMotion) private var reduceMotion + #if os(iOS) + @Environment(\.tabBarScrollTag) private var tabBarScrollTag + @Environment(\.tabBarScrollInteractionChanged) private var tabBarScrollInteractionChanged + #endif /// Shared with the real Today's card-customise editor so the two stay in sync. @AppStorage(DashboardCardPrefs.selectionKey) private var dashboardCardsRaw = "" @@ -191,8 +195,16 @@ struct LiquidTodayView: View { // Scroll-offset probe at the very top (before padding), so its minY in the scroll's // coordinate space reads the top OVERSCROLL: ~0 at rest, positive as you pull down. GeometryReader { g in + let offset = g.frame(in: .named(Self.pullSpace)).minY + #if os(iOS) + Color.clear + .preference(key: PullOffsetKey.self, value: offset) + .preference(key: TabBarScrollOffsetKey.self, + value: tabBarScrollTag.map { [$0: offset] } ?? [:]) + #else Color.clear.preference(key: PullOffsetKey.self, - value: g.frame(in: .named(Self.pullSpace)).minY) + value: offset) + #endif } .frame(height: 0) @@ -220,7 +232,16 @@ struct LiquidTodayView: View { #endif } .coordinateSpace(name: Self.pullSpace) - .onPreferenceChange(PullOffsetKey.self) { handlePull($0) } + #if os(iOS) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in tabBarScrollInteractionChanged(true) } + .onEnded { _ in tabBarScrollInteractionChanged(false) } + ) + #endif + .onPreferenceChange(PullOffsetKey.self) { offset in + handlePull(offset) + } // The sky is a FIXED full-bleed backdrop drawn behind the scroll content, edge-to-edge under the // status bar. A ScrollView background does not scroll with the content, so pulling down never // moves the sky (the exact behaviour the scaffold uses on the classic Today). diff --git a/Strand/Screens/ScreenScaffold.swift b/Strand/Screens/ScreenScaffold.swift index 5d1a7ef134..31ab759820 100644 --- a/Strand/Screens/ScreenScaffold.swift +++ b/Strand/Screens/ScreenScaffold.swift @@ -1,6 +1,59 @@ import SwiftUI import StrandDesign +#if os(iOS) +enum BottomBarBehavior: String, CaseIterable, Identifiable { + static let storageKey = "noop.bottomBarBehavior" + + case reactive + case expanded + case compact + + var id: String { rawValue } + var label: LocalizedStringKey { + switch self { + case .reactive: "Reactive" + case .expanded: "Expanded" + case .compact: "Compact" + } + } +} + +private struct TabBarScrollTagKey: EnvironmentKey { + static let defaultValue: Int? = nil +} + +private struct TabBarScrollInteractionChangedKey: EnvironmentKey { + static let defaultValue: (Bool) -> Void = { _ in } +} + +extension EnvironmentValues { + var tabBarScrollTag: Int? { + get { self[TabBarScrollTagKey.self] } + set { self[TabBarScrollTagKey.self] = newValue } + } + + var tabBarScrollInteractionChanged: (Bool) -> Void { + get { self[TabBarScrollInteractionChangedKey.self] } + set { self[TabBarScrollInteractionChangedKey.self] = newValue } + } +} + +/// Scrollable tab roots publish their content offset to the iPhone shell, which owns the floating bar. +/// Values stay keyed by tab because `TabView` retains its off-screen roots; reducing them to one scalar lets +/// a hidden tab's zero overwrite the visible tab's moving offset. +struct TabBarScrollOffsetKey: PreferenceKey { + static var defaultValue: [Int: CGFloat] = [:] + static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} + +private enum TabBarScrollSpace { + static let name = "noop.tabBarScroll" +} +#endif + /// Standard scrollable screen container: title + dark surface + content column. struct ScreenScaffold: View { /// Optional — when nil (and no subtitle) the header is omitted entirely, so a screen can supply its @@ -31,6 +84,8 @@ struct ScreenScaffold: View { // by `#if os(iOS)` — a runtime size-class check alone would also narrow the Mac detail pane. #if os(iOS) @Environment(\.horizontalSizeClass) private var hSizeClass + @Environment(\.tabBarScrollTag) private var tabBarScrollTag + @Environment(\.tabBarScrollInteractionChanged) private var tabBarScrollInteractionChanged #endif var body: some View { @@ -49,6 +104,18 @@ struct ScreenScaffold: View { .frame(maxWidth: hSizeClass == .regular ? 700 : .infinity, alignment: hSizeClass == .regular ? .center : .leading) .frame(maxWidth: .infinity, alignment: .center) + // Read the content's real position in its scroll viewport. The root shell accumulates these + // offsets before changing state, so bounce and one-pixel settling never flicker the tab bar. + .background { + GeometryReader { geometry in + Color.clear.preference( + key: TabBarScrollOffsetKey.self, + value: tabBarScrollTag.map { + [$0: geometry.frame(in: .named(TabBarScrollSpace.name)).minY] + } ?? [:] + ) + } + } #else .padding(28) .frame(maxWidth: .infinity, alignment: .leading) @@ -59,6 +126,12 @@ struct ScreenScaffold: View { // permits horizontal bounce when content genuinely overflows the width (it does not here, the column // is width-capped), so the spurious horizontal rubber-band that caused the sideways drift is gone. .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + .coordinateSpace(name: TabBarScrollSpace.name) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in tabBarScrollInteractionChanged(true) } + .onEnded { _ in tabBarScrollInteractionChanged(false) } + ) #endif // The flat canvas, plus an optional full-bleed TOP backdrop (Today's day-cycle scene) drawn behind // the scroll content — edge-to-edge under the status bar. The scene is CONFINED to the header+hero diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 34ee3c38d0..09e055e436 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -72,6 +72,10 @@ struct SettingsView: View { @AppStorage("appIcon.alt") private var useNavyIcon = false // Light/Dark/System theme. Read by both app roots' .preferredColorScheme; default follows the OS. @AppStorage(AppearanceMode.storageKey) private var appearanceRaw = AppearanceMode.system.rawValue + #if os(iOS) + // iPhone floating navigation: react to scroll by default, or pin either resting state. + @AppStorage(BottomBarBehavior.storageKey) private var bottomBarBehaviorRaw = BottomBarBehavior.reactive.rawValue + #endif // Chart colour style: Titanium (brand) or Classic (throwback red→green). Re-colours gauges + charts. @AppStorage(ChartStyle.storageKey) private var chartStyleRaw = ChartStyle.titanium.rawValue // Day-cycle scene backdrop behind Today (#698). Default ON. Off swaps the scene for a plain dark @@ -670,6 +674,23 @@ struct SettingsView: View { .accessibilityLabel("Chart colours") } #if os(iOS) + FormRow(label: "Bottom bar") { + Picker("Bottom bar", selection: $bottomBarBehaviorRaw) { + ForEach(BottomBarBehavior.allCases) { behavior in + Text(behavior.label).tag(behavior.rawValue) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + .accessibilityLabel("Bottom bar") + } + Text("Reactive subtly compacts while scrolling. Expanded and Compact keep the chosen size at all times.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + FormRow(label: "App icon") { Picker("App icon", selection: $useNavyIcon) { Text("Default").tag(false) diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 8eefee53d2..316ae7221a 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -10,6 +10,7 @@ struct RootTabView: View { /// Cross-screen navigation requests (e.g. Live → "Manage devices"). Devices isn't a tab — it lives /// behind the More list — so a request presents it as a sheet, matching the quick-action screens. @EnvironmentObject private var router: NavRouter + @Environment(\.accessibilityReduceMotion) private var reduceMotion /// Which quick-action screen the centre FAB is presenting (nil = sheet closed). @State private var quickAction: QuickAction? @@ -21,6 +22,12 @@ struct RootTabView: View { /// Selected tab — bound so tab switches can crossfade (README §Motion: ~240ms opacity swap /// between tab roots, calm easing). Defaults to Today. @State private var selectedTab: Int = 0 + /// A small scroll-linked pre-compression, followed by a fast eased settle once the threshold is crossed. + @State private var tabBarCollapseProgress: CGFloat = 0 + @State private var tabBarDownwardScroll: CGFloat = 0 + @State private var tabBarIsCollapsed = false + @State private var tabBarScrollIsDragging = false + @State private var lastTabBarScrollOffset: CGFloat? /// Which More-tab groups are expanded (S2). Insights + Body stay open at rest; Data + App collapse to /// just their header until tapped. Persisted (#860 item 2): the user's open/closed choice must SURVIVE /// leaving and re-entering the More tab (and relaunch), not reset to the seed every visit. Backed by an @@ -32,6 +39,11 @@ struct RootTabView: View { /// V8 liquid redesign is the default Today; the Settings toggle lets a user fall back to the classic /// Today if they prefer it (keyed identically to the SettingsView toggle). Default ON. @AppStorage("noop.liquidTodayEnabled") private var liquidTodayEnabled = true + @AppStorage(BottomBarBehavior.storageKey) private var bottomBarBehaviorRaw = BottomBarBehavior.reactive.rawValue + + private var bottomBarBehavior: BottomBarBehavior { + BottomBarBehavior(rawValue: bottomBarBehaviorRaw) ?? .reactive + } /// The Today tab root, honouring the liquid/classic preference. @ViewBuilder private var todayTabRoot: some View { @@ -61,10 +73,18 @@ struct RootTabView: View { // cleanly in the gap between them — replaces the native tab bar: no overlap, no glow. The // native TabView still drives content + per-tab nav state; only its bar is hidden. TabView(selection: $selectedTab) { - tab(todayTabRoot, "Today", "square.grid.2x2").tag(0) - tab(TrendsView(), "Trends", "chart.line.uptrend.xyaxis").tag(1) - tab(SleepView(), "Sleep", "bed.double").tag(2) - moreTab.tag(3) + tab(todayTabRoot, "Today", "square.grid.2x2", tag: 0).tag(0) + tab(TrendsView(), "Trends", "chart.line.uptrend.xyaxis", tag: 1).tag(1) + tab(SleepView(), "Sleep", "bed.double", tag: 2).tag(2) + moreTab + .environment(\.tabBarScrollTag, 3) + .environment(\.tabBarScrollInteractionChanged) { isDragging in + if selectedTab == 3 { tabBarScrollIsDragging = isDragging } + } + .onPreferenceChange(TabBarScrollOffsetKey.self) { offsets in + if selectedTab == 3, let offset = offsets[3] { handleTabBarScroll(offset) } + } + .tag(3) } .tint(StrandPalette.accent) .toolbar(.hidden, for: .tabBar) @@ -87,7 +107,10 @@ struct RootTabView: View { } ) - FloatingTabBar(selection: $selectedTab, onReselect: { _ in + FloatingTabBar(selection: $selectedTab, + collapseProgress: bottomBarBehavior == .compact ? 1 : + (bottomBarBehavior == .expanded ? 0 : tabBarCollapseProgress), + onReselect: { _ in // Re-tapping the active tab refreshes that page's data (2026-07-02). Task { await repo.refresh() } }) @@ -153,6 +176,67 @@ struct RootTabView: View { router.quickActionsRequested = false } } + .onChange(of: selectedTab) { _, _ in + // Keep the bar's current collapsed/expanded state — only reset scroll tracking so + // the new tab's offset doesn't inherit the previous tab's position. + lastTabBarScrollOffset = nil + tabBarDownwardScroll = 0 + tabBarScrollIsDragging = false + } + .onChange(of: bottomBarBehaviorRaw) { _, _ in + resetTabBarScroll(expand: true) + } + } + + /// Instagram-style hybrid: the first downward travel subtly pre-compresses with the finger; crossing + /// the threshold eases the rest of the way to compact. The first upward sample expands immediately. + private func handleTabBarScroll(_ offset: CGFloat) { + guard bottomBarBehavior == .reactive else { return } + guard let previous = lastTabBarScrollOffset else { + lastTabBarScrollOffset = offset + return + } + + let delta = offset - previous + lastTabBarScrollOffset = offset + guard abs(delta) >= NoopMetrics.space1 / 8 else { return } + + if delta > 0 { + // Elastic bounce, refresh completion and content relayout can all move the offset downward. + // Only a finger-driven upward scroll is allowed to restore the expanded navigation bar. + guard tabBarScrollIsDragging else { return } + tabBarDownwardScroll = 0 + guard tabBarCollapseProgress > 0 else { return } + tabBarIsCollapsed = false + withAnimation(NoopMotion.gated(StrandMotion.navigationBarExpand, reduced: reduceMotion)) { + tabBarCollapseProgress = 0 + } + return + } + + guard !tabBarIsCollapsed else { return } + tabBarDownwardScroll += -delta + let threshold = NoopMetrics.space6 + let precompression: CGFloat = 0.16 + tabBarCollapseProgress = min( + precompression, + precompression * tabBarDownwardScroll / threshold + ) + + if tabBarDownwardScroll >= threshold { + tabBarIsCollapsed = true + withAnimation(NoopMotion.gated(StrandMotion.navigationBarCollapse, reduced: reduceMotion)) { + tabBarCollapseProgress = 1 + } + } + } + + private func resetTabBarScroll(expand: Bool) { + lastTabBarScrollOffset = nil + tabBarDownwardScroll = 0 + tabBarIsCollapsed = false + tabBarScrollIsDragging = false + if expand { tabBarCollapseProgress = 0 } } /// A routed v5 pillar screen wrapped in its own nav stack + Done button (mirrors `quickScreen`). @@ -264,7 +348,8 @@ struct RootTabView: View { } } - private func tab(_ view: V, _ title: LocalizedStringKey, _ icon: String) -> some View { + private func tab(_ view: V, _ title: LocalizedStringKey, _ icon: String, + tag: Int) -> some View { // Each primary tab gets its OWN NavigationStack so the in-content NavigationLinks (e.g. the Today // dashboard card rows) both navigate AND render opaque. An ORPHANED NavigationLink (no // NavigationStack ancestor) renders its whole label in a disabled/translucent state — that was @@ -276,6 +361,15 @@ struct RootTabView: View { .background(StrandPalette.surfaceBase.ignoresSafeArea()) .toolbar(.hidden, for: .navigationBar) } + .environment(\.tabBarScrollTag, tag) + .environment(\.tabBarScrollInteractionChanged) { isDragging in + if selectedTab == tag { tabBarScrollIsDragging = isDragging } + } + // TabView retains off-screen roots. Filter their geometry updates here so a hidden tab's layout + // cannot be mistaken for a scroll on the visible tab. + .onPreferenceChange(TabBarScrollOffsetKey.self) { offsets in + if selectedTab == tag, let offset = offsets[tag] { handleTabBarScroll(offset) } + } .toolbar(.hidden, for: .tabBar) // we draw our own FloatingTabBar .tabItem { Label(title, systemImage: icon) } } @@ -547,8 +641,11 @@ private struct QuickActionSheet: View { /// Glass where available, a `.ultraThinMaterial` fallback below. Replaces the hidden native tab bar. private struct FloatingTabBar: View { @Binding var selection: Int + /// 0 is expanded and 1 is compact. Intermediate values remain visible while the scroll is in between. + var collapseProgress: CGFloat = 0 /// Fires when the user taps the ALREADY-active tab (2026-07-02: re-tap should refresh). var onReselect: (Int) -> Void = { _ in } + @Environment(\.accessibilityReduceMotion) private var reduceMotion private struct Item: Identifiable { let title: LocalizedStringKey; let icon: String; let tag: Int; var id: Int { tag } } private let nav = [Item(title: "Today", icon: "square.grid.2x2", tag: 0), @@ -557,16 +654,19 @@ private struct FloatingTabBar: View { Item(title: "More", icon: "ellipsis", tag: 3)] var body: some View { + let progress = min(1, max(0, collapseProgress)) + // Reduce Motion retains the useful compact state but removes continuous interpolation. + let collapse = reduceMotion ? (progress >= 0.5 ? 1.0 : 0.0) : progress // One frosted glass bar, four evenly-spaced tabs. The quick-action "+" now lives in the // top-right of each screen's header (balancing the profile avatar on the left). - HStack(spacing: 2) { - tabButton(nav[0]) - tabButton(nav[1]) - tabButton(nav[2]) - tabButton(nav[3]) + HStack(spacing: NoopMetrics.space1 / 2) { + tabButton(nav[0], collapse: collapse) + tabButton(nav[1], collapse: collapse) + tabButton(nav[2], collapse: collapse) + tabButton(nav[3], collapse: collapse) } - .padding(.vertical, 7) - .padding(.horizontal, 8) + .padding(.vertical, NoopMetrics.space2 - collapse * NoopMetrics.space1) + .padding(.horizontal, NoopMetrics.space2) .liquidGlass(in: Capsule()) // Over the liquid Today the sky ends at ~340pt, so the bar floats on flat opaque surfaceBase — // a blur material has nothing to dissolve and hardens into a solid lozenge (2026-07-02: @@ -582,11 +682,11 @@ private struct FloatingTabBar: View { ) // Lighter, wider shadow: real elevation without stamping a dark halo on the flat canvas. .shadow(color: .black.opacity(0.22), radius: 18, x: 0, y: 8) - .padding(.horizontal, 22) - .padding(.bottom, 4) + .padding(.horizontal, NoopMetrics.space6 + collapse * NoopMetrics.space5) + .padding(.bottom, NoopMetrics.space1) } - private func tabButton(_ item: Item) -> some View { + private func tabButton(_ item: Item, collapse: CGFloat) -> some View { let active = selection == item.tag return Button { if active { @@ -595,15 +695,18 @@ private struct FloatingTabBar: View { withAnimation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24)) { selection = item.tag } } } label: { - VStack(spacing: 3) { + VStack(spacing: NoopMetrics.space1 * (1 - collapse)) { Image(systemName: item.icon) .font(.system(size: 18, weight: active ? .semibold : .regular)) Text(item.title) .font(.system(size: 10, weight: active ? .semibold : .medium)) + .frame(height: NoopMetrics.space3 * (1 - collapse)) + .opacity(1 - collapse) + .clipped() } .foregroundStyle(active ? StrandPalette.accent : StrandPalette.textSecondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 3) + .frame(maxWidth: .infinity, minHeight: NoopMetrics.controlHeight) + .padding(.vertical, NoopMetrics.space1 * (1 - collapse)) .contentShape(Rectangle()) } .buttonStyle(.plain) 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 e853431240..f16e99fcf9 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -3,7 +3,7 @@ package com.noop.ui import androidx.annotation.StringRes import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.snap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -107,6 +107,36 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +enum class BottomBarBehavior(val storageValue: String, val label: String) { + REACTIVE("reactive", "Reactive"), + EXPANDED("expanded", "Expanded"), + COMPACT("compact", "Compact"); + + companion object { + fun fromStorage(raw: String?): BottomBarBehavior = + entries.firstOrNull { it.storageValue == raw } ?: REACTIVE + } +} + +/** Persisted bottom-navigation behavior, mirrored in snapshot state so Settings updates the shell live. */ +object BottomBarPrefs { + const val KEY = "noop.bottomBarBehavior" + + var behavior by mutableStateOf(BottomBarBehavior.REACTIVE) + private set + + fun load(context: android.content.Context) { + behavior = BottomBarBehavior.fromStorage( + NoopPrefs.of(context).getString(KEY, BottomBarBehavior.REACTIVE.storageValue), + ) + } + + fun set(context: android.content.Context, value: BottomBarBehavior) { + behavior = value + NoopPrefs.of(context).edit().putString(KEY, value.storageValue).apply() + } +} + // MARK: - Navigation model // // The macOS app's sidebar holds many sections; on Android (mirroring the iOS RootTabView) we surface @@ -279,30 +309,77 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { val updateStore = remember { UpdateStore.from(context) } var showUpdatesInbox by remember { mutableStateOf(false) } - // #86 (prototype): Instagram-style scroll-reactive bottom bar. It recedes (subtle shrink + fade + - // slide) when you scroll DOWN into content and returns when you scroll UP — driven by a single - // NestedScrollConnection on the NavHost, so every screen gets it with no per-screen wiring. Reduce - // Motion keeps the bar fully shown (no animation). The bar keeps its reserved layout space (Scaffold - // measured it full-size), so only a cheap graphicsLayer transform changes per frame — no relayout. + // #86: Instagram-style hybrid: slight scroll-linked pre-compression, then a fast eased settle after a + // threshold. The first upward scroll expands it. Reduce Motion preserves state but snaps rendering. val reduceMotion = rememberReduceMotion() + val bottomBarBehavior = BottomBarPrefs.behavior + var barPrecompression by remember { mutableStateOf(0f) } var barCollapsed by remember { mutableStateOf(false) } - val barNestedScroll = remember(reduceMotion) { + val density = androidx.compose.ui.platform.LocalDensity.current + val barScrollNoisePx = with(density) { Metrics.space4.toPx() / 8f } + val barCollapseThresholdPx = with(density) { Metrics.space24.toPx() } + val barNestedScroll = remember( + currentRoute, + bottomBarBehavior, + barScrollNoisePx, + barCollapseThresholdPx, + ) { object : NestedScrollConnection { + private var downwardScroll = 0f + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if (!reduceMotion) { - // available.y < 0 = content moving up (finger swipes up, reading down) → collapse. - if (available.y < -3f) barCollapsed = true - else if (available.y > 3f) barCollapsed = false + if (bottomBarBehavior != BottomBarBehavior.REACTIVE) return Offset.Zero + val delta = available.y + if (kotlin.math.abs(delta) >= barScrollNoisePx) { + // Ignore positive rebound/settling deltas: only an active user gesture restores the bar. + if (delta > 0f && source == NestedScrollSource.Drag) { + downwardScroll = 0f + barPrecompression = 0f + barCollapsed = false + } else if (!barCollapsed) { + downwardScroll += -delta + barPrecompression = + (0.16f * downwardScroll / barCollapseThresholdPx).coerceIn(0f, 0.16f) + if (downwardScroll >= barCollapseThresholdPx) barCollapsed = true + } } return Offset.Zero } } } - val barCollapse by animateFloatAsState( - targetValue = if (barCollapsed && !reduceMotion) 1f else 0f, - animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), - label = "barCollapse", + // Route changes reset scroll tracking but preserve the bar's collapsed/expanded state. + // Behavior setting changes force an expand so the new mode starts from a known position. + LaunchedEffect(currentRoute) { + barPrecompression = 0f + } + LaunchedEffect(bottomBarBehavior) { + barPrecompression = 0f + barCollapsed = false + } + val effectiveBarCollapsed = bottomBarBehavior == BottomBarBehavior.COMPACT || + (bottomBarBehavior == BottomBarBehavior.REACTIVE && barCollapsed) + val barSettledCollapse by animateFloatAsState( + targetValue = if (effectiveBarCollapsed) 1f else 0f, + animationSpec = if (reduceMotion) { + snap() + } else if (effectiveBarCollapsed) { + barCollapseSpec + } else { + barExpandSpec + }, + 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 + } run { Scaffold( @@ -730,8 +807,7 @@ private val barTrailingTabs = listOf( private fun GlassBottomBar( current: Destination, onTabSelected: (Destination) -> Unit, - // #86 (prototype): 0 = fully shown, 1 = collapsed (scrolled into content). A cheap GPU transform — - // subtle shrink + fade + slide toward the bottom edge — kept partial so the tabs stay reachable. + // #86: 0 = fully shown, 1 = compact (scrolled into content). collapse: Float = 0f, // #86: the bar now FLOATS over the content (overlaid, not a reserved Scaffold slot), so the content's // background fills continuously behind it — no reserved "bar" band. The caller aligns it bottom-centre. @@ -741,16 +817,12 @@ private fun GlassBottomBar( Box( modifier = modifier .fillMaxWidth() - // #86: recede on scroll. graphicsLayer only (no relayout). It fades toward TRANSPARENT and - // shrinks slightly IN PLACE rather than sliding off the bottom — a big translate looked like the - // bar was falling off / going under the gesture nav. Gentle shrink (0.92), fades to ~0.35 (clearly - // see-through), and only a hair of downward drift (6dp). Bottom-centre pivot. + // Contract from the left, top and right while the bottom edge stays planted above gesture nav. + // The transform is GPU-only; the Scaffold never reflows the scrolling content. .graphicsLayer { val s = 1f - collapse * 0.08f scaleX = s scaleY = s - alpha = 1f - collapse * 0.65f - translationY = collapse * 6.dp.toPx() transformOrigin = TransformOrigin(0.5f, 1f) } // Clear the gesture-nav bar (home indicator) first, then add breathing room so the capsule @@ -763,10 +835,9 @@ private fun GlassBottomBar( ) { Surface( shape = barShape, - // #86: NO fill. The dark translucent fill read as a solid "bar" behind the tabs; the content - // should show straight through. Keep the pill SHAPE via the hairline outline only (a floating - // capsule of tabs over the content), no fill and no drop shadow (which also read as a slab). - color = Color.Transparent, + // A light translucent scrim preserves icon contrast over charts and bright cards while still + // letting the full-bleed content read through the floating capsule. + color = Palette.surfaceRaised.copy(alpha = StrandAlpha.navigationGlass), tonalElevation = 0.dp, shadowElevation = 0.dp, modifier = Modifier @@ -787,6 +858,7 @@ private fun GlassBottomBar( icon = tab.icon, label = stringResource(tab.labelRes), active = current == tab.dest, + collapse = collapse, modifier = Modifier.weight(1f), onClick = { onTabSelected(tab.dest) }, ) @@ -796,6 +868,7 @@ private fun GlassBottomBar( icon = tab.icon, label = stringResource(tab.labelRes), active = current == tab.dest, + collapse = collapse, modifier = Modifier.weight(1f), onClick = { onTabSelected(tab.dest) }, ) @@ -808,6 +881,7 @@ private fun GlassBottomBar( // into any grouped destination still reads as "you're in More", never "nowhere". active = current != Destination.Today && current != Destination.Trends && current != Destination.Sleep, + collapse = collapse, modifier = Modifier.weight(1f), onClick = { onTabSelected(Destination.More) }, ) @@ -823,6 +897,7 @@ private fun BarSlot( icon: ImageVector, label: String, active: Boolean, + collapse: Float = 0f, modifier: Modifier = Modifier, onClick: () -> Unit, ) { @@ -848,6 +923,7 @@ private fun BarSlot( fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium, ), color = tint, + modifier = Modifier.graphicsLayer { alpha = 1f - collapse }, ) } } @@ -873,6 +949,11 @@ private val quickActions: List = listOf( /** The calm global easing curve from the handoff (cubic-bezier 0.22, 1, 0.36, 1). */ private val NavEasing = CubicBezierEasing(0.22f, 1f, 0.36f, 1f) +/** Symmetric, non-bouncy navigation transitions; restoration is deliberately slower than contraction. */ +private val BarSettleEasing = CubicBezierEasing(0.42f, 0f, 0.58f, 1f) +private val barCollapseSpec = tween(durationMillis = 300, easing = BarSettleEasing) +private val barExpandSpec = tween(durationMillis = 420, easing = BarSettleEasing) + /** ~240ms crossfade on the calm easing — the README "Tab crossfade" between roots. */ private val navFadeSpec = tween(durationMillis = 240, easing = NavEasing) diff --git a/android/app/src/main/java/com/noop/ui/Components.kt b/android/app/src/main/java/com/noop/ui/Components.kt index ebd0f21131..4a51266e56 100644 --- a/android/app/src/main/java/com/noop/ui/Components.kt +++ b/android/app/src/main/java/com/noop/ui/Components.kt @@ -1155,7 +1155,9 @@ fun ScreenScaffold( Column( modifier = columnModifier .verticalScroll(rememberScrollState()) - .padding(start = 28.dp, end = 28.dp, top = topPadding, bottom = 28.dp), + // The app bar floats over content, so the final row needs enough scroll room to clear it. + .padding(start = 28.dp, end = 28.dp, top = topPadding, + bottom = Metrics.tabBarClearance), // #765: one shared inter-card spacing token (was a bare `20.dp`), so the eager + lazy scaffolds // and every screen through them keep the SAME uniform gap between top-level cards. verticalArrangement = Arrangement.spacedBy(Metrics.screenRowSpacing), @@ -1303,7 +1305,12 @@ fun LazyScreenScaffold( val list: @Composable () -> Unit = { LazyColumn( modifier = listModifier, - contentPadding = PaddingValues(start = 28.dp, top = topPadding, end = 28.dp, bottom = 28.dp), + contentPadding = PaddingValues( + start = 28.dp, + top = topPadding, + end = 28.dp, + bottom = Metrics.tabBarClearance, + ), // #765: the shared inter-card spacing token by default (Today/Explore + the eager screens share // one uniform card rhythm); a caller may pass a tighter [rowSpacing] (the liquid Today does, for // the iOS-compact section rhythm). diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt index 8a56d0f3b5..936074ec97 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -91,6 +91,7 @@ class MainActivity : ComponentActivity() { // and chart ramps are correct from the very first frame (no flash). AppearancePrefs.load(this) ChartStylePrefs.load(this) + BottomBarPrefs.load(this) // Decode the optional on-device profile photo (if set) before first composition so the Today // header + Settings avatars show it from the first frame. No-op when no photo is set. ProfileAvatarStore.load(this) diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index aac614b6fb..9549120859 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -462,6 +462,8 @@ fun SettingsScreen( // Theme (System / Light / Dark) — drives NoopTheme; AppearancePrefs mirrors it in snapshot state. var themeMode by remember { mutableStateOf(AppearancePrefs.mode) } + // Floating bottom navigation: scroll-reactive by default, or pinned expanded/compact. + var bottomBarBehavior by remember { mutableStateOf(BottomBarPrefs.behavior) } // Chart colours (Titanium / Classic) — re-colours gauges + charts; ChartStylePrefs mirrors it live. var chartStyle by remember { mutableStateOf(ChartStylePrefs.style) } // Day-cycle background (#698) — the time-of-day scene behind Today. Default ON. SharedPreferences @@ -922,6 +924,22 @@ fun SettingsScreen( }, ) } + FormRow(label = "Bottom bar") { + SegmentedPillControl( + items = BottomBarBehavior.entries, + selection = bottomBarBehavior, + label = { it.label }, + onSelect = { behavior -> + bottomBarBehavior = behavior + BottomBarPrefs.set(context, behavior) + }, + ) + } + Text( + "Reactive subtly compacts while scrolling. Expanded and Compact keep the chosen size at all times.", + style = NoopType.footnote, + color = Palette.textTertiary, + ) // Day-cycle background (#698): the time-of-day scene behind Today. On by default. Off swaps it // for a plain dark canvas for people who find the moving scene distracting. Takes effect next diff --git a/android/app/src/main/java/com/noop/ui/Theme.kt b/android/app/src/main/java/com/noop/ui/Theme.kt index 29f24fc5be..fde35197c4 100644 --- a/android/app/src/main/java/com/noop/ui/Theme.kt +++ b/android/app/src/main/java/com/noop/ui/Theme.kt @@ -355,6 +355,7 @@ object StrandAlpha { const val unselectedBar = 0.88f const val warningFill = 0.12f const val warningBorder = 0.40f + const val navigationGlass = 0.42f } // MARK: - Metrics (ported from StrandDesign/Components.swift NoopMetrics) @@ -386,6 +387,8 @@ object Metrics { val screenPadding = 24.dp val tileHeight = 108.dp // every metric tile is this tall val chartHeight = 220.dp + // Extra scroll room so the final card clears the floating bottom navigation capsule. + val tabBarClearance = 76.dp val divider = 1.dp val compactChartHeight = chartHeight - 90.dp val selectorTopUp = sectionGap - screenRowSpacing From de413b44ae8844c20b323bacfa207a0bd321d937 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Tue, 14 Jul 2026 11:32:22 +1200 Subject: [PATCH 6/6] test(android): cover bottom-bar behavior state machine (#86) (#410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the two pure decisions from AppRoot's inline collapse composition into testable top-level functions — isBarEffectivelyCollapsed() and resolveBarCollapse() — and add BottomBarBehaviorTest. Behavior-preserving: the composable now calls the extracted functions instead of the inline if/else chain. Covers storage-string resolution (unknown/null -> REACTIVE, round-trip), the COMPACT-always / EXPANDED-never / REACTIVE-follows-flag collapse decision, and the fraction composition (Reduce Motion snaps to 0/1, non-reactive rides the settle, reactive-collapsed takes max(precompression, settle), reactive-expanding follows precompression then settle). 9 tests, testFullDebugUnitTest green. Follows up #354: that PR left the Android-unit-tests box unchecked; this is the test gate before the feature promotes from prototype-scroll-navbar to main. --- .../app/src/main/java/com/noop/ui/AppRoot.kt | 52 +++++++--- .../java/com/noop/ui/BottomBarBehaviorTest.kt | 94 +++++++++++++++++++ 2 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 android/app/src/test/java/com/noop/ui/BottomBarBehaviorTest.kt 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) + } +}