Brings the phone's ambient surfaces (UX_IOS §5/§8) to maturity: - Notification pipeline (NotificationRouter): local notifications for approvals and needs-input transitions while backgrounded; low-risk approvals are actionable from the banner (Allow requires device auth, high-risk must open the app's Face ID gate); taps deep-link to the session; app-icon badge = NEEDS YOU count; resolutions withdraw the notification (first-responder-wins). The relay's content-free approval.pending tickle localizes via Localizable.strings. - Live Activity: new NucleicRemoteWidgets extension target (lock screen + Dynamic Island) rendering one aggregate Activity — N running / M waiting + the most urgent session — started/updated/ ended by LiveActivityManager as session state changes. - Out-of-band push path: nucleic-edge gains POST /v1/push/register (admin) so the host can upload tokens for LAN-only pairings; the host's new PushRelayClient (config-gated on NUCLEIC_RELAY_URL + NUCLEIC_RELAY_ADMIN_SECRET) mirrors Hello.pushToken to the relay and wakes non-connected phones when an approval arrives, throttled per device. Everything stays off until the relay is provisioned. Worker tests (23) and Swift suites pass apart from the pre-existing fixture gaps and nvrsion flake. Simulated APNs delivery is blocked in this environment (notification auth can't be granted headlessly). Co-Authored-By: Claude Fable 5 <[email protected]>
113 lines
5.3 KiB
Swift
113 lines
5.3 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|