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 {
await refreshDashboard()
} else if launchArguments.contains("--show-pair-scanner") {
isScannerPresented = true
}
} catch {
if session == nil {
+195 -95
View File
@@ -54,110 +54,187 @@ private struct PairingWelcomeView: View {
let onNFCRequested: () -> Void
var body: some View {
AppScrollScreen(compactLayout: true, bottomPadding: 150) {
AppPanel(compactLayout: true, radius: 30) {
ShadcnBadge(
title: "Passport setup",
tone: .accent,
leading: Image(systemName: "shield.lefthalf.filled")
AppScrollScreen(compactLayout: true, bottomPadding: 170) {
PairingHero()
PairingStepsCard()
PairingChecklistCard()
}
.navigationTitle("")
.toolbar(.hidden, for: .navigationBar)
.safeAreaInset(edge: .bottom) {
PairingActionDock(
isAuthenticating: model.isAuthenticating,
onScanRequested: onScanRequested,
onNFCRequested: onNFCRequested
)
}
}
}
private struct PairingHero: View {
var body: some View {
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)
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)
.foregroundStyle(Color.idpMutedForeground)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
}
}
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)
HStack(alignment: .top, spacing: 16) {
ZStack {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(IdP.tint.opacity(0.14))
.frame(width: 72, height: 72)
RoundedRectangle(cornerRadius: 26, style: .continuous)
.stroke(Color.white.opacity(0.22), lineWidth: 1)
.frame(width: 76, height: 76)
Image(systemName: "qrcode.viewfinder")
.font(.system(size: 30, weight: .semibold))
.foregroundStyle(IdP.tint)
}
Image(systemName: "shield.lefthalf.filled")
.font(.system(size: 34, weight: .semibold))
.foregroundStyle(.white)
}
}
}
VStack(alignment: .leading, spacing: 8) {
Text("Link this iPhone to your idp.global account")
.font(.title2.weight(.bold))
.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.")
.font(.subheadline)
.foregroundStyle(Color.idpMutedForeground)
}
}
Text("No camera? The scanner screen also lets you paste a pairing link manually.")
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)
.foregroundStyle(Color.idpMutedForeground)
}
AppSectionCard(
title: "How pairing works",
subtitle: "The QR is created by the web session you want to trust.",
compactLayout: true
) {
VStack(spacing: 14) {
PairingStepRow(
number: 1,
title: "Start from the web",
message: "Open idp.global in your browser and begin a new device pairing flow."
)
VStack(spacing: 0) {
PairingStepRow(
number: 1,
title: "Start from the web",
message: "Open idp.global in your browser and begin a new device pairing."
)
PairingStepRow(
number: 2,
title: "Scan the QR",
message: "Use the camera on the next screen to scan the pairing QR shown by that browser session."
)
StepDivider()
PairingStepRow(
number: 3,
title: "Approve future sign-ins",
message: "Once linked, this iPhone can receive approval requests and security alerts."
)
}
}
PairingStepRow(
number: 2,
title: "Scan the pairing QR",
message: "Point this iPhone at the QR shown by that browser session."
)
AppSectionCard(
title: "Before you scan",
subtitle: "A few quick checks help the link complete cleanly.",
compactLayout: true
) {
VStack(alignment: .leading, spacing: 10) {
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: "wave.3.right", text: "If your organization uses NFC tags, you can link with NFC instead of QR.")
}
StepDivider()
PairingStepRow(
number: 3,
title: "Approve future sign-ins",
message: "Once linked, this phone receives approval requests and alerts."
)
}
}
.navigationTitle("Welcome")
.idpInlineNavigationTitle()
.safeAreaInset(edge: .bottom) {
VStack(spacing: 10) {
Button(action: onScanRequested) {
HStack(spacing: 10) {
if model.isAuthenticating {
ProgressView()
.progressViewStyle(.circular)
.tint(Color.idpPrimaryForeground)
} else {
Image(systemName: "camera.viewfinder")
}
}
}
Text(model.isAuthenticating ? "Linking device..." : "Scan pairing QR")
private struct PairingChecklistCard: View {
var body: some View {
AppPanel(compactLayout: true, radius: 22) {
VStack(alignment: .leading, spacing: 4) {
Text("Before you scan")
.font(.headline)
Text("A few quick checks help the link complete cleanly.")
.font(.footnote)
.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."
)
}
}
}
}
private struct PairingActionDock: View {
let isAuthenticating: Bool
let onScanRequested: () -> Void
let onNFCRequested: () -> Void
var body: some View {
VStack(spacing: 10) {
Button(action: onScanRequested) {
HStack(spacing: 10) {
if isAuthenticating {
ProgressView()
.progressViewStyle(.circular)
.tint(Color.idpPrimaryForeground)
} else {
Image(systemName: "qrcode.viewfinder")
}
}
.buttonStyle(PrimaryActionStyle())
.disabled(model.isAuthenticating)
Button(action: onNFCRequested) {
Label("Use NFC tag instead", systemImage: "wave.3.right")
Text(isAuthenticating ? "Linking device..." : "Scan pairing QR")
}
.buttonStyle(SecondaryActionStyle())
.disabled(model.isAuthenticating)
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 14)
.background(.regularMaterial)
.buttonStyle(PrimaryActionStyle())
.disabled(isAuthenticating)
Button(action: onNFCRequested) {
Label("Use NFC tag instead", systemImage: "wave.3.right")
}
.buttonStyle(SecondaryActionStyle())
.disabled(isAuthenticating)
}
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 14)
.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
var body: some View {
HStack(alignment: .top, spacing: 12) {
Text("\(number)")
.font(.subheadline.weight(.bold))
.frame(width: 28, height: 28)
.foregroundStyle(IdP.tint)
.background(IdP.tint.opacity(0.12), in: Circle())
HStack(alignment: .top, spacing: 14) {
ZStack {
Circle()
.fill(IdP.tint.opacity(0.14))
Text("\(number)")
.font(.subheadline.weight(.bold))
.foregroundStyle(IdP.tint)
}
.frame(width: 30, height: 30)
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(message)
.font(.footnote)
.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
var body: some View {
HStack(alignment: .top, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 13, weight: .semibold))
.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)
.font(.footnote)
.foregroundStyle(Color.idpMutedForeground)
.foregroundStyle(Color.idpForeground.opacity(0.82))
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
}
}
+383 -127
View File
@@ -31,42 +31,16 @@ struct QRScannerSheet: View {
var body: some View {
NavigationStack {
ZStack(alignment: .top) {
LiveQRScannerView { payload in
ScannerContent(
title: title,
description: description,
manualFallback: $manualFallback,
onCodeScanned: { payload in
onCodeScanned(payload)
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: {
manualFallback = ClipboardPayloadReader.readString()
}
)
}
.padding(16)
}
},
onDismiss: { dismiss() }
)
.navigationTitle(navigationTitleText)
.applyInlineNavigationTitleDisplayMode()
.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 onDismiss: () -> Void
@StateObject private var scanner = QRScannerViewModel()
@State private var didDetectCode = false
var body: some View {
ZStack {
Group {
if scanner.isPreviewAvailable {
ScannerPreview(session: scanner.captureSession)
} else {
Color.black
VStack(alignment: .leading, spacing: 10) {
Image(systemName: "video.slash.fill")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.white)
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)
Group {
if scanner.isPreviewAvailable {
LiveScannerSurface(
title: title,
description: description,
manualFallback: $manualFallback,
scanner: scanner,
didDetectCode: didDetectCode,
onCodeScanned: onCodeScanned
)
} else {
ManualOnlySurface(
title: title,
description: description,
statusMessage: scanner.statusMessage,
manualFallback: $manualFallback,
onCodeScanned: onCodeScanned
)
.padding(.horizontal, 18)
.padding(.bottom, 18)
}
}
.task {
@@ -166,9 +101,8 @@ struct LiveQRScannerView: View {
withAnimation(.spring(response: 0.3, dampingFraction: 0.82)) {
didDetectCode = true
}
Task {
try? await Task.sleep(for: .milliseconds(180))
try? await Task.sleep(for: .milliseconds(220))
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 {
let detected: Bool
@@ -240,8 +475,13 @@ private struct ScannerStatusBadge: View {
}
}
private enum ManualFallbackSurface {
case elevated
}
private struct ManualFallbackCard: View {
@Binding var manualFallback: String
var surface: ManualFallbackSurface = .elevated
let onUsePayload: () -> Void
let onPasteFromClipboard: () -> Void
@@ -252,63 +492,79 @@ private struct ManualFallbackCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "doc.text.viewfinder")
.font(.system(size: 18, weight: .semibold))
Image(systemName: "link")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(IdP.tint)
.frame(width: 34, height: 34)
.background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.frame(width: 32, height: 32)
.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")
.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)
.foregroundStyle(Color.idpMutedForeground)
.fixedSize(horizontal: false, vertical: true)
}
}
ZStack(alignment: .topLeading) {
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)
}
}
ManualPayloadField(manualFallback: $manualFallback, dark: false)
VStack(spacing: 10) {
Button("Use pasted payload", action: onUsePayload)
.buttonStyle(PrimaryActionStyle())
.disabled(!hasManualPayload)
Button {
onPasteFromClipboard()
} label: {
Button(action: onPasteFromClipboard) {
Label("Paste from clipboard", systemImage: "doc.on.clipboard")
}
.buttonStyle(SecondaryActionStyle())
}
}
.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(
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1)
RoundedRectangle(cornerRadius: 22, style: .continuous)
.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 {
static func readString() -> String {
#if os(iOS)