refine pairing onboarding and scanner presentation

This commit is contained in:
2026-04-20 19:18:03 +00:00
parent 5b6de05b11
commit 75f5a4e7e8
3 changed files with 580 additions and 222 deletions
+2
View File
@@ -121,6 +121,8 @@ final class AppViewModel: ObservableObject {
} }
} else if session != nil { } else if session != nil {
await refreshDashboard() await refreshDashboard()
} else if launchArguments.contains("--show-pair-scanner") {
isScannerPresented = true
} }
} catch { } catch {
if session == nil { if session == nil {
+156 -56
View File
@@ -54,110 +54,187 @@ private struct PairingWelcomeView: View {
let onNFCRequested: () -> Void let onNFCRequested: () -> Void
var body: some View { var body: some View {
AppScrollScreen(compactLayout: true, bottomPadding: 150) { AppScrollScreen(compactLayout: true, bottomPadding: 170) {
AppPanel(compactLayout: true, radius: 30) { PairingHero()
ShadcnBadge(
title: "Passport setup",
tone: .accent,
leading: Image(systemName: "shield.lefthalf.filled")
)
HStack(alignment: .top, spacing: 16) { PairingStepsCard()
ZStack {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(IdP.tint.opacity(0.14))
.frame(width: 72, height: 72)
Image(systemName: "qrcode.viewfinder") PairingChecklistCard()
.font(.system(size: 30, weight: .semibold))
.foregroundStyle(IdP.tint)
} }
.navigationTitle("")
.toolbar(.hidden, for: .navigationBar)
.safeAreaInset(edge: .bottom) {
PairingActionDock(
isAuthenticating: model.isAuthenticating,
onScanRequested: onScanRequested,
onNFCRequested: onNFCRequested
)
}
}
}
VStack(alignment: .leading, spacing: 8) { private struct PairingHero: View {
Text("Link this iPhone to your idp.global account") var body: some View {
.font(.title2.weight(.bold)) VStack(alignment: .leading, spacing: 18) {
HeroGlyph()
VStack(alignment: .leading, spacing: 10) {
Text("Your passport, in your pocket")
.font(.system(size: 30, weight: .bold, design: .default))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Text("Start pairing in your browser, then scan the fresh QR on the next screen. This device becomes your passport for approvals, alerts, and identity proofs.") Text("Link this iPhone to your idp.global web session to approve sign-ins, receive security alerts, and prove your identity with NFC.")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Color.idpMutedForeground) .foregroundStyle(Color.idpMutedForeground)
.fixedSize(horizontal: false, vertical: true)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
}
}
Text("No camera? The scanner screen also lets you paste a pairing link manually.") private struct HeroGlyph: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(
LinearGradient(
colors: [
IdP.tint,
IdP.tint.opacity(0.78)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 76, height: 76)
.shadow(color: IdP.tint.opacity(0.35), radius: 18, x: 0, y: 10)
RoundedRectangle(cornerRadius: 26, style: .continuous)
.stroke(Color.white.opacity(0.22), lineWidth: 1)
.frame(width: 76, height: 76)
Image(systemName: "shield.lefthalf.filled")
.font(.system(size: 34, weight: .semibold))
.foregroundStyle(.white)
}
}
}
private struct PairingStepsCard: View {
var body: some View {
AppPanel(compactLayout: true, radius: 22) {
VStack(alignment: .leading, spacing: 4) {
Text("How pairing works")
.font(.headline)
Text("Three quick steps to finish setup.")
.font(.footnote) .font(.footnote)
.foregroundStyle(Color.idpMutedForeground) .foregroundStyle(Color.idpMutedForeground)
} }
AppSectionCard( VStack(spacing: 0) {
title: "How pairing works",
subtitle: "The QR is created by the web session you want to trust.",
compactLayout: true
) {
VStack(spacing: 14) {
PairingStepRow( PairingStepRow(
number: 1, number: 1,
title: "Start from the web", title: "Start from the web",
message: "Open idp.global in your browser and begin a new device pairing flow." message: "Open idp.global in your browser and begin a new device pairing."
) )
StepDivider()
PairingStepRow( PairingStepRow(
number: 2, number: 2,
title: "Scan the QR", title: "Scan the pairing QR",
message: "Use the camera on the next screen to scan the pairing QR shown by that browser session." message: "Point this iPhone at the QR shown by that browser session."
) )
StepDivider()
PairingStepRow( PairingStepRow(
number: 3, number: 3,
title: "Approve future sign-ins", title: "Approve future sign-ins",
message: "Once linked, this iPhone can receive approval requests and security alerts." message: "Once linked, this phone receives approval requests and alerts."
) )
} }
} }
}
}
AppSectionCard( private struct PairingChecklistCard: View {
title: "Before you scan", var body: some View {
subtitle: "A few quick checks help the link complete cleanly.", AppPanel(compactLayout: true, radius: 22) {
compactLayout: true VStack(alignment: .leading, spacing: 4) {
) { Text("Before you scan")
VStack(alignment: .leading, spacing: 10) { .font(.headline)
PairingNoteRow(icon: "safari", text: "Keep the pairing page open in your browser until this phone confirms the link.") Text("A few quick checks help the link complete cleanly.")
PairingNoteRow(icon: "camera.fill", text: "Grant camera access when prompted so the QR can be read directly.") .font(.footnote)
PairingNoteRow(icon: "wave.3.right", text: "If your organization uses NFC tags, you can link with NFC instead of QR.") .foregroundStyle(Color.idpMutedForeground)
}
VStack(alignment: .leading, spacing: 12) {
PairingNoteRow(
icon: "safari",
text: "Keep the pairing page open in your browser until this phone confirms the link."
)
PairingNoteRow(
icon: "camera.fill",
text: "Grant camera access when prompted so the QR can be read directly."
)
PairingNoteRow(
icon: "doc.on.clipboard",
text: "No camera? The scanner also accepts a pasted pairing link."
)
PairingNoteRow(
icon: "wave.3.right",
text: "Organizations using NFC tags can link with NFC instead of QR."
)
} }
} }
} }
.navigationTitle("Welcome") }
.idpInlineNavigationTitle()
.safeAreaInset(edge: .bottom) { private struct PairingActionDock: View {
let isAuthenticating: Bool
let onScanRequested: () -> Void
let onNFCRequested: () -> Void
var body: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
Button(action: onScanRequested) { Button(action: onScanRequested) {
HStack(spacing: 10) { HStack(spacing: 10) {
if model.isAuthenticating { if isAuthenticating {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
.tint(Color.idpPrimaryForeground) .tint(Color.idpPrimaryForeground)
} else { } else {
Image(systemName: "camera.viewfinder") Image(systemName: "qrcode.viewfinder")
} }
Text(model.isAuthenticating ? "Linking device..." : "Scan pairing QR") Text(isAuthenticating ? "Linking device..." : "Scan pairing QR")
} }
} }
.buttonStyle(PrimaryActionStyle()) .buttonStyle(PrimaryActionStyle())
.disabled(model.isAuthenticating) .disabled(isAuthenticating)
Button(action: onNFCRequested) { Button(action: onNFCRequested) {
Label("Use NFC tag instead", systemImage: "wave.3.right") Label("Use NFC tag instead", systemImage: "wave.3.right")
} }
.buttonStyle(SecondaryActionStyle()) .buttonStyle(SecondaryActionStyle())
.disabled(model.isAuthenticating) .disabled(isAuthenticating)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 10) .padding(.top, 12)
.padding(.bottom, 14) .padding(.bottom, 14)
.background(.regularMaterial) .background(
Rectangle()
.fill(.regularMaterial)
.ignoresSafeArea()
.overlay(alignment: .top) {
Rectangle()
.fill(Color.idpSeparator.opacity(0.5))
.frame(height: 0.5)
} }
)
} }
} }
@@ -167,20 +244,39 @@ private struct PairingStepRow: View {
let message: String let message: String
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 14) {
ZStack {
Circle()
.fill(IdP.tint.opacity(0.14))
Text("\(number)") Text("\(number)")
.font(.subheadline.weight(.bold)) .font(.subheadline.weight(.bold))
.frame(width: 28, height: 28)
.foregroundStyle(IdP.tint) .foregroundStyle(IdP.tint)
.background(IdP.tint.opacity(0.12), in: Circle()) }
.frame(width: 30, height: 30)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 3) {
Text(title) Text(title)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
Text(message) Text(message)
.font(.footnote) .font(.footnote)
.foregroundStyle(Color.idpMutedForeground) .foregroundStyle(Color.idpMutedForeground)
.fixedSize(horizontal: false, vertical: true)
} }
Spacer(minLength: 0)
}
.padding(.vertical, 10)
}
}
private struct StepDivider: View {
var body: some View {
HStack(spacing: 14) {
Rectangle()
.fill(Color.idpSeparator.opacity(0.6))
.frame(width: 1, height: 10)
.padding(.leading, 14)
Spacer()
} }
} }
} }
@@ -190,15 +286,19 @@ private struct PairingNoteRow: View {
let text: String let text: String
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 10) { HStack(alignment: .top, spacing: 12) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(IdP.tint) .foregroundStyle(IdP.tint)
.frame(width: 18, height: 18) .frame(width: 26, height: 26)
.background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
Text(text) Text(text)
.font(.footnote) .font(.footnote)
.foregroundStyle(Color.idpMutedForeground) .foregroundStyle(Color.idpForeground.opacity(0.82))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
} }
} }
} }
+378 -122
View File
@@ -31,42 +31,16 @@ struct QRScannerSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack(alignment: .top) { ScannerContent(
LiveQRScannerView { payload in title: title,
description: description,
manualFallback: $manualFallback,
onCodeScanned: { payload in
onCodeScanned(payload) onCodeScanned(payload)
dismiss() dismiss()
}
.ignoresSafeArea()
VStack(spacing: 12) {
IdPGlassCapsule {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.headline)
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
ManualFallbackCard(
manualFallback: $manualFallback,
onUsePayload: {
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
guard !chosen.isEmpty else { return }
onCodeScanned(chosen)
dismiss()
}, },
onPasteFromClipboard: { onDismiss: { dismiss() }
manualFallback = ClipboardPayloadReader.readString()
}
) )
}
.padding(16)
}
.navigationTitle(navigationTitleText) .navigationTitle(navigationTitleText)
.applyInlineNavigationTitleDisplayMode() .applyInlineNavigationTitleDisplayMode()
.toolbar { .toolbar {
@@ -91,74 +65,35 @@ private extension View {
} }
} }
struct LiveQRScannerView: View { private struct ScannerContent: View {
let title: String
let description: String
@Binding var manualFallback: String
let onCodeScanned: (String) -> Void let onCodeScanned: (String) -> Void
let onDismiss: () -> Void
@StateObject private var scanner = QRScannerViewModel() @StateObject private var scanner = QRScannerViewModel()
@State private var didDetectCode = false @State private var didDetectCode = false
var body: some View { var body: some View {
ZStack {
Group { Group {
if scanner.isPreviewAvailable { if scanner.isPreviewAvailable {
ScannerPreview(session: scanner.captureSession) LiveScannerSurface(
title: title,
description: description,
manualFallback: $manualFallback,
scanner: scanner,
didDetectCode: didDetectCode,
onCodeScanned: onCodeScanned
)
} else { } else {
Color.black ManualOnlySurface(
title: title,
VStack(alignment: .leading, spacing: 10) { description: description,
Image(systemName: "video.slash.fill") statusMessage: scanner.statusMessage,
.font(.system(size: 24, weight: .semibold)) manualFallback: $manualFallback,
.foregroundStyle(.white) onCodeScanned: onCodeScanned
Text("Camera preview unavailable")
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
Text(scanner.statusMessage)
.foregroundStyle(.white.opacity(0.78))
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
LinearGradient(
colors: [Color.black.opacity(0.64), Color.clear, Color.black.opacity(0.78)],
startPoint: .top,
endPoint: .bottom
) )
.ignoresSafeArea()
ScanFrameOverlay(detected: didDetectCode)
.padding(40)
VStack {
HStack {
ScannerStatusBadge(title: scanner.isPreviewAvailable ? "Camera ready" : "Manual fallback")
Spacer()
}
.padding(.horizontal, 20)
.padding(.top, 18)
Spacer()
VStack(alignment: .leading, spacing: 8) {
Text("Align the pairing QR inside the frame")
.font(.headline.weight(.semibold))
.foregroundStyle(.white)
Text(scanner.statusMessage)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.84))
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(Color.black.opacity(0.34), in: RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
.padding(.horizontal, 18)
.padding(.bottom, 18)
} }
} }
.task { .task {
@@ -166,9 +101,8 @@ struct LiveQRScannerView: View {
withAnimation(.spring(response: 0.3, dampingFraction: 0.82)) { withAnimation(.spring(response: 0.3, dampingFraction: 0.82)) {
didDetectCode = true didDetectCode = true
} }
Task { Task {
try? await Task.sleep(for: .milliseconds(180)) try? await Task.sleep(for: .milliseconds(220))
onCodeScanned(payload) onCodeScanned(payload)
} }
} }
@@ -180,6 +114,307 @@ struct LiveQRScannerView: View {
} }
} }
private struct LiveScannerSurface: View {
let title: String
let description: String
@Binding var manualFallback: String
@ObservedObject var scanner: QRScannerViewModel
let didDetectCode: Bool
let onCodeScanned: (String) -> Void
@State private var isFallbackExpanded = false
var body: some View {
ZStack {
ScannerPreview(session: scanner.captureSession)
.ignoresSafeArea()
LinearGradient(
colors: [Color.black.opacity(0.68), Color.clear, Color.black.opacity(0.82)],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
ScanFrameOverlay(detected: didDetectCode)
.padding(40)
VStack(spacing: 14) {
ScannerTopHint(title: title, description: description)
.padding(.horizontal, 18)
.padding(.top, 6)
Spacer(minLength: 0)
ScannerAlignmentHint(statusMessage: scanner.statusMessage)
.padding(.horizontal, 18)
ExpandableFallback(
isExpanded: $isFallbackExpanded,
manualFallback: $manualFallback,
onCodeScanned: onCodeScanned
)
.padding(.horizontal, 18)
.padding(.bottom, 14)
}
}
}
}
private struct ManualOnlySurface: View {
let title: String
let description: String
let statusMessage: String
@Binding var manualFallback: String
let onCodeScanned: (String) -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
ManualHero(statusMessage: statusMessage)
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.title3.weight(.bold))
.fixedSize(horizontal: false, vertical: true)
Text(description)
.font(.subheadline)
.foregroundStyle(Color.idpMutedForeground)
.fixedSize(horizontal: false, vertical: true)
}
ManualFallbackCard(
manualFallback: $manualFallback,
surface: .elevated,
onUsePayload: {
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
guard !chosen.isEmpty else { return }
onCodeScanned(chosen)
},
onPasteFromClipboard: {
manualFallback = ClipboardPayloadReader.readString()
}
)
}
.frame(maxWidth: 560, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 20)
.padding(.top, 16)
.padding(.bottom, 28)
}
.background(Color.idpGroupedBackground.ignoresSafeArea())
.scrollIndicators(.hidden)
}
}
private struct ManualHero: View {
let statusMessage: String
var body: some View {
HStack(alignment: .top, spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(
LinearGradient(
colors: [IdP.tint, IdP.tint.opacity(0.78)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Image(systemName: "doc.text.viewfinder")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(.white)
}
.frame(width: 60, height: 60)
.shadow(color: IdP.tint.opacity(0.28), radius: 14, x: 0, y: 8)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Circle()
.fill(Color.idpWarn)
.frame(width: 7, height: 7)
Text("Camera unavailable")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.idpWarn)
}
Text(statusMessage)
.font(.footnote)
.foregroundStyle(Color.idpMutedForeground)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
}
}
private struct ScannerTopHint: View {
let title: String
let description: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "qrcode.viewfinder")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.background(Color.white.opacity(0.16), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
Text(description)
.font(.caption)
.foregroundStyle(.white.opacity(0.78))
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.padding(14)
.background(Color.black.opacity(0.38), in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1)
)
}
}
private struct ScannerAlignmentHint: View {
let statusMessage: String
var body: some View {
HStack(spacing: 10) {
ProgressPulse()
Text(statusMessage)
.font(.footnote)
.foregroundStyle(.white.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.black.opacity(0.32), in: Capsule(style: .continuous))
.overlay(
Capsule(style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1)
)
}
}
private struct ProgressPulse: View {
@State private var animate = false
var body: some View {
ZStack {
Circle()
.fill(IdP.tint)
.frame(width: 8, height: 8)
Circle()
.stroke(IdP.tint.opacity(0.6), lineWidth: 1.5)
.frame(width: 16, height: 16)
.scaleEffect(animate ? 1.4 : 0.8)
.opacity(animate ? 0 : 0.8)
.animation(.easeOut(duration: 1.2).repeatForever(autoreverses: false), value: animate)
}
.onAppear { animate = true }
}
}
private struct ExpandableFallback: View {
@Binding var isExpanded: Bool
@Binding var manualFallback: String
let onCodeScanned: (String) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(.spring(response: 0.35, dampingFraction: 0.86)) {
isExpanded.toggle()
}
} label: {
HStack(spacing: 12) {
Image(systemName: "doc.on.clipboard")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
Text(isExpanded ? "Hide manual entry" : "Paste pairing link instead")
.font(.footnote.weight(.semibold))
.foregroundStyle(.white)
Spacer(minLength: 0)
Image(systemName: isExpanded ? "chevron.down" : "chevron.up")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.72))
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded {
VStack(alignment: .leading, spacing: 12) {
ManualPayloadField(manualFallback: $manualFallback, dark: true)
Button {
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
guard !chosen.isEmpty else { return }
onCodeScanned(chosen)
} label: {
Text("Use pasted payload")
}
.buttonStyle(PrimaryActionStyle())
.disabled(manualFallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button {
manualFallback = ClipboardPayloadReader.readString()
} label: {
Label("Paste from clipboard", systemImage: "doc.on.clipboard")
.foregroundStyle(.white)
}
.buttonStyle(DarkOutlineButtonStyle())
}
.padding(.horizontal, 16)
.padding(.bottom, 14)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.background(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(Color.black.opacity(0.38))
)
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1)
)
}
}
private struct DarkOutlineButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.frame(height: 44)
.foregroundStyle(.white)
.background(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.fill(Color.white.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.stroke(Color.white.opacity(0.20), lineWidth: 1)
)
.opacity(configuration.isPressed ? 0.85 : 1)
}
}
private struct ScanFrameOverlay: View { private struct ScanFrameOverlay: View {
let detected: Bool let detected: Bool
@@ -240,8 +475,13 @@ private struct ScannerStatusBadge: View {
} }
} }
private enum ManualFallbackSurface {
case elevated
}
private struct ManualFallbackCard: View { private struct ManualFallbackCard: View {
@Binding var manualFallback: String @Binding var manualFallback: String
var surface: ManualFallbackSurface = .elevated
let onUsePayload: () -> Void let onUsePayload: () -> Void
let onPasteFromClipboard: () -> Void let onPasteFromClipboard: () -> Void
@@ -252,63 +492,79 @@ private struct ManualFallbackCard: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Image(systemName: "doc.text.viewfinder") Image(systemName: "link")
.font(.system(size: 18, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundStyle(IdP.tint) .foregroundStyle(IdP.tint)
.frame(width: 34, height: 34) .frame(width: 32, height: 32)
.background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 3) {
Text("Paste pairing link") Text("Paste pairing link")
.font(.headline) .font(.subheadline.weight(.semibold))
Text("If scanning is unavailable, paste the idp.global pairing link from your browser or clipboard.") Text("Copy the link from idp.global in your browser and paste it below.")
.font(.footnote) .font(.footnote)
.foregroundStyle(Color.idpMutedForeground) .foregroundStyle(Color.idpMutedForeground)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
} }
ZStack(alignment: .topLeading) { ManualPayloadField(manualFallback: $manualFallback, dark: false)
TextEditor(text: $manualFallback)
.font(.footnote.monospaced())
.scrollContentBackground(.hidden)
.frame(minHeight: 110)
.padding(10)
.background(Color.idpTertiaryFill, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
if manualFallback.isEmpty {
Text("idp.global://pair?token=...")
.font(.footnote.monospaced())
.foregroundStyle(Color.idpMutedForeground)
.padding(.horizontal, 16)
.padding(.vertical, 18)
.allowsHitTesting(false)
}
}
VStack(spacing: 10) { VStack(spacing: 10) {
Button("Use pasted payload", action: onUsePayload) Button("Use pasted payload", action: onUsePayload)
.buttonStyle(PrimaryActionStyle()) .buttonStyle(PrimaryActionStyle())
.disabled(!hasManualPayload) .disabled(!hasManualPayload)
Button { Button(action: onPasteFromClipboard) {
onPasteFromClipboard()
} label: {
Label("Paste from clipboard", systemImage: "doc.on.clipboard") Label("Paste from clipboard", systemImage: "doc.on.clipboard")
} }
.buttonStyle(SecondaryActionStyle()) .buttonStyle(SecondaryActionStyle())
} }
} }
.padding(18) .padding(18)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) .frame(maxWidth: .infinity, alignment: .leading)
.background(Color.idpSecondaryGroupedBackground, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay( .overlay(
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1) .stroke(Color.idpSeparator, lineWidth: 0.5)
) )
} }
} }
private struct ManualPayloadField: View {
@Binding var manualFallback: String
let dark: Bool
var body: some View {
ZStack(alignment: .topLeading) {
TextEditor(text: $manualFallback)
.font(.footnote.monospaced())
.foregroundStyle(dark ? Color.white : Color.idpForeground)
.scrollContentBackground(.hidden)
.frame(minHeight: 96)
.padding(10)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(dark ? Color.white.opacity(0.08) : Color.idpTertiaryFill)
)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(dark ? Color.white.opacity(0.14) : Color.idpSeparator, lineWidth: 0.5)
)
if manualFallback.isEmpty {
Text("idpglobal://pair?token=…")
.font(.footnote.monospaced())
.foregroundStyle(dark ? Color.white.opacity(0.4) : Color.idpMutedForeground)
.padding(.horizontal, 16)
.padding(.vertical, 18)
.allowsHitTesting(false)
}
}
}
}
private enum ClipboardPayloadReader { private enum ClipboardPayloadReader {
static func readString() -> String { static func readString() -> String {
#if os(iOS) #if os(iOS)