diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index f248d0a..e8f692b 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -23,42 +23,76 @@ struct AdaptiveRootView: View { // MARK: - Compact (iPhone / narrow multitasking) -/// The iPhone shell: a four-tab `TabView` using SwiftUI's *native* tab bar — on iOS 26 that's the -/// Liquid Glass bar with the animated selection indicator ("bubble slider"). +/// The iPhone shell: a four-tab `TabView` whose *system* tab bar is suppressed in favour of our +/// own `NucleicTabBar` (below). We draw the bar ourselves because SwiftUI's per-navigation +/// `.toolbar(.hidden, for: .tabBar)` restores the system bar late (~1s) and un-animated when you +/// pop back from a pushed `SessionDetailView` — long enough that the floating "+" renders low +/// (anchored to a bottom safe-area inset that hasn't grown yet) and is briefly untappable before +/// snapping up. (Confirmed still true on iOS 26's native Liquid Glass bar — a "go native" experiment +/// reproduced exactly this ~1s un-animated restore, so owning the bar remains the right call.) +/// Owning the bar lets us slide it out/in with a spring the instant a session opens or closes, so +/// the return is smooth and the "+" reflows in one coordinated animation — and lets the "+" ride +/// beside the tabs, which the native bar can't host. `NucleicTabBar` draws its own animated +/// selection "bubble" so we keep the native look the experiment was after. /// -/// **This is the "go native" experiment.** We previously drew the bar ourselves (`NucleicTabBar`, -/// still defined below) because `.toolbar(.hidden, for: .tabBar)` used to restore the system bar -/// late (~1s) and un-animated when popping back from a pushed `SessionDetailView`. We're now testing -/// whether iOS 26's native bar restores smoothly on pop-back: the pushed `SessionDetailView` hides -/// the bar via `.toolbar(.hidden, for: .tabBar)` (see `SessionsView`), and we watch the return for -/// the old jank. To revert, swap this body back to the `NucleicTabBar` overlay below. -/// -/// The "New Chat" affordance rides in the iOS 26 `tabViewBottomAccessory` (a floating "+" on -/// earlier systems). A notification tap routes to the Sessions tab, where `SessionsView` consumes -/// `pendingRoute` and pushes the detail. +/// A notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute` +/// and pushes the detail. struct CompactRootView: View { @EnvironmentObject var store: RemoteStore + @Environment(\.accessibilityReduceMotion) private var reduceMotion // Initial tab; overridable via NUCLEIC_TAB for offline UI previews. @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0 - /// The shared "New Chat" entry opens the project-picker composer (the per-page discs are gone on - /// compact — this one entry owns "new chat"). + /// The shared "+" beside the tab bar opens the project-picker composer (the per-page discs are + /// gone on compact — this one bar owns "new chat"). @State private var showNewChat = false + /// Measured height of the floating bar, fed back into the bottom inset so tab content lays out + /// exactly above it (no guessing, no overlap). + @State private var barHeight: CGFloat = 72 + + /// The bar owns the bottom edge on the top-level tabs, but yields it to a pushed session's + /// floating glass chat bar (`compactDetailPresented`, driven by `SessionsView`'s navigation + /// path) and to the new-chat composer while it's up (`showNewChat`) — both slide it out, so the + /// composer doesn't just stack on top of a lingering tab bar. We key off the path rather than + /// `openSessionID` because the path flips the instant a back-swipe begins, whereas + /// `openSessionID` isn't cleared until the detail's `onDisappear` fires at the *end* of the pop + /// transition — which left the bar sliding back up 1–2s late. + private var showsTabBar: Bool { !store.compactDetailPresented && !showNewChat } /// The "+" is inert without control scope or a project to start in, so it's shown only when a /// chat could actually be kicked off (mirrors `NewChatAffordance.canStart`). private var canStartChat: Bool { store.canControl && !store.dashboard.projects.isEmpty } + /// Height reserved under each tab's content for the floating bar — the measured bar height when + /// it's up, nothing when a session or the composer hides it (so those run full-bleed). + private var contentInset: CGFloat { showsTabBar ? barHeight : 0 } + var body: some View { TabView(selection: $tab) { - Tab("Home", systemImage: "house", value: 0) { HomeView() } - Tab("Sessions", systemImage: "square.stack.3d.up", value: 1) { SessionsView() } - // The "needs you" badge the custom bar carried, back on the native tab item. - .badge(store.needsYouCount) - Tab("Projects", systemImage: "folder", value: 2) { ProjectsView() } - Tab("Settings", systemImage: "gearshape", value: 3) { SettingsView() } + HomeView().toolbar(.hidden, for: .tabBar).tag(0) + SessionsView().toolbar(.hidden, for: .tabBar).tag(1) + ProjectsView().toolbar(.hidden, for: .tabBar).tag(2) + SettingsView().toolbar(.hidden, for: .tabBar).tag(3) } - .newChatEntry(show: $showNewChat, enabled: canStartChat) - // The project-picker composer, presented for the shared "New Chat" entry. + // Each tab's scroll view reads this and reserves room for the floating bar directly on + // itself (via `.compactTabBarInset()`). Neither a `safeAreaInset` nor `contentMargins` on + // the tab root reaches the pages' scroll content through the `TabView`, so it must be applied + // to the `List`/`ScrollView` itself. Zero (its default) on iPad, which has no bar. + .environment(\.compactTabBarInset, contentInset) + // The bar itself lives in an always-mounted overlay and slides on `offset`, so it travels + // continuously off the bottom of the screen (a fixed distance past the home-indicator curve) + // instead of a transition that removes it mid-descent and makes it pop. + .overlay(alignment: .bottom) { + bottomBar + .background( + GeometryReader { geo in + Color.clear.preference(key: BarHeightKey.self, value: geo.size.height) + }) + .onPreferenceChange(BarHeightKey.self) { barHeight = $0 } + .offset(y: showsTabBar ? 0 : barHeight + 120) + } + .animation(reduceMotion ? nil : .spring(response: 0.38, dampingFraction: 0.9), + value: showsTabBar) + // The project-picker composer, presented for the shared "+" (no per-page disc on compact). .newChatAffordance(isPresented: $showNewChat, showsButton: false) // A notification tap routes to its session by jumping to the Sessions tab. .onChange(of: store.pendingRoute) { _, route in @@ -69,30 +103,14 @@ struct CompactRootView: View { if show { tab = 1; store.pendingSessionsList = false } } } -} -private extension View { - /// Hangs the "New Chat" entry off the tab bar: on iOS 26 as a native `tabViewBottomAccessory` - /// (a pill that morphs with the Liquid Glass bar), and on earlier systems as a floating glass - /// "+" above the bar. A no-op when a chat can't be started. - @ViewBuilder - func newChatEntry(show: Binding, enabled: Bool) -> some View { - if !enabled { - self - } else if #available(iOS 26.0, *) { - self.tabViewBottomAccessory { - Button { show.wrappedValue = true } label: { - Label("New Chat", systemImage: "plus") - .font(.body.weight(.semibold)) - .frame(maxWidth: .infinity) - } - .buttonStyle(.plain) - .foregroundStyle(Palette.accent) - .accessibilityLabel("New chat") - } - } else { - self.overlay(alignment: .bottomTrailing) { - Button { show.wrappedValue = true } label: { + /// The floating bottom bar: the tab capsule, with the "+" disc riding to its right in the width + /// freed by dropping the To-dos tab (the iOS 26 tab-bar-plus-accessory shape). + private var bottomBar: some View { + HStack(spacing: 10) { + NucleicTabBar(selection: $tab, needsYou: store.needsYouCount) + if canStartChat { + Button { showNewChat = true } label: { Image(systemName: "plus") .font(.system(size: 22, weight: .semibold)) .foregroundStyle(Palette.accent) @@ -101,14 +119,21 @@ private extension View { .glassCircle() } .buttonStyle(.plain) - .padding(.trailing, 16) - .padding(.bottom, 70) // clear the native tab bar .accessibilityLabel("New chat") } } + .padding(.horizontal, 16) + .padding(.bottom, 4) } } +/// A preference carrying the floating bar's measured height up to `CompactRootView`, which feeds it +/// back into each tab's bottom inset so content sits exactly above the bar. +private struct BarHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 72 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } +} + /// Height the compact shell's floating tab bar wants reserved at the bottom of a tab's scroll /// content, published down the environment by `CompactRootView`. Zero on iPad (no bar). private struct CompactTabBarInsetKey: EnvironmentKey {