Overhaul native approval UX and add widget surfaces
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:
2026-04-19 16:29:13 +02:00
parent a6939453f8
commit 61a0cc1f7d
63 changed files with 3496 additions and 1769 deletions

View File

@@ -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
}