From 84a339a0e7c54352f471b313bf1af04f9411b795 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 18:49:28 -0700 Subject: [PATCH] Tab Bar Load Animation Nucleic-Session: EEC60FAE-8368-49CA-9202-7026C12E7F8C Co-authored-by: Nucleic --- .../Views/AdaptiveRootView.swift | 89 ++++++++++++++----- 1 file changed, 66 insertions(+), 23 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index 9defdfe..276343d 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -38,31 +38,51 @@ struct CompactRootView: View { @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"). + @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 — so it's hidden exactly while a session detail is on screen /// (`openSessionID` is set by `SessionDetailView`'s appear/disappear, our animation trigger). private var showsTabBar: Bool { store.openSessionID == nil } + /// 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 } + var body: some View { TabView(selection: $tab) { HomeView().tag(0).toolbar(.hidden, for: .tabBar) SessionsView().tag(1).toolbar(.hidden, for: .tabBar) ProjectsView().tag(2).toolbar(.hidden, for: .tabBar) - TodosView().tag(3).toolbar(.hidden, for: .tabBar) - SettingsView().tag(4).toolbar(.hidden, for: .tabBar) + SettingsView().tag(3).toolbar(.hidden, for: .tabBar) } - // Our bar sits in a bottom inset so top-level content (and each tab's floating "+") lays - // out above it; a pushed session sees the inset collapse and runs its transcript full-bleed - // to the bottom. Both the slide and that reflow ride the one spring below. + // Reserve exactly the bar's height so top-level content (and each tab's own bottom chrome) + // lays out above the floating bar rather than under it; collapses to nothing when a session + // hides the bar so the transcript runs full-bleed. .safeAreaInset(edge: .bottom, spacing: 0) { - if showsTabBar { - NucleicTabBar(selection: $tab, needsYou: store.needsYouCount) - .transition(.move(edge: .bottom)) - } + Color.clear.frame(height: showsTabBar ? barHeight : 0) } - .animation(reduceMotion ? nil : .spring(response: 0.34, dampingFraction: 0.92), + // 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 if route != nil { tab = 1 } @@ -72,15 +92,43 @@ 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: { + Image(systemName: "plus") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(Palette.accent) + .frame(width: 56, height: 56) + .contentShape(Circle()) + .glassCircle() + } + .buttonStyle(.plain) + .accessibilityLabel("New chat") + } + } + .padding(.horizontal, 16) + .padding(.bottom, 4) + } } -/// Our own bottom tab bar for the compact shell — a floating Liquid Glass capsule of five items, -/// standing in for the system tab bar so `CompactRootView` can animate its visibility itself (see -/// that view for why) while keeping the iOS 26 floating-glass look. Real `glassEffect` on iOS 26; -/// an ultra-thin material capsule with a hairline edge + soft shadow on earlier systems, matching -/// the app's `glassSurface`/`glassCircle`. It floats inset from the edges, above the home -/// indicator — it does not bleed to the screen edge. The Sessions item carries the "needs you" -/// badge the system tab item used to. +/// A preference carrying the floating bar's measured height up to `CompactRootView`, which feeds it +/// back into the bottom safe-area 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() } +} + +/// Our own bottom tab bar for the compact shell — a floating Liquid Glass capsule of four items +/// (Home, Sessions, Projects, Settings), standing in for the system tab bar so `CompactRootView` +/// can animate its visibility itself (see that view for why) while keeping the iOS 26 floating-glass +/// look. Real `glassEffect` on iOS 26; an ultra-thin material capsule with a hairline edge + soft +/// shadow on earlier systems, matching the app's `glassSurface`/`glassCircle`. The Sessions item +/// carries the "needs you" badge the system tab item used to. struct NucleicTabBar: View { @Binding var selection: Int /// Sessions blocking on a human — the red badge on the Sessions item (was the tab item badge). @@ -91,8 +139,7 @@ struct NucleicTabBar: View { Item(index: 0, title: "Home", icon: "house"), Item(index: 1, title: "Sessions", icon: "square.stack.3d.up"), Item(index: 2, title: "Projects", icon: "folder"), - Item(index: 3, title: "To-dos", icon: "checklist"), - Item(index: 4, title: "Settings", icon: "gearshape"), + Item(index: 3, title: "Settings", icon: "gearshape"), ] var body: some View { @@ -104,10 +151,6 @@ struct NucleicTabBar: View { .padding(.horizontal, 6) .padding(.vertical, 8) .glassBar() - // Float the capsule inset from the edges and just above the home indicator, rather than - // filling the bottom edge like the old opaque bar. - .padding(.horizontal, 16) - .padding(.bottom, 4) } private func itemButton(_ item: Item) -> some View {