835 lines
28 KiB
Swift
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
|