diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index fd005d8..f248d0a 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -23,72 +23,42 @@ struct AdaptiveRootView: View { // MARK: - Compact (iPhone / narrow multitasking) -/// The iPhone shell: a five-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. 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. +/// 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"). /// -/// A notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute` -/// and pushes the detail. +/// **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. 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 "+" beside the tab bar opens the project-picker composer (the per-page discs are - /// gone on compact — this one bar owns "new chat"). + /// The shared "New Chat" entry opens the project-picker composer (the per-page discs are gone on + /// compact — this one entry 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) { - 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) + 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() } } - // 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). + .newChatEntry(show: $showNewChat, enabled: canStartChat) + // The project-picker composer, presented for the shared "New Chat" entry. .newChatAffordance(isPresented: $showNewChat, showsButton: false) // A notification tap routes to its session by jumping to the Sessions tab. .onChange(of: store.pendingRoute) { _, route in @@ -99,14 +69,30 @@ struct CompactRootView: View { if show { tab = 1; store.pendingSessionsList = false } } } +} - /// 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: { +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: { Image(systemName: "plus") .font(.system(size: 22, weight: .semibold)) .foregroundStyle(Palette.accent) @@ -115,21 +101,14 @@ struct CompactRootView: 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 { diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 4389191..9772ef6 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -82,6 +82,10 @@ struct SessionsView: View { .navigationTitle("Sessions") .navigationDestination(for: SessionID.self) { id in SessionDetailView(sessionID: id) + // Native-tab-bar experiment: hide the system bar on the pushed detail so the + // full-bleed chat owns the bottom edge. Whether it restores smoothly on pop-back + // (vs. the old ~1s un-animated snap) is exactly what we're testing here. + .toolbar(.hidden, for: .tabBar) } // Offline UI previews (with NUCLEIC_DEMO): jump straight into one session. .onAppear {