This commit is contained in:
@@ -9,7 +9,6 @@ import AppKit
|
||||
#endif
|
||||
|
||||
struct QRScannerSheet: View {
|
||||
let seededPayload: String
|
||||
let title: String
|
||||
let description: String
|
||||
let navigationTitleText: String
|
||||
@@ -19,13 +18,11 @@ struct QRScannerSheet: View {
|
||||
@State private var manualFallback = ""
|
||||
|
||||
init(
|
||||
seededPayload: String,
|
||||
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.seededPayload = seededPayload
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.navigationTitleText = navigationTitle
|
||||
@@ -55,33 +52,18 @@ struct QRScannerSheet: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Manual fallback")
|
||||
.font(.headline)
|
||||
|
||||
TextEditor(text: $manualFallback)
|
||||
.font(.footnote.monospaced())
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(minHeight: 110)
|
||||
.padding(10)
|
||||
.background(Color.idpTertiaryFill, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Button("Use payload") {
|
||||
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
onCodeScanned(chosen.isEmpty ? seededPayload : chosen)
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(PrimaryActionStyle())
|
||||
|
||||
Button("Use demo payload") {
|
||||
manualFallback = seededPayload
|
||||
}
|
||||
.buttonStyle(SecondaryActionStyle())
|
||||
ManualFallbackCard(
|
||||
manualFallback: $manualFallback,
|
||||
onUsePayload: {
|
||||
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !chosen.isEmpty else { return }
|
||||
onCodeScanned(chosen)
|
||||
dismiss()
|
||||
},
|
||||
onPasteFromClipboard: {
|
||||
manualFallback = ClipboardPayloadReader.readString()
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
|
||||
)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
@@ -94,9 +76,6 @@ struct QRScannerSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
manualFallback = seededPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,7 +98,7 @@ struct LiveQRScannerView: View {
|
||||
@State private var didDetectCode = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
ZStack {
|
||||
Group {
|
||||
if scanner.isPreviewAvailable {
|
||||
ScannerPreview(session: scanner.captureSession)
|
||||
@@ -141,21 +120,46 @@ struct LiveQRScannerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Color.black.opacity(0.18)
|
||||
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(alignment: .leading, spacing: 6) {
|
||||
Text("Point the camera at the pairing QR")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(scanner.statusMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.84))
|
||||
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)
|
||||
)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.padding(22)
|
||||
}
|
||||
.task {
|
||||
scanner.onCodeScanned = { payload in
|
||||
@@ -185,6 +189,17 @@ private struct ScanFrameOverlay: View {
|
||||
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))
|
||||
@@ -202,6 +217,110 @@ private struct ScanFrameOverlay: View {
|
||||
}
|
||||
}
|
||||
|
||||
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 struct ManualFallbackCard: View {
|
||||
@Binding var manualFallback: String
|
||||
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: "doc.text.viewfinder")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(IdP.tint)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Paste pairing link")
|
||||
.font(.headline)
|
||||
|
||||
Text("If scanning is unavailable, paste the idp.global pairing link from your browser or clipboard.")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Button("Use pasted payload", action: onUsePayload)
|
||||
.buttonStyle(PrimaryActionStyle())
|
||||
.disabled(!hasManualPayload)
|
||||
|
||||
Button {
|
||||
onPasteFromClipboard()
|
||||
} label: {
|
||||
Label("Paste from clipboard", systemImage: "doc.on.clipboard")
|
||||
}
|
||||
.buttonStyle(SecondaryActionStyle())
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -233,7 +352,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
|
||||
#if os(iOS) && targetEnvironment(simulator)
|
||||
await MainActor.run {
|
||||
isPreviewAvailable = false
|
||||
statusMessage = "The iOS simulator has no live camera feed. Use the demo payload below."
|
||||
statusMessage = "The iOS simulator has no live camera feed. Paste a pairing link below instead."
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
Reference in New Issue
Block a user