From 75f5a4e7e8a439e14732a471358baaa77b103714 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 20 Apr 2026 19:18:03 +0000 Subject: [PATCH] refine pairing onboarding and scanner presentation --- swift/Sources/App/AppViewModel.swift | 2 + .../Sources/Features/Auth/LoginRootView.swift | 290 ++++++---- .../Sources/Features/Auth/QRScannerView.swift | 510 +++++++++++++----- 3 files changed, 580 insertions(+), 222 deletions(-) diff --git a/swift/Sources/App/AppViewModel.swift b/swift/Sources/App/AppViewModel.swift index e79a8ab..5c2113b 100644 --- a/swift/Sources/App/AppViewModel.swift +++ b/swift/Sources/App/AppViewModel.swift @@ -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 { diff --git a/swift/Sources/Features/Auth/LoginRootView.swift b/swift/Sources/Features/Auth/LoginRootView.swift index f7ff904..33fd50d 100644 --- a/swift/Sources/Features/Auth/LoginRootView.swift +++ b/swift/Sources/Features/Auth/LoginRootView.swift @@ -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) } } } diff --git a/swift/Sources/Features/Auth/QRScannerView.swift b/swift/Sources/Features/Auth/QRScannerView.swift index 3a3c806..6e57152 100644 --- a/swift/Sources/Features/Auth/QRScannerView.swift +++ b/swift/Sources/Features/Auth/QRScannerView.swift @@ -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)