From 20afa55a7be11797514fad1c1127bcee9ac25b07 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:05:31 -0700 Subject: [PATCH] Settings Feature Enhancement Nucleic-Session: 89BFAB13-D559-43FC-BE28-0B2639BECF0F Co-authored-by: Nucleic --- .../NucleicRemote/Views/SettingsView.swift | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index bba953b..c7ffbda 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -370,3 +370,118 @@ struct ManualPairingView: View { dismiss() } } + +/// "Add a device to this mesh": the phone can't mint a join code itself (it runs no listener), so +/// it asks a connected Mac to open a pairing window and relays back that Mac's code — shown here as +/// a QR to scan from a new iPhone/iPad, or a copyable link to send to a new Mac. Scanning/pasting it +/// joins the new device to the whole group. Dismissing the sheet closes the Mac's pairing window +/// (`RemoteStore.cancelPairingCode`, wired to the sheet's `onDismiss`). +struct AddDeviceView: View { + @EnvironmentObject var store: RemoteStore + @Environment(\.dismiss) private var dismiss + @State private var copied = false + @State private var copyResetTask: Task? + + var body: some View { + NavigationStack { + Form { + switch store.addDevice { + case .idle, .requesting: + Section { + HStack { + ProgressView() + Text("Asking a Mac in your mesh for a join code…") + .foregroundStyle(.secondary) + } + } + case .unavailable: + Section { + Label("Couldn't get a join code", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("This needs a Mac in your mesh that's connected right now. Make sure " + + "one is reachable with its meshing service on, then try again.") + .font(.callout).foregroundStyle(.secondary) + Button("Try again") { store.requestPairingCode() } + } + case .ready(let code): + codeSection(code) + } + } + .navigationTitle("Add a device") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } + } + } + // Fire the request as the sheet appears; `onDismiss` (on the presenter) tells the Mac to + // close its pairing window. + .onAppear { if case .ready = store.addDevice {} else { store.requestPairingCode() } } + .onDisappear { copyResetTask?.cancel() } + } + + @ViewBuilder + private func codeSection(_ code: String) -> some View { + Section { + if let image = Self.qrImage(code) { + HStack { + Spacer() + Image(uiImage: image) + .interpolation(.none) + .resizable() + .frame(width: 220, height: 220) + .padding(10) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + Spacer() + } + .listRowBackground(Color.clear) + } + } header: { + Text("Scan to join") + } footer: { + Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join an Orbital mesh and scan this. " + + "It joins the whole group — every Mac and device here.") + } + + Section { + Text(code) + .font(.system(.caption, design: .monospaced)) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + Button { + copyCode(code) + } label: { + Label(copied ? "Copied" : "Copy join code", + systemImage: copied ? "checkmark" : "doc.on.doc") + } + } header: { + Text("Or send a code") + } footer: { + Text("Paste into “Join mesh…” on another Mac, or “Enter code manually” on an iPhone or " + + "iPad. A Mac joining is confirmed on the Mac that shared the code.") + } + } + + private func copyCode(_ code: String) { + UIPasteboard.general.string = code + withAnimation(.easeOut(duration: 0.12)) { copied = true } + copyResetTask?.cancel() + copyResetTask = Task { + try? await Task.sleep(for: .seconds(1.4)) + guard !Task.isCancelled else { return } + withAnimation(.easeIn(duration: 0.2)) { copied = false } + } + } + + /// Render the join string as a QR code (CoreImage), mirroring the Mac's pairing sheet. + private static func qrImage(_ string: String) -> UIImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10)) + let context = CIContext() + guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil } + return UIImage(cgImage: cg) + } +}