Files
swiftapp/swift/Sources/Features/Auth/QRScannerView.swift
T

835 lines
28 KiB
Swift

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<Void, Never>) 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