Overhaul native approval UX and add widget surfaces
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
Bring the SwiftUI app in line with the Apple-native mock and keep pending approvals actionable from Live Activities and watch complications.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
@@ -15,7 +16,6 @@ struct QRScannerSheet: View {
|
||||
let onCodeScanned: (String) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var manualFallback = ""
|
||||
|
||||
init(
|
||||
@@ -34,33 +34,59 @@ struct QRScannerSheet: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
AppScrollScreen(compactLayout: compactLayout) {
|
||||
AppSectionCard(title: title, compactLayout: compactLayout) {
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
LiveQRScannerView(onCodeScanned: onCodeScanned)
|
||||
.frame(minHeight: 340)
|
||||
ZStack(alignment: .top) {
|
||||
LiveQRScannerView { payload in
|
||||
onCodeScanned(payload)
|
||||
dismiss()
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
|
||||
AppSectionCard(title: "Manual fallback", compactLayout: compactLayout) {
|
||||
AppTextEditorField(text: $manualFallback, minHeight: 120)
|
||||
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
useFallbackButton
|
||||
useSeededButton
|
||||
VStack(spacing: 12) {
|
||||
IdPGlassCapsule {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
useFallbackButton
|
||||
useSeededButton
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.navigationTitle(navigationTitleText)
|
||||
.applyInlineNavigationTitleDisplayMode()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
@@ -73,85 +99,74 @@ struct QRScannerSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func applyInlineNavigationTitleDisplayMode() -> some View {
|
||||
#if os(macOS)
|
||||
self
|
||||
#else
|
||||
false
|
||||
navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var useFallbackButton: some View {
|
||||
Button {
|
||||
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
onCodeScanned(chosen.isEmpty ? seededPayload : chosen)
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Use payload", systemImage: "arrow.up.forward.square")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
private var useSeededButton: some View {
|
||||
Button {
|
||||
manualFallback = seededPayload
|
||||
} label: {
|
||||
Label("Reset sample", systemImage: "wand.and.rays")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiveQRScannerView: View {
|
||||
struct LiveQRScannerView: View {
|
||||
let onCodeScanned: (String) -> Void
|
||||
|
||||
@StateObject private var scanner = QRScannerViewModel()
|
||||
@State private var didDetectCode = false
|
||||
|
||||
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))
|
||||
Color.black
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Image(systemName: "video.slash.fill")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("Live camera preview unavailable")
|
||||
Text("Camera preview unavailable")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(scanner.statusMessage)
|
||||
.foregroundStyle(.white.opacity(0.78))
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.22), lineWidth: 1.5)
|
||||
Color.black.opacity(0.18)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Camera Scanner")
|
||||
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))
|
||||
}
|
||||
.padding(22)
|
||||
|
||||
ScanFrameOverlay()
|
||||
.padding(40)
|
||||
}
|
||||
.task {
|
||||
scanner.onCodeScanned = { payload in
|
||||
onCodeScanned(payload)
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.82)) {
|
||||
didDetectCode = true
|
||||
}
|
||||
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(180))
|
||||
onCodeScanned(payload)
|
||||
}
|
||||
}
|
||||
await scanner.start()
|
||||
}
|
||||
@@ -162,19 +177,46 @@ private struct LiveQRScannerView: View {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
ZStack {
|
||||
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 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."
|
||||
@@ -191,9 +233,8 @@ 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 seeded payload below."
|
||||
statusMessage = "The iOS simulator has no live camera feed. Use the demo payload below."
|
||||
}
|
||||
#else
|
||||
#endif
|
||||
|
||||
#if !(os(iOS) && targetEnvironment(simulator))
|
||||
@@ -207,7 +248,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
|
||||
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."
|
||||
: "Camera access was denied. Use the manual fallback instead."
|
||||
}
|
||||
guard granted else { return }
|
||||
await configureIfNeeded()
|
||||
@@ -215,7 +256,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
|
||||
case .denied, .restricted:
|
||||
await MainActor.run {
|
||||
isPreviewAvailable = false
|
||||
statusMessage = "Camera access is unavailable. Use the fallback payload below."
|
||||
statusMessage = "Camera access is unavailable. Use the manual fallback instead."
|
||||
}
|
||||
@unknown default:
|
||||
await MainActor.run {
|
||||
@@ -285,7 +326,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
|
||||
guard let device = AVCaptureDevice.default(for: .video) else {
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
||||
self.statusMessage = "No compatible camera was found. Use the manual fallback instead."
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -293,7 +334,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
|
||||
guard let input = try? AVCaptureDeviceInput(device: device) else {
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
||||
self.statusMessage = "No compatible camera was found. Use the manual fallback instead."
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -301,7 +342,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
|
||||
guard self.captureSession.canAddInput(input) else {
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
||||
self.statusMessage = "No compatible camera was found. Use the manual fallback instead."
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -313,7 +354,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
|
||||
self.captureSession.removeInput(input)
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "Unable to configure QR metadata scanning on this device."
|
||||
self.statusMessage = "Unable to configure QR scanning on this device."
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -327,7 +368,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
|
||||
self.captureSession.removeInput(input)
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "This camera does not support QR metadata scanning. Use the fallback payload below."
|
||||
self.statusMessage = "This camera does not support QR scanning. Use the manual fallback instead."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user