Build passport-style identity app shell
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
import SwiftUI
|
||||
|
||||
private let loginAccent = Color(red: 0.12, green: 0.40, blue: 0.31)
|
||||
private let loginGold = Color(red: 0.90, green: 0.79, blue: 0.60)
|
||||
|
||||
struct LoginRootView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: compactLayout ? 18 : 24) {
|
||||
LoginHeroPanel(model: model, compactLayout: compactLayout)
|
||||
PairingConsoleCard(model: model, compactLayout: compactLayout)
|
||||
TrustFootprintCard(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
.frame(maxWidth: 1040)
|
||||
.padding(compactLayout ? 18 : 28)
|
||||
}
|
||||
.sheet(isPresented: $model.isScannerPresented) {
|
||||
QRScannerSheet(
|
||||
seededPayload: model.suggestedQRCodePayload,
|
||||
onCodeScanned: { payload in
|
||||
model.manualQRCodePayload = payload
|
||||
Task {
|
||||
await model.signIn(with: payload)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoginHeroPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
RoundedRectangle(cornerRadius: 36, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.13, green: 0.22, blue: 0.19),
|
||||
Color(red: 0.20, green: 0.41, blue: 0.33),
|
||||
loginGold
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: compactLayout ? 16 : 18) {
|
||||
Text("Bind this device to your idp.global account")
|
||||
.font(.system(size: compactLayout ? 32 : 44, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Scan the pairing QR from your account to turn this device into your approval and notification app.")
|
||||
.font(compactLayout ? .body : .title3)
|
||||
.foregroundStyle(.white.opacity(0.88))
|
||||
|
||||
if compactLayout {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HeroTag(title: "Account binding")
|
||||
HeroTag(title: "QR pairing")
|
||||
HeroTag(title: "iPhone, iPad, Mac")
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
HeroTag(title: "Account binding")
|
||||
HeroTag(title: "QR pairing")
|
||||
HeroTag(title: "iPhone, iPad, Mac")
|
||||
}
|
||||
}
|
||||
|
||||
if model.isBootstrapping {
|
||||
ProgressView("Preparing preview pairing payload…")
|
||||
.tint(.white)
|
||||
}
|
||||
}
|
||||
.padding(compactLayout ? 22 : 32)
|
||||
}
|
||||
.frame(minHeight: compactLayout ? 280 : 320)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PairingConsoleCard: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
LoginCard(title: "Bind your account", subtitle: "Scan the QR code from your idp.global account or use the preview payload while backend wiring is still in progress.") {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Open your account pairing screen, then scan the QR code here.")
|
||||
.font(.headline)
|
||||
Text("If you are testing the preview build without the live backend yet, the seeded payload below will still bind the mock session.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
TextEditor(text: $model.manualQRCodePayload)
|
||||
.font(.body.monospaced())
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(16)
|
||||
.frame(minHeight: compactLayout ? 130 : 150)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
|
||||
if model.isAuthenticating {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Binding this device to your account…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
primaryButtons
|
||||
secondaryButtons
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
primaryButtons
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
secondaryButtons
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var primaryButtons: some View {
|
||||
Button {
|
||||
model.isScannerPresented = true
|
||||
} label: {
|
||||
Label("Bind With QR Code", systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithManualCode()
|
||||
}
|
||||
} label: {
|
||||
if model.isAuthenticating {
|
||||
ProgressView()
|
||||
} else {
|
||||
Label("Bind With Payload", systemImage: "arrow.right.circle.fill")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(model.isAuthenticating)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var secondaryButtons: some View {
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithSuggestedCode()
|
||||
}
|
||||
} label: {
|
||||
Label("Use Preview QR", systemImage: "wand.and.stars")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Text("This preview keeps the account-binding flow realistic while the live API is still being wired in.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TrustFootprintCard: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
LoginCard(title: "About this build", subtitle: "Keep the first-run screen simple, but still explain the trust context and preview status clearly.") {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
trustFacts
|
||||
}
|
||||
} else {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
trustFacts
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Preview Pairing Payload")
|
||||
.font(.headline)
|
||||
Text(model.suggestedQRCodePayload.isEmpty ? "Preparing preview payload…" : model.suggestedQRCodePayload)
|
||||
.font(.footnote.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trustFacts: some View {
|
||||
TrustFactCard(
|
||||
icon: "person.badge.key.fill",
|
||||
title: "Account Binding",
|
||||
message: "This device binds to your idp.global account and becomes your place for approvals and alerts."
|
||||
)
|
||||
TrustFactCard(
|
||||
icon: "person.2.badge.gearshape.fill",
|
||||
title: "Built by foss.global",
|
||||
message: "foss.global is the open-source collective behind idp.global and the current preview environment."
|
||||
)
|
||||
TrustFactCard(
|
||||
icon: "bolt.badge.clock",
|
||||
title: "Preview Backend",
|
||||
message: "Login, requests, and notifications are mocked behind a clean service boundary until live integration is ready."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoginCard<Content: View>: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let content: () -> Content
|
||||
|
||||
init(title: String, subtitle: String, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.title2.weight(.semibold))
|
||||
Text(subtitle)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
content()
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.white.opacity(0.68), in: RoundedRectangle(cornerRadius: 32, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private struct HeroTag: View {
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TrustFactCard: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.title2)
|
||||
.foregroundStyle(loginAccent)
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
Text(message)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct QRScannerSheet: View {
|
||||
let seededPayload: String
|
||||
let onCodeScanned: (String) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var manualFallback = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Use the camera to scan the QR code shown by the web portal. If you’re on a simulator or desktop without a camera, the seeded payload works as a mock fallback.")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
LiveQRScannerView(onCodeScanned: onCodeScanned)
|
||||
.frame(minHeight: 340)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Fallback Pairing Payload")
|
||||
.font(.headline)
|
||||
|
||||
TextEditor(text: $manualFallback)
|
||||
.font(.body.monospaced())
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(14)
|
||||
.frame(minHeight: 120)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
onCodeScanned(chosen.isEmpty ? seededPayload : chosen)
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Use Fallback Payload", systemImage: "arrow.up.forward.square")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
manualFallback = seededPayload
|
||||
} label: {
|
||||
Label("Use Seeded Mock", systemImage: "wand.and.rays")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.navigationTitle("Scan QR Code")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
manualFallback = seededPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiveQRScannerView: View {
|
||||
let onCodeScanned: (String) -> Void
|
||||
|
||||
@StateObject private var scanner = QRScannerViewModel()
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
Group {
|
||||
if scanner.isPreviewAvailable {
|
||||
ScannerPreview(session: scanner.captureSession)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.fill(Color.black.opacity(0.86))
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Image(systemName: "video.slash.fill")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("Live camera preview unavailable")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(scanner.statusMessage)
|
||||
.foregroundStyle(.white.opacity(0.78))
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.22), lineWidth: 1.5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Camera Scanner")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(scanner.statusMessage)
|
||||
.foregroundStyle(.white.opacity(0.84))
|
||||
}
|
||||
.padding(22)
|
||||
|
||||
ScanFrameOverlay()
|
||||
.padding(40)
|
||||
}
|
||||
.task {
|
||||
scanner.onCodeScanned = { payload in
|
||||
onCodeScanned(payload)
|
||||
}
|
||||
await scanner.start()
|
||||
}
|
||||
.onDisappear {
|
||||
scanner.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ScanFrameOverlay: View {
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let size = min(geometry.size.width, geometry.size.height) * 0.5
|
||||
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.82), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
|
||||
.frame(width: size, height: size)
|
||||
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
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. Use the seeded payload below."
|
||||
}
|
||||
#else
|
||||
#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 fallback payload below."
|
||||
}
|
||||
guard granted else { return }
|
||||
await configureIfNeeded()
|
||||
startRunning()
|
||||
case .denied, .restricted:
|
||||
await MainActor.run {
|
||||
isPreviewAvailable = false
|
||||
statusMessage = "Camera access is unavailable. Use the fallback payload below."
|
||||
}
|
||||
@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),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
self.captureSession.canAddInput(input) else {
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.captureSession.addInput(input)
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard self.captureSession.canAddOutput(output) else {
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "Unable to configure QR metadata scanning on this device."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.captureSession.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||
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
|
||||
Reference in New Issue
Block a user