Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift
T
abkslmandNucleic 311f7e2237 Tab Bar Load Animation
Nucleic-Session: EEC60FAE-8368-49CA-9202-7026C12E7F8C
Co-authored-by: Nucleic <[email protected]>
2026-07-05 19:05:24 -07:00

424 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import NucleicProtocol
/// Chooses the navigation shell by width so one universal binary serves both idioms:
/// • **compact** (iPhone, and iPad in Slide Over / narrow split) → the five-tab `TabView`.
/// • **regular** (iPad full-screen / Stage Manager) → the Mac's sidebar + detail split.
///
/// Both render the SAME `RemoteStore` projection — the iPad is a third renderer of the one host
/// authority (UX_MACOS §8), not a fork. The store holds all real state, so a size-class flip
/// (e.g. resizing a Stage Manager window) only swaps chrome; sessions, connection, and the open
/// transcript survive it.
struct AdaptiveRootView: View {
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
if sizeClass == .regular {
SplitRootView()
} else {
CompactRootView()
}
}
}
// 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.
///
/// 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").
@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 (`openSessionID`, set by `SessionDetailView`'s appear/disappear) 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.
private var showsTabBar: Bool { store.openSessionID == nil && !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) {
// The inset is applied per tab (not to the `TabView`): a bottom `safeAreaInset` on the
// container does NOT propagate into the tab pages' scroll content, so the list would run
// under the bar. On each tab's `NavigationStack` it reaches the scroll content and pushes
// it up above the bar.
HomeView().modifier(TabBarInset(height: contentInset)).tag(0)
SessionsView().modifier(TabBarInset(height: contentInset)).tag(1)
ProjectsView().modifier(TabBarInset(height: contentInset)).tag(2)
SettingsView().modifier(TabBarInset(height: contentInset)).tag(3)
}
// 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 }
}
// A Live Activity tap with work running but nothing waiting: show the Sessions list.
.onChange(of: store.pendingSessionsList) { _, show in
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)
}
}
/// 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() }
}
/// Per-tab chrome for the compact shell: suppress the system tab bar (we draw our own) and reserve
/// `height` at the bottom of this tab's content so its scroll view lays out above the floating bar.
/// Applied to each tab rather than the `TabView`, whose container-level inset doesn't reach the tab
/// pages' scroll content.
private struct TabBarInset: ViewModifier {
let height: CGFloat
func body(content: Content) -> some View {
content
.toolbar(.hidden, for: .tabBar)
.safeAreaInset(edge: .bottom, spacing: 0) {
Color.clear.frame(height: height)
}
}
}
/// 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).
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: "Settings", icon: "gearshape"),
]
var body: some View {
HStack(spacing: 0) {
ForEach(items, id: \.index) { item in
itemButton(item)
}
}
.padding(.horizontal, 6)
.padding(.vertical, 8)
.glassBar()
}
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: 21, weight: selected ? .semibold : .regular))
.frame(height: 24)
.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)
.padding(.vertical, 4)
// The selected item rides a soft accent-tinted capsule, echoing the system floating
// bar's highlighted tab.
.background {
if selected {
Capsule().fill(Palette.accent.opacity(0.15))
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(item.title)
.accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
}
}
private extension View {
/// The floating tab bar's Liquid Glass capsule backing — the capsule twin of `glassSurface`.
/// Real `glassEffect` on iOS 26; an ultra-thin material capsule with a hairline edge and soft
/// shadow on earlier systems so it still lifts off the content.
@ViewBuilder
func glassBar() -> some View {
if #available(iOS 26.0, *) {
glassEffect(.regular, in: .capsule)
} else {
background(.ultraThinMaterial, in: Capsule())
.overlay(Capsule().strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
.shadow(color: .black.opacity(0.18), radius: 10, y: 3)
}
}
}
// MARK: - Regular (iPad sidebar + detail)
/// What the sidebar can select. Mirrors the Mac sidebar: a few global destinations plus the
/// live sessions themselves (grouped by project). Projects are reached through the `projects`
/// destination (`ProjectsView`), which also hosts the scoped "start a chat here" composer.
enum SidebarItem: Hashable {
case home
case projects
case todos
case settings
case session(SessionID)
}
/// The Mac's information architecture on iPad: a two-column `NavigationSplitView` whose leading
/// column carries the destinations + the project→session tree, and whose detail is the selected
/// session (or a destination view). Selection lives here and binds the same `openSessionID`
/// lifecycle the iPhone push/pop path uses — the detail is keyed by session id so switching one
/// session for another fires `open`/`closeOpen` exactly as a push/pop would.
struct SplitRootView: View {
@EnvironmentObject var store: RemoteStore
@State private var selection: SidebarItem? = .home
@State private var columnVisibility: NavigationSplitViewVisibility = .all
/// One-shot: a Cmd+N sets this, the Home composer consumes it to grab focus.
@State private var focusNewChat = false
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
SplitSidebar(selection: $selection, focusNewChat: $focusNewChat)
} detail: {
SplitDetail(selection: selection, focusNewChat: $focusNewChat)
}
.navigationSplitViewStyle(.balanced)
.background { keyboardShortcuts }
// Notification tap → select its session directly (the regular-width analogue of the
// compact path's jump-to-Sessions-tab). Then clear the request so it isn't re-consumed.
.onChange(of: store.pendingRoute) { _, route in
guard let route else { return }
selection = .session(route)
store.pendingRoute = nil
}
// A Live Activity tap with work running but nothing waiting: reveal the sidebar, which
// always lists the live sessions (the regular-width analogue of the Sessions tab).
.onChange(of: store.pendingSessionsList) { _, show in
guard show else { return }
columnVisibility = .all
store.pendingSessionsList = false
}
.onAppear {
// Offline UI previews (NUCLEIC_DEMO): open straight into one session.
if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"], !raw.isEmpty {
selection = .session(SessionID(rawValue: raw))
}
}
}
/// Hardware-keyboard shortcuts (Magic Keyboard on iPad), the remote's echo of the Mac's menu
/// commands: ⌘N new chat, ⌘1–4 jump to a destination, ⌘R refresh. Carried by hidden buttons —
/// SwiftUI still routes a shortcut to a zero-opacity button that's in the hierarchy.
private var keyboardShortcuts: some View {
Group {
Button("New chat") { selection = .home; focusNewChat = true }
.keyboardShortcut("n", modifiers: .command)
Button("Home") { selection = .home }.keyboardShortcut("1", modifiers: .command)
Button("Projects") { selection = .projects }.keyboardShortcut("2", modifiers: .command)
Button("To-dos") { selection = .todos }.keyboardShortcut("3", modifiers: .command)
Button("Settings") { selection = .settings }.keyboardShortcut("4", modifiers: .command)
Button("Refresh") { store.refreshSessions() }.keyboardShortcut("r", modifiers: .command)
}
.opacity(0)
.accessibilityHidden(true)
}
}
/// The leading column: global destinations, then the live sessions grouped under their projects
/// (attention-first within each group), mirroring the Mac sidebar tree. The persistent
/// connection status sits in the footer — one home for it, versus the phone's per-tab inset.
private struct SplitSidebar: View {
@EnvironmentObject var store: RemoteStore
@Binding var selection: SidebarItem?
var focusNewChat: Binding<Bool> = .constant(false)
/// Live sessions grouped under their project (dashboard order), with anything whose project
/// isn't in the dashboard collected into a trailing "Other" group. Attention-first sort.
private var projectGroups: [(name: String, rows: [WireSessionSummary])] {
let live = store.liveSessions.sorted(by: StatusStyle.attentionThenRecency)
var groups: [(name: String, rows: [WireSessionSummary])] = []
var placed = Set<SessionID>()
for project in store.dashboard.projects {
let rows = live.filter { $0.projectID == project.id.rawValue }
guard !rows.isEmpty else { continue }
groups.append((project.name, rows))
rows.forEach { placed.insert($0.sessionID) }
}
let orphans = live.filter { !placed.contains($0.sessionID) }
if !orphans.isEmpty { groups.append(("Other", orphans)) }
return groups
}
var body: some View {
List(selection: $selection) {
Section {
Label("Home", systemImage: "house").tag(SidebarItem.home)
Label("Projects", systemImage: "folder").tag(SidebarItem.projects)
Label("To-dos", systemImage: "checklist").tag(SidebarItem.todos)
Label("Settings", systemImage: "gearshape").tag(SidebarItem.settings)
}
if projectGroups.isEmpty {
Section("Sessions") {
Text(store.connectivity.isLive
? "Start a session from Home or the Mac to see it here."
: "Waiting to connect to your Mac…")
.font(.callout).foregroundStyle(.secondary)
}
} else {
ForEach(projectGroups, id: \.name) { group in
Section(group.name) {
ForEach(group.rows, id: \.sessionID) { summary in
SessionRow(summary: summary, showProjectName: false)
.tag(SidebarItem.session(summary.sessionID))
.listRowBackground(SessionRowWash(summary: summary))
.swipeActions(edge: .leading) {
Button {
store.setFavorite(summary.sessionID, !summary.favorite)
} label: {
Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill")
}.tint(.yellow)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
store.deleteSession(summary.sessionID)
} label: { Label("Delete", systemImage: "trash") }
Button {
store.setArchived(summary.sessionID, !summary.archived)
} label: {
Label(summary.archived ? "Unarchive" : "Archive",
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
}.tint(.gray)
}
}
}
}
}
}
.listStyle(.sidebar)
// Every Mac's sessions are merged into this sidebar (mesh P3), so a single host name would
// misrepresent it — title it for the app.
.navigationTitle("Nucleic")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
selection = .home
focusNewChat.wrappedValue = true
} label: {
Image(systemName: "square.and.pencil")
}
.help("New chat (⌘N)")
.disabled(!store.canControl)
}
ToolbarItem(placement: .primaryAction) {
Button { store.refreshSessions() } label: {
Image(systemName: "arrow.clockwise")
}
.help("Refresh (⌘R)")
}
}
}
}
/// The detail column. A selected session reuses the existing `SessionDetailView` unchanged
/// (it owns its own subscribe/unsubscribe on appear/disappear); it's keyed `.id(sessionID)` so
/// switching sessions remounts and drives that lifecycle. The destination views already embed
/// their own `NavigationStack`, so they're shown directly.
private struct SplitDetail: View {
let selection: SidebarItem?
var focusNewChat: Binding<Bool> = .constant(false)
var body: some View {
switch selection {
case .home, .none:
HomeView(focusNewChat: focusNewChat)
case .projects:
ProjectsView()
case .todos:
TodosView()
case .settings:
SettingsView()
case .session(let id):
NavigationStack {
SessionDetailView(sessionID: id)
}
.id(id)
}
}
}