Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift
T

470 lines
23 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
@EnvironmentObject var store: RemoteStore
var body: some View {
Group {
if sizeClass == .regular {
SplitRootView()
} else {
CompactRootView()
}
}
// Remote agent sign-in rides one root-level sheet so every entry point — Settings ▸
// Agent Accounts and the in-chat auth-failure banner — presents the same flow.
.sheet(isPresented: Binding(
get: { store.agentLogin != .idle },
set: { if !$0 { store.cancelAgentLogin() } })
) {
AgentLoginSheet()
}
}
}
// MARK: - Compact (iPhone / narrow multitasking)
/// 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.
///
/// 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 (`compactDetailPresented`) 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. `compactDetailPresented` is driven by the session lifecycle (`RemoteStore.open`/
/// `closeOpen`), so the bar hides whenever a session view opens — no matter which tab launched
/// it, not only the Sessions list. `SessionsView` additionally clears it the instant a
/// back-swipe begins (the path empties before `onDisappear` fires) for a snappy re-show.
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)
}
// 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
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() }
}
/// 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 {
static let defaultValue: CGFloat = 0
}
extension EnvironmentValues {
var compactTabBarInset: CGFloat {
get { self[CompactTabBarInsetKey.self] }
set { self[CompactTabBarInsetKey.self] = newValue }
}
}
extension View {
/// Reserve room for the compact shell's floating tab bar at the bottom of a scroll view, so its
/// content clears the bar when scrolled to the end. Apply this directly to the `List`/`ScrollView`
/// inside a tab — a bottom `safeAreaInset` on the scroll view itself reliably insets its content,
/// unlike one on the tab root, which the `TabView` doesn't forward. A no-op at regular width
/// (iPad), where the environment inset is 0.
func compactTabBarInset() -> some View { modifier(CompactTabBarInsetReader()) }
}
private struct CompactTabBarInsetReader: ViewModifier {
@Environment(\.compactTabBarInset) private var inset
func body(content: Content) -> some View {
content.safeAreaInset(edge: .bottom, spacing: 0) {
Color.clear.frame(height: inset)
}
}
}
/// 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
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Ties every item's selected-capsule to one shared geometry, so the "bubble" interpolates its
/// frame from the old tab to the new one instead of cross-fading — the native bar's slide.
@Namespace private var bubble
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, 5)
.glassBar()
// Drive the bubble's slide (and the label/tint crossfade) off the selection itself, so any
// change — tap here or a programmatic jump from a notification — springs across.
.animation(reduceMotion ? nil : .spring(response: 0.32, dampingFraction: 0.78),
value: selection)
}
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, 8)
// The selected item rides a soft accent-tinted capsule that slides between tabs — only
// the selected item mounts it, and the shared `bubble` geometry makes the single capsule
// travel to the new tab, echoing the system floating bar's highlighted-tab indicator.
.background {
if selected {
Capsule()
.fill(Palette.accent.opacity(0.15))
.matchedGeometryEffect(id: "selectionBubble", in: bubble)
}
}
.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 mesh…")
.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)
}
}
}