import AVFoundation import Combine import SwiftUI #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif struct QRScannerSheet: View { let title: String let description: String let navigationTitleText: String let onCodeScanned: (String) -> Void @Environment(\.dismiss) private var dismiss @State private var manualFallback = "" init( title: String = "Scan QR", description: String = "Use the camera to scan an idp.global QR challenge.", navigationTitle: String = "Scan QR", onCodeScanned: @escaping (String) -> Void ) { self.title = title self.description = description self.navigationTitleText = navigationTitle self.onCodeScanned = onCodeScanned } var body: some View { NavigationStack { ScannerContent( title: title, description: description, manualFallback: $manualFallback, onCodeScanned: { payload in onCodeScanned(payload) dismiss() }, onDismiss: { dismiss() } ) .navigationTitle(navigationTitleText) .applyInlineNavigationTitleDisplayMode() .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } } } } } } private extension View { @ViewBuilder func applyInlineNavigationTitleDisplayMode() -> some View { #if os(macOS) self #else navigationBarTitleDisplayMode(.inline) #endif } } 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 { 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 ) } } .task { scanner.onCodeScanned = { payload in withAnimation(.spring(response: 0.3, dampingFraction: 0.82)) { didDetectCode = true } Task { try? await Task.sleep(for: .milliseconds(220)) onCodeScanned(payload) } } await scanner.start() } .onDisappear { scanner.stop() } } } 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 var body: some View { GeometryReader { geometry in let size = min(geometry.size.width, geometry.size.height) * 0.5 let inset = detected ? 18.0 : 0 ZStack { RoundedRectangle(cornerRadius: 30, style: .continuous) .fill(Color.black.opacity(0.18)) .frame(width: size - inset, height: size - inset) RoundedRectangle(cornerRadius: 30, style: .continuous) .stroke( Color.white.opacity(detected ? 0.95 : 0.75), style: StrokeStyle(lineWidth: detected ? 3 : 2, lineCap: .round, lineJoin: .round) ) .frame(width: size - inset, height: size - inset) CornerTick(rotation: .degrees(0)) .frame(width: size, height: size) CornerTick(rotation: .degrees(90)) .frame(width: size, height: size) CornerTick(rotation: .degrees(180)) .frame(width: size, height: size) CornerTick(rotation: .degrees(270)) .frame(width: size, height: size) } .frame(width: size - inset, height: size - inset) .position(x: geometry.size.width / 2, y: geometry.size.height / 2) .animation(.spring(response: 0.3, dampingFraction: 0.82), value: detected) } .allowsHitTesting(false) } } private struct ScannerStatusBadge: View { let title: String var body: some View { HStack(spacing: 8) { Circle() .fill(IdP.tint) .frame(width: 8, height: 8) Text(title) .font(.footnote.weight(.semibold)) .foregroundStyle(.white) } .padding(.horizontal, 12) .padding(.vertical, 8) .background(Color.black.opacity(0.26), in: Capsule(style: .continuous)) .overlay( Capsule(style: .continuous) .stroke(Color.white.opacity(0.10), lineWidth: 1) ) } } private enum ManualFallbackSurface { case elevated } private struct ManualFallbackCard: View { @Binding var manualFallback: String var surface: ManualFallbackSurface = .elevated let onUsePayload: () -> Void let onPasteFromClipboard: () -> Void private var hasManualPayload: Bool { !manualFallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var body: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top, spacing: 12) { Image(systemName: "link") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(IdP.tint) .frame(width: 32, height: 32) .background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) VStack(alignment: .leading, spacing: 3) { Text("Paste pairing link") .font(.subheadline.weight(.semibold)) Text("Copy the link from idp.global in your browser and paste it below.") .font(.footnote) .foregroundStyle(Color.idpMutedForeground) .fixedSize(horizontal: false, vertical: true) } } ManualPayloadField(manualFallback: $manualFallback, dark: false) VStack(spacing: 10) { Button("Use pasted payload", action: onUsePayload) .buttonStyle(PrimaryActionStyle()) .disabled(!hasManualPayload) Button(action: onPasteFromClipboard) { Label("Paste from clipboard", systemImage: "doc.on.clipboard") } .buttonStyle(SecondaryActionStyle()) } } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.idpSecondaryGroupedBackground, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) .overlay( 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) UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" #elseif os(macOS) NSPasteboard.general.string(forType: .string)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" #else "" #endif } } private struct CornerTick: View { let rotation: Angle var body: some View { Path { path in let length: CGFloat = 34 path.move(to: CGPoint(x: 0, y: length)) path.addLine(to: CGPoint(x: 0, y: 0)) path.addLine(to: CGPoint(x: length, y: 0)) } .stroke(.white, style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round)) .rotationEffect(rotation) } } private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { @Published var isPreviewAvailable = false @Published var statusMessage = "Point the camera at the QR code from the idp.global web portal." let captureSession = AVCaptureSession() var onCodeScanned: ((String) -> Void)? private let queue = DispatchQueue(label: "global.idp.qrscanner") private var isConfigured = false private var hasDeliveredCode = false func start() async { #if os(iOS) && targetEnvironment(simulator) await MainActor.run { isPreviewAvailable = false statusMessage = "The iOS simulator has no live camera feed. Paste a pairing link below instead." } #endif #if !(os(iOS) && targetEnvironment(simulator)) let authorization = AVCaptureDevice.authorizationStatus(for: .video) switch authorization { case .authorized: await configureIfNeeded() startRunning() case .notDetermined: let granted = await requestCameraAccess() await MainActor.run { self.statusMessage = granted ? "Point the camera at the QR code from the idp.global web portal." : "Camera access was denied. Use the manual fallback instead." } guard granted else { return } await configureIfNeeded() startRunning() case .denied, .restricted: await MainActor.run { isPreviewAvailable = false statusMessage = "Camera access is unavailable. Use the manual fallback instead." } @unknown default: await MainActor.run { isPreviewAvailable = false statusMessage = "Camera access could not be initialized on this device." } } #endif } func stop() { queue.async { if self.captureSession.isRunning { self.captureSession.stopRunning() } } } func metadataOutput( _ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection ) { guard !hasDeliveredCode, let readable = metadataObjects.first as? AVMetadataMachineReadableCodeObject, readable.type == .qr, let payload = readable.stringValue else { return } hasDeliveredCode = true stop() #if os(iOS) UINotificationFeedbackGenerator().notificationOccurred(.success) #endif DispatchQueue.main.async { [onCodeScanned] in onCodeScanned?(payload) } } private func requestCameraAccess() async -> Bool { await withCheckedContinuation { continuation in AVCaptureDevice.requestAccess(for: .video) { granted in continuation.resume(returning: granted) } } } private func configureIfNeeded() async { guard !isConfigured else { await MainActor.run { self.isPreviewAvailable = true } return } await withCheckedContinuation { (continuation: CheckedContinuation) in queue.async { self.captureSession.beginConfiguration() defer { self.captureSession.commitConfiguration() continuation.resume() } guard let device = AVCaptureDevice.default(for: .video) else { DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "No compatible camera was found. Use the manual fallback instead." } return } guard let input = try? AVCaptureDeviceInput(device: device) else { DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "No compatible camera was found. Use the manual fallback instead." } return } guard self.captureSession.canAddInput(input) else { DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "No compatible camera was found. Use the manual fallback instead." } return } self.captureSession.addInput(input) let output = AVCaptureMetadataOutput() guard self.captureSession.canAddOutput(output) else { self.captureSession.removeInput(input) DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "Unable to configure QR scanning on this device." } return } self.captureSession.addOutput(output) output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) let supportedTypes = output.availableMetadataObjectTypes guard supportedTypes.contains(.qr) else { self.captureSession.removeOutput(output) self.captureSession.removeInput(input) DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "This camera does not support QR scanning. Use the manual fallback instead." } return } output.metadataObjectTypes = [.qr] self.isConfigured = true DispatchQueue.main.async { self.isPreviewAvailable = true self.statusMessage = "Point the camera at the QR code from the idp.global web portal." } } } } private func startRunning() { queue.async { guard !self.captureSession.isRunning else { return } self.hasDeliveredCode = false self.captureSession.startRunning() } } } extension QRScannerViewModel: @unchecked Sendable {} #if os(iOS) private struct ScannerPreview: UIViewRepresentable { let session: AVCaptureSession func makeUIView(context: Context) -> ScannerPreviewUIView { let view = ScannerPreviewUIView() view.previewLayer.session = session view.previewLayer.videoGravity = .resizeAspectFill return view } func updateUIView(_ uiView: ScannerPreviewUIView, context: Context) { uiView.previewLayer.session = session } } private final class ScannerPreviewUIView: UIView { override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } } #elseif os(macOS) private struct ScannerPreview: NSViewRepresentable { let session: AVCaptureSession func makeNSView(context: Context) -> ScannerPreviewNSView { let view = ScannerPreviewNSView() view.attach(session: session) return view } func updateNSView(_ nsView: ScannerPreviewNSView, context: Context) { nsView.attach(session: session) } } private final class ScannerPreviewNSView: NSView { private var previewLayer: AVCaptureVideoPreviewLayer? override init(frame frameRect: NSRect) { super.init(frame: frameRect) wantsLayer = true } required init?(coder: NSCoder) { super.init(coder: coder) wantsLayer = true } func attach(session: AVCaptureSession) { let layer = previewLayer ?? AVCaptureVideoPreviewLayer(session: session) layer.session = session layer.videoGravity = .resizeAspectFill self.layer = layer previewLayer = layer } } #endif