import SwiftUI @main struct NucleicRemoteApp: App { @StateObject private var store = RemoteStore() @Environment(\.scenePhase) private var scenePhase @UIApplicationDelegateAdaptor(PushAppDelegate.self) private var pushDelegate var body: some Scene { WindowGroup { RootView() .environmentObject(store) .onAppear { store.onAppear() // Notification categories + delegate must be in place before any // notification can arrive, so actions/taps always route. NotificationRouter.shared.install(store: store) // Surface the notifications prompt + register for APNS. The token rides along // in the sync Hello; the relay uses it to wake the phone for approvals (§3). // Skipped in demo mode so offline UI previews aren't blocked by the system dialog. if ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] != "1" { PushRegistrar.shared.requestAuthorizationAndRegister() } // Anonymous, opt-out, once-a-day DAI heartbeat (docs/CLOUD_INFRA.md §4). Task { await HeartbeatReporter.reportIfDue() } } // Re-check on every foreground so a new active day is counted; keep the store's // activity flag current so it only notifies for background transitions. .onChange(of: scenePhase) { _, phase in store.setScenePhaseActive(phase == .active) if phase == .active { Task { await HeartbeatReporter.reportIfDue() } } } } } } struct RootView: View { @EnvironmentObject var store: RemoteStore // Initial tab; overridable via NUCLEIC_TAB for offline UI previews. @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0 // Appearance preferences — same storage keys and semantics as the Mac's Settings → // Appearance, so both devices honor the same choices. @AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue @AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue @AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue var body: some View { VStack(spacing: 0) { // The same non-release warning strip the Mac window carries (dev/canary/beta/rc). BuildBanner() Group { if store.isPaired { 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) } } else { PairingIntroView() } } } .overlay(alignment: .bottom) { ErrorBubble() } // A notification tap routes to its session: jump to the Sessions tab, where // `SessionsView` consumes `pendingRoute` and pushes the detail. .onChange(of: store.pendingRoute) { _, route in if route != nil { tab = 1 } } .tint(Palette.accent) .preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme) .dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize) // The palette resolves against the stored color-vision mode at render time; rebuild // the hierarchy when it changes so every cached view picks up the remapped colors. .id(colorVisionRaw) } } /// A transient red bubble for host-reported errors — the mobile echo of the Mac's last-error /// overlay at the detail pane's bottom. Auto-dismissed by the store after a few seconds; a tap /// dismisses it immediately. private struct ErrorBubble: View { @EnvironmentObject var store: RemoteStore var body: some View { if let error = store.lastError { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") Text(error.message).lineLimit(3) Spacer(minLength: 0) Image(systemName: "xmark").opacity(0.7) } .font(.footnote.weight(.medium)) .foregroundStyle(.white) .padding(.horizontal, 12) .padding(.vertical, 10) .background(Palette.danger, in: RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 16) .padding(.bottom, 58) // clear the tab bar .onTapGesture { store.dismissError() } .transition(.move(edge: .bottom).combined(with: .opacity)) .animation(.easeInOut(duration: 0.2), value: store.lastError) } } }