nvrsion: Add review feature access for iOS remote: Enable reviewers to test all features in App Store (or TestFlight) reviews by updating BUILD.md, docs/APP_REVIEW_NOTES.md, RemoteStore.swift, RemoteApp.swift, BuildBanner.swift, and SettingsView.swift.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -145,9 +145,18 @@ final class RemoteStore: ObservableObject {
|
|||||||
private var seenSeq: Set<UInt64> = []
|
private var seenSeq: Set<UInt64> = []
|
||||||
private var reconnectAttempts = 0
|
private var reconnectAttempts = 0
|
||||||
|
|
||||||
/// Offline UI-preview mode (set `NUCLEIC_DEMO=1`): seeds mock state so the surfaces render
|
/// Offline demo mode: seeds mock state and simulates the agent locally so every surface
|
||||||
/// without a paired Mac. No effect on real use.
|
/// renders — and the core loops (send, approve, start chat, to-dos) actually respond —
|
||||||
private let demoMode = ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] == "1"
|
/// without a paired Mac. Reachable in two ways: the `NUCLEIC_DEMO=1` env var (dev /
|
||||||
|
/// screenshots, forced on at launch) and a persisted in-app toggle
|
||||||
|
/// (`enterDemo()` / `exitDemo()`) so an App Store reviewer with no Mac can exercise the app
|
||||||
|
/// (App Review Guideline 2.1). `@Published` so the UI can show a DEMO indicator and the
|
||||||
|
/// "Leave demo" affordance.
|
||||||
|
private static let demoModeKey = "nucleic.demoMode"
|
||||||
|
@Published private(set) var demoMode = ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] == "1"
|
||||||
|
|| UserDefaults.standard.bool(forKey: RemoteStore.demoModeKey)
|
||||||
|
/// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
|
||||||
|
private var demoTasks: [Task<Void, Never>] = []
|
||||||
|
|
||||||
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
|
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
|
||||||
var deviceFingerprint: String { identity.fingerprint }
|
var deviceFingerprint: String { identity.fingerprint }
|
||||||
@@ -317,6 +326,40 @@ final class RemoteStore: ObservableObject {
|
|||||||
NotificationRouter.shared.updateBadge(0)
|
NotificationRouter.shared.updateBadge(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enter the in-app demo (the "Explore a demo" button): drop any live connection, persist the
|
||||||
|
/// flag so it survives relaunch (a reviewer may relaunch), and seed the mock world. `isPaired`
|
||||||
|
/// then returns true, so `RootView` shows the full TabView.
|
||||||
|
func enterDemo() {
|
||||||
|
teardown()
|
||||||
|
demoMode = true
|
||||||
|
UserDefaults.standard.set(true, forKey: Self.demoModeKey)
|
||||||
|
reconnectAttempts = 0
|
||||||
|
seedDemo()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave the demo (Settings ▸ Leave demo): cancel any simulated runs, clear the mock world,
|
||||||
|
/// and return to the real state — reconnect if a Mac is actually paired, else the pairing intro.
|
||||||
|
func exitDemo() {
|
||||||
|
demoMode = false
|
||||||
|
UserDefaults.standard.set(false, forKey: Self.demoModeKey)
|
||||||
|
demoTasks.forEach { $0.cancel() }
|
||||||
|
demoTasks.removeAll()
|
||||||
|
openSessionID = nil
|
||||||
|
openEvents = []
|
||||||
|
openApprovals = []
|
||||||
|
openDiff = nil
|
||||||
|
diffLoading = false
|
||||||
|
sessions = []
|
||||||
|
dashboard = .empty
|
||||||
|
LiveActivityManager.shared.end()
|
||||||
|
NotificationRouter.shared.updateBadge(0)
|
||||||
|
if IdentityStore.loadPairedHost() != nil {
|
||||||
|
reconnect()
|
||||||
|
} else {
|
||||||
|
connectivity = .unpaired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Intents (UX_IOS §9)
|
// MARK: - Intents (UX_IOS §9)
|
||||||
|
|
||||||
func open(_ sessionID: SessionID) {
|
func open(_ sessionID: SessionID) {
|
||||||
@@ -371,6 +414,21 @@ final class RemoteStore: ObservableObject {
|
|||||||
event(14, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
|
event(14, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
|
||||||
event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))),
|
event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))),
|
||||||
]
|
]
|
||||||
|
// If this session is blocked on a human, surface a real approval card so the
|
||||||
|
// Allow/Deny loop is exercisable in the demo (the seeded `a1` session).
|
||||||
|
if sessions.first(where: { $0.sessionID == sessionID })?.status == .awaitingApproval {
|
||||||
|
openApprovals = [ApprovalRequest(
|
||||||
|
id: Self.demoApprovalID(for: sessionID),
|
||||||
|
sessionID: sessionID, toolCallID: "t-appr", toolName: "Bash",
|
||||||
|
input: ["command": "npm run deploy"], title: "Run npm run deploy",
|
||||||
|
risk: .execute, createdAt: Date())]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic approval id for a demo session's seeded approval, so re-opening the same
|
||||||
|
/// session doesn't stack duplicate cards.
|
||||||
|
private static func demoApprovalID(for sessionID: SessionID) -> ApprovalID {
|
||||||
|
ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func closeOpen() {
|
func closeOpen() {
|
||||||
@@ -449,10 +507,191 @@ final class RemoteStore: ObservableObject {
|
|||||||
// MARK: - Plumbing
|
// MARK: - Plumbing
|
||||||
|
|
||||||
private func send(_ msg: ClientMsg) {
|
private func send(_ msg: ClientMsg) {
|
||||||
|
if demoMode { demoHandle(msg); return }
|
||||||
guard let client else { return }
|
guard let client else { return }
|
||||||
Task { await client.send(msg) }
|
Task { await client.send(msg) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Demo simulator (offline, interactive)
|
||||||
|
//
|
||||||
|
// In demo mode there's no host, so writes can't go over the wire. Instead they mutate the
|
||||||
|
// already-`@Published` projection directly and (for the agent loop) stream a short canned run,
|
||||||
|
// so a reviewer can send messages, approve actions, start chats, and manage to-dos and see the
|
||||||
|
// UI respond — the read-only fixtures (`seedDemo`/`seedDemoTranscript`/`demoDiff`) already
|
||||||
|
// cover the passive surfaces.
|
||||||
|
|
||||||
|
private func demoHandle(_ msg: ClientMsg) {
|
||||||
|
switch msg {
|
||||||
|
case .sendInput(let id, let input):
|
||||||
|
demoRun(id, userText: input.plainText ?? "")
|
||||||
|
case .startChat(let req):
|
||||||
|
demoStartChat(req)
|
||||||
|
case .approvalRespond(let id, let decision):
|
||||||
|
demoResolveApproval(id, decision)
|
||||||
|
case .captureTodo(let req):
|
||||||
|
demoCaptureTodo(req)
|
||||||
|
case .setTodoStatus(let id, let status):
|
||||||
|
demoSetTodoStatus(id, status)
|
||||||
|
case .deleteTodo(let id):
|
||||||
|
demoMutateTodos { $0.filter { $0.id != id } }
|
||||||
|
case .dispatchTodo(let id, _):
|
||||||
|
demoSetTodoStatus(id, .dispatched)
|
||||||
|
case .renameSession(let id, let title):
|
||||||
|
demoUpdateSession(id) { $0.demoCopy(title: title) }
|
||||||
|
case .setFavorite(let id, let favorite):
|
||||||
|
demoUpdateSession(id) { $0.demoCopy(favorite: favorite) }
|
||||||
|
case .setArchived(let id, let archived):
|
||||||
|
demoUpdateSession(id) { $0.demoCopy(archived: archived) }
|
||||||
|
case .deleteSession(let id):
|
||||||
|
sessions.removeAll { $0.sessionID == id }
|
||||||
|
NotificationRouter.shared.updateBadge(needsYouCount)
|
||||||
|
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
|
||||||
|
case .discard(let id):
|
||||||
|
sessions.removeAll { $0.sessionID == id }
|
||||||
|
NotificationRouter.shared.updateBadge(needsYouCount)
|
||||||
|
case .interrupt(let id):
|
||||||
|
demoUpdateSession(id) { $0.demoCopy(status: .interrupted, disposition: .some(nil)) }
|
||||||
|
case .integrate(let id, _):
|
||||||
|
demoAppend(id, .note(NoteEvent(text: "nvrsion: Landed to trunk.", icon: "arrow.triangle.branch")))
|
||||||
|
demoUpdateSession(id) { $0.demoCopy(status: .finished, disposition: .some(.completed)) }
|
||||||
|
case .setSessionModel(let id, let model):
|
||||||
|
demoUpdateSession(id) { $0.demoCopy(model: .some(model)) }
|
||||||
|
case .setSessionEffort(let id, let effort):
|
||||||
|
demoUpdateSession(id) { $0.demoCopy(effort: .some(effort)) }
|
||||||
|
case .setSessionAuto(let id, let auto):
|
||||||
|
demoUpdateSession(id) { $0.demoCopy(auto: auto) }
|
||||||
|
case .setSessionAutoShip(let id, let autoShip):
|
||||||
|
demoUpdateSession(id) { $0.demoCopy(autoShip: autoShip) }
|
||||||
|
case .setSessionShipBranch(let id, let branch):
|
||||||
|
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
|
||||||
|
case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
|
||||||
|
.ping, .cancelQueuedMessage, .fetchDiff:
|
||||||
|
break // passive / already handled by the seeded fixtures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a transcript event to the open session (no-op if it isn't the one on screen).
|
||||||
|
private func demoAppend(_ sessionID: SessionID, _ kind: AgentEvent.Kind) {
|
||||||
|
guard sessionID == openSessionID else { return }
|
||||||
|
let seq = (openEvents.map(\.seq).max() ?? 0) + 1
|
||||||
|
let backend = sessions.first { $0.sessionID == sessionID }?.backend ?? .claudeCode
|
||||||
|
openEvents.append(AgentEvent(
|
||||||
|
sessionID: sessionID, seq: seq, at: Date(), backend: backend, nativeType: nil, kind: kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace a session summary in the list, keeping the badge / Live Activity in sync.
|
||||||
|
private func demoUpdateSession(_ id: SessionID, _ transform: (WireSessionSummary) -> WireSessionSummary) {
|
||||||
|
guard let i = sessions.firstIndex(where: { $0.sessionID == id }) else { return }
|
||||||
|
sessions[i] = transform(sessions[i])
|
||||||
|
NotificationRouter.shared.updateBadge(needsYouCount)
|
||||||
|
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream a short, believable canned turn for a session: assistant prose, a tool call +
|
||||||
|
/// result, usage, and a finish — flipping the summary running → awaiting-input. Each step
|
||||||
|
/// re-checks demo mode and cancellation so `exitDemo()` stops it cleanly. Runs entirely on the
|
||||||
|
/// main actor (the store is `@MainActor`, and a `Task` in an isolated method inherits it).
|
||||||
|
private func demoRun(_ sessionID: SessionID, userText: String?) {
|
||||||
|
if let userText, !userText.isEmpty {
|
||||||
|
demoAppend(sessionID, .userText(TextChunk(messageID: UUID().uuidString, text: userText, isPartial: false)))
|
||||||
|
}
|
||||||
|
demoUpdateSession(sessionID) { $0.demoCopy(status: .running, disposition: .some(nil)) }
|
||||||
|
let toolID = UUID().uuidString
|
||||||
|
let task = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
@MainActor func pause(_ ms: Int) async -> Bool {
|
||||||
|
try? await Task.sleep(for: .milliseconds(ms))
|
||||||
|
if Task.isCancelled { return false }
|
||||||
|
return self.demoMode
|
||||||
|
}
|
||||||
|
guard await pause(600) else { return }
|
||||||
|
self.demoAppend(sessionID, .assistantText(TextChunk(
|
||||||
|
messageID: UUID().uuidString, text: "On it — let me take a look.", isPartial: false)))
|
||||||
|
guard await pause(700) else { return }
|
||||||
|
self.demoAppend(sessionID, .toolCallStarted(ToolCall(
|
||||||
|
toolCallID: toolID, name: "Bash", input: ["command": "npm test"])))
|
||||||
|
guard await pause(900) else { return }
|
||||||
|
self.demoAppend(sessionID, .toolResult(ToolResult(
|
||||||
|
toolCallID: toolID, content: "42 passing\n0 failing", isError: false)))
|
||||||
|
guard await pause(700) else { return }
|
||||||
|
self.demoAppend(sessionID, .assistantText(TextChunk(
|
||||||
|
messageID: UUID().uuidString,
|
||||||
|
text: "All green — tests pass and the change is in place. Anything else?",
|
||||||
|
isPartial: false)))
|
||||||
|
self.demoAppend(sessionID, .usage(Usage(
|
||||||
|
inputTokens: 12_400, outputTokens: 640, costUSD: 0.0088, contextInputTokens: 12_400)))
|
||||||
|
self.demoAppend(sessionID, .runFinished(RunFinished(outcome: .completed, finalText: "Done.")))
|
||||||
|
self.demoUpdateSession(sessionID) {
|
||||||
|
$0.demoCopy(status: .awaitingInput, disposition: .some(.completed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
demoTasks.append(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func demoStartChat(_ req: StartChatRequest) {
|
||||||
|
let project = dashboard.projects.first { $0.id == req.projectID }
|
||||||
|
let id = SessionID(rawValue: "demo-\(UUID().uuidString.prefix(8))")
|
||||||
|
let title = String(req.message.prefix(48))
|
||||||
|
let summary = WireSessionSummary(
|
||||||
|
sessionID: id, projectID: req.projectID.rawValue,
|
||||||
|
projectName: project?.name ?? "project", backend: BackendID.forModel(req.model) ?? .claudeCode,
|
||||||
|
status: .running, disposition: nil, title: title.isEmpty ? "New chat" : title,
|
||||||
|
branch: "nucleic/\(id.rawValue)", lastSeq: 0, diffStat: nil,
|
||||||
|
pendingApprovalCount: 0, favorite: false, archived: false,
|
||||||
|
model: req.model, effort: req.effort, auto: req.auto ?? false,
|
||||||
|
updatedAt: Date())
|
||||||
|
sessions.insert(summary, at: 0)
|
||||||
|
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
|
||||||
|
demoRun(id, userText: req.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func demoResolveApproval(_ id: ApprovalID, _ decision: Decision) {
|
||||||
|
openApprovals.removeAll { $0.id == id }
|
||||||
|
NotificationRouter.shared.withdrawApproval(id)
|
||||||
|
guard let sessionID = openSessionID else { return }
|
||||||
|
switch decision {
|
||||||
|
case .deny, .cancelRun:
|
||||||
|
demoAppend(sessionID, .note(NoteEvent(text: "You denied the command.", icon: "hand.raised")))
|
||||||
|
demoUpdateSession(sessionID) {
|
||||||
|
$0.demoCopy(status: .awaitingInput, disposition: .some(.awaitingInput), pendingApprovalCount: 0)
|
||||||
|
}
|
||||||
|
case .allow, .allowAlways:
|
||||||
|
demoAppend(sessionID, .toolResult(ToolResult(
|
||||||
|
toolCallID: "t-appr", content: "Deployed successfully.", isError: false)))
|
||||||
|
demoUpdateSession(sessionID) { $0.demoCopy(pendingApprovalCount: 0) }
|
||||||
|
demoRun(sessionID, userText: nil) // wrap up the turn after the approved tool runs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func demoCaptureTodo(_ req: CaptureTodoRequest) {
|
||||||
|
let project = req.projectID.flatMap { pid in dashboard.projects.first { $0.id == pid } }
|
||||||
|
let todo = WireTodo(
|
||||||
|
id: .generate(), text: req.text, summary: nil, projectID: req.projectID,
|
||||||
|
projectName: project?.name, status: .open, dispatchedSessionID: nil,
|
||||||
|
triage: nil, updatedAt: Date())
|
||||||
|
demoMutateTodos { [todo] + $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func demoSetTodoStatus(_ id: TodoID, _ status: TodoStatus) {
|
||||||
|
demoMutateTodos { todos in
|
||||||
|
todos.map { todo in
|
||||||
|
todo.id == id
|
||||||
|
? WireTodo(id: todo.id, text: todo.text, summary: todo.summary,
|
||||||
|
projectID: todo.projectID, projectName: todo.projectName,
|
||||||
|
status: status, dispatchedSessionID: todo.dispatchedSessionID,
|
||||||
|
triage: todo.triage, updatedAt: Date())
|
||||||
|
: todo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the dashboard with a transformed to-do list (its fields are `let`).
|
||||||
|
private func demoMutateTodos(_ transform: ([WireTodo]) -> [WireTodo]) {
|
||||||
|
dashboard = DashboardSnapshot(
|
||||||
|
counts: dashboard.counts, activity: dashboard.activity, projects: dashboard.projects,
|
||||||
|
todos: transform(dashboard.todos), usage: dashboard.usage, statusFeeds: dashboard.statusFeeds)
|
||||||
|
}
|
||||||
|
|
||||||
private func makeChannel(_ endpoint: NWEndpoint) -> NWFrameChannel {
|
private func makeChannel(_ endpoint: NWEndpoint) -> NWFrameChannel {
|
||||||
NWFrameChannel(endpoint: endpoint)
|
NWFrameChannel(endpoint: endpoint)
|
||||||
}
|
}
|
||||||
@@ -599,3 +838,41 @@ extension Data {
|
|||||||
DeviceIdentity.fingerprint(ofStaticKey: self)
|
DeviceIdentity.fingerprint(ofStaticKey: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension WireSessionSummary {
|
||||||
|
/// A copy with selected fields overridden — the demo simulator's only way to "mutate" a
|
||||||
|
/// summary, whose stored properties are all `let`. Nullable fields use a double optional so a
|
||||||
|
/// caller can distinguish "keep" (omit) from "set to nil" (`.some(nil)`). `updatedAt` bumps
|
||||||
|
/// to now unless given. Only the fields the simulator touches are exposed.
|
||||||
|
func demoCopy(
|
||||||
|
status: SessionStatus? = nil,
|
||||||
|
disposition: TurnDisposition?? = nil,
|
||||||
|
title: String? = nil,
|
||||||
|
favorite: Bool? = nil,
|
||||||
|
archived: Bool? = nil,
|
||||||
|
pendingApprovalCount: Int? = nil,
|
||||||
|
diffStat: DiffStat?? = nil,
|
||||||
|
model: String?? = nil,
|
||||||
|
effort: String?? = nil,
|
||||||
|
auto: Bool? = nil,
|
||||||
|
autoShip: Bool? = nil,
|
||||||
|
shipBranch: String?? = nil,
|
||||||
|
updatedAt: Date? = nil
|
||||||
|
) -> WireSessionSummary {
|
||||||
|
WireSessionSummary(
|
||||||
|
sessionID: sessionID, projectID: projectID, projectName: projectName, backend: backend,
|
||||||
|
status: status ?? self.status,
|
||||||
|
disposition: disposition ?? self.disposition,
|
||||||
|
title: title ?? self.title, branch: branch, lastSeq: lastSeq,
|
||||||
|
diffStat: diffStat ?? self.diffStat,
|
||||||
|
pendingApprovalCount: pendingApprovalCount ?? self.pendingApprovalCount,
|
||||||
|
favorite: favorite ?? self.favorite,
|
||||||
|
archived: archived ?? self.archived,
|
||||||
|
queuedMessage: queuedMessage, queuedMessages: queuedMessages,
|
||||||
|
model: model ?? self.model, effort: effort ?? self.effort,
|
||||||
|
auto: auto ?? self.auto, autoShip: autoShip ?? self.autoShip,
|
||||||
|
shipBranch: shipBranch ?? self.shipBranch,
|
||||||
|
contextInputTokens: contextInputTokens,
|
||||||
|
updatedAt: updatedAt ?? Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ struct RootView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// The same non-release warning strip the Mac window carries (dev/canary/beta/rc).
|
// The same non-release warning strip the Mac window carries (dev/canary/beta/rc).
|
||||||
BuildBanner()
|
BuildBanner()
|
||||||
|
// A demo-mode strip so sample data is never mistaken for a live Mac connection.
|
||||||
|
DemoBanner()
|
||||||
Group {
|
Group {
|
||||||
if store.isPaired {
|
if store.isPaired {
|
||||||
TabView(selection: $tab) {
|
TabView(selection: $tab) {
|
||||||
|
|||||||
@@ -131,3 +131,33 @@ struct BuildBanner: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A slim strip shown while the app is in offline demo mode, so sample data is never mistaken
|
||||||
|
/// for a live connection to a Mac. Sits under the `BuildBanner` in `RootView`.
|
||||||
|
struct DemoBanner: View {
|
||||||
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if store.demoMode {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "play.circle.fill").font(.system(size: 14, weight: .bold))
|
||||||
|
Text("Demo — sample data, no Mac connected")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text("DEMO")
|
||||||
|
.font(.caption2.weight(.heavy))
|
||||||
|
.tracking(0.9)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(.white.opacity(0.18), in: .capsule)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white.opacity(0.95))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color(red: 0.36, green: 0.36, blue: 0.42))
|
||||||
|
.accessibilityLabel("Demo mode — sample data, not connected to a Mac.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ struct SettingsView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
if store.demoMode {
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) { store.exitDemo() } label: {
|
||||||
|
Label("Leave demo", systemImage: "arrow.uturn.left")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Demo")
|
||||||
|
} footer: {
|
||||||
|
Text("You're exploring Nucleic with sample data and a simulated agent. Leave the demo to pair with your Mac.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Picker("Theme", selection: $appearanceRaw) {
|
Picker("Theme", selection: $appearanceRaw) {
|
||||||
ForEach(AppAppearance.allCases) { appearance in
|
ForEach(AppAppearance.allCases) { appearance in
|
||||||
@@ -113,6 +125,19 @@ struct PairingIntroView: View {
|
|||||||
Label("Scan QR code", systemImage: "camera").font(.headline).padding(.horizontal)
|
Label("Scan QR code", systemImage: "camera").font(.headline).padding(.horizontal)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Button { store.enterDemo() } label: {
|
||||||
|
Label("Explore a demo — no Mac needed", systemImage: "play.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
Text("Sample data and a simulated agent — no Mac or account required.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.sheet(isPresented: $showScanner) {
|
.sheet(isPresented: $showScanner) {
|
||||||
|
|||||||
Reference in New Issue
Block a user