Build passport-style identity app shell

This commit is contained in:
2026-04-17 22:08:27 +02:00
commit 6936ad5cfe
11 changed files with 4922 additions and 0 deletions
+300
View File
@@ -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))
}
}
+359
View File
@@ -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 youre 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