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 ec399d6c23..3f50d788ed 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.snap +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 @@ -101,6 +107,67 @@ 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 + } +} + +/** + * 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" + + 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 @@ -273,27 +340,87 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { val updateStore = remember { UpdateStore.from(context) } var showUpdatesInbox by remember { mutableStateOf(false) } + // #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 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 (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 + } + } + } + // 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 = isBarEffectivelyCollapsed(bottomBarBehavior, 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 = resolveBarCollapse( + behavior = bottomBarBehavior, + reduceMotion = reduceMotion, + reactiveCollapsed = barCollapsed, + precompression = barPrecompression, + settled = barSettledCollapse, + ) + 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, - 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, - modifier = Modifier.padding(inner), + // #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 @@ -435,6 +562,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 @@ -695,11 +833,24 @@ private val barTrailingTabs = listOf( private fun GlassBottomBar( current: Destination, onTabSelected: (Destination) -> Unit, + // #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. + modifier: Modifier = Modifier, ) { val barShape = RoundedCornerShape(50) Box( - modifier = Modifier + modifier = modifier .fillMaxWidth() + // 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 + 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. @@ -710,12 +861,11 @@ 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, + // 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 .fillMaxWidth() // Cap the width so the pill stays a centred floating island on tablets, not a full-bleed bar. @@ -734,6 +884,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) }, ) @@ -743,6 +894,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) }, ) @@ -755,6 +907,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) }, ) @@ -770,6 +923,7 @@ private fun BarSlot( icon: ImageVector, label: String, active: Boolean, + collapse: Float = 0f, modifier: Modifier = Modifier, onClick: () -> Unit, ) { @@ -795,6 +949,7 @@ private fun BarSlot( fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium, ), color = tint, + modifier = Modifier.graphicsLayer { alpha = 1f - collapse }, ) } } @@ -820,6 +975,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 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) + } +}