diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index 9c4a02b..ef4c872 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -23,28 +23,46 @@ struct AdaptiveRootView: View { // MARK: - Compact (iPhone / narrow multitasking) -/// The original iPhone shell, relocated verbatim from `RootView`: a five-tab `TabView`. A -/// notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute` +/// 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. +/// +/// 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 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 } + var body: some View { TabView(selection: $tab) { - HomeView() - .tabItem { Label("Home", systemImage: "house") }.tag(0) - SessionsView() - .tabItem { Label("Sessions", systemImage: "square.stack.3d.up") } - .badge(store.needsYouCount).tag(1) - ProjectsView() - .tabItem { Label("Projects", systemImage: "folder") }.tag(2) - TodosView() - .tabItem { Label("To-dos", systemImage: "checklist") }.tag(3) - SettingsView() - .tabItem { Label("Settings", systemImage: "gearshape") }.tag(4) + 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) } + // 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. + .safeAreaInset(edge: .bottom, spacing: 0) { + if showsTabBar { + NucleicTabBar(selection: $tab, needsYou: store.needsYouCount) + .transition(.move(edge: .bottom)) + } + } + .animation(reduceMotion ? nil : .spring(response: 0.34, dampingFraction: 0.92), + value: showsTabBar) // A notification tap routes to its session by jumping to the Sessions tab. .onChange(of: store.pendingRoute) { _, route in if route != nil { tab = 1 } @@ -56,6 +74,73 @@ struct CompactRootView: View { } } +/// Our own bottom tab bar for the compact shell — a plain `.bar`-material strip of five items, +/// standing in for the system tab bar so `CompactRootView` can animate its visibility itself +/// (see that view for why). The material bleeds into the home-indicator area; the items stay in +/// the safe region. 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). + var needsYou: Int + + private struct Item { let index: Int; let title: String; let icon: String } + private let items = [ + 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"), + ] + + var body: some View { + HStack(spacing: 0) { + ForEach(items, id: \.index) { item in + itemButton(item) + } + } + .padding(.top, 8) + .padding(.bottom, 2) + // A hairline top separator, like the system bar's edge over scrolling content. + .overlay(alignment: .top) { + Rectangle().fill(Color.primary.opacity(0.10)).frame(height: 0.5) + } + // `.bar` is the material the system uses for tab/tool bars; bleed it into the bottom + // unsafe area so it reaches the screen edge under the home indicator. + .background(.bar, ignoresSafeAreaEdges: .bottom) + } + + private func itemButton(_ item: Item) -> some View { + let selected = selection == item.index + return Button { + selection = item.index + } label: { + VStack(spacing: 3) { + Image(systemName: item.icon) + .font(.system(size: 22, weight: selected ? .semibold : .regular)) + .frame(height: 26) + .overlay(alignment: .topTrailing) { + if item.index == 1, needsYou > 0 { + Text(needsYou > 99 ? "99+" : "\(needsYou)") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(Palette.attention, in: Capsule()) + .offset(x: 14, y: -6) + .fixedSize() + } + } + Text(item.title).font(.system(size: 10, weight: .medium)) + } + .foregroundStyle(selected ? Palette.accent : Color.secondary) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(item.title) + .accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton) + } +} + // MARK: - Regular (iPad sidebar + detail) /// What the sidebar can select. Mirrors the Mac sidebar: a few global destinations plus the