ios: full feature/appearance parity with the macOS app (client-side)
Port the Mac's MarkdownText renderer, AppPalette (color-vision modes + night-softening), appearance settings (theme/text size/color vision), and BuildBanner channel strip to the iPhone remote. Add session-row attention/unseen-completion washes and marker icons, Discard action, branch/worktree options in the new-chat composer, and a transient error bubble for host wire errors. Stamp the build channel via NucleicChannel in Info.plist (ios-release.sh passes NUCLEIC_CHANNEL, default beta). Demo mode gains NUCLEIC_DEMO_SESSION and skips the notifications prompt so UI previews are scriptable. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -14,7 +14,10 @@ struct NucleicRemoteApp: App {
|
||||
store.onAppear()
|
||||
// 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).
|
||||
PushRegistrar.shared.requestAuthorizationAndRegister()
|
||||
// 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() }
|
||||
}
|
||||
@@ -30,27 +33,70 @@ 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 {
|
||||
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)
|
||||
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()
|
||||
}
|
||||
} else {
|
||||
PairingIntroView()
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) { ErrorBubble() }
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user