Files
swiftapp/swift/Sources/Features/Auth/LoginRootView.swift
T
jkunz 8057216006
CI / test (push) Has been cancelled
give the welcome screen glass chrome and shadcn polish
- Wrap the pairing action dock in a rounded glassEffect container with linear-gradient edge fades at the top and bottom of the scroll, producing the native iOS 26 Liquid Glass chrome without masking blur hacks.
- Realign the welcome layout to match the shadcn cards used by the home tab: tighter 40pt hero glyph, ShadcnBadge callout, AppSectionCard step/note rows, and PrimaryActionStyle/SecondaryActionStyle buttons instead of a tint-colored glassProminent pill.
- Add a VariableBlurView utility in GlassChrome.swift (adapted from nikstar/VariableBlur, MIT) for cases where a real progressive Gaussian blur is needed, and capture the chrome/blur playbook plus tsswift screenshot workflow notes in readme.knowledge.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:45:47 +00:00

356 lines
12 KiB
Swift

import SwiftUI
struct LoginRootView: View {
@ObservedObject var model: AppViewModel
#if !os(macOS)
@State private var isNFCSheetPresented = false
#endif
var body: some View {
#if os(macOS)
MacPairingView(model: model)
#else
NavigationStack {
PairingWelcomeView(
model: model,
onScanRequested: {
model.isScannerPresented = true
},
onNFCRequested: {
isNFCSheetPresented = true
}
)
.fullScreenCover(isPresented: $model.isScannerPresented) {
QRScannerSheet(
title: "Scan pairing QR",
description: "Open pairing in your idp.global web session, then scan the QR shown there to link this iPhone.",
navigationTitle: "Link Passport"
) { payload in
model.manualPairingPayload = payload
Task {
await model.signIn(with: payload, transport: .qr)
}
}
}
}
.sheet(isPresented: $isNFCSheetPresented) {
NFCSheet(
title: "Hold near pairing tag",
message: "Use a supported idp.global NFC tag to link this iPhone and attach a signed location proof.",
actionTitle: "Link device"
) { request in
await model.signIn(with: request)
}
}
#endif
}
}
#if !os(macOS)
private struct PairingWelcomeView: View {
@ObservedObject var model: AppViewModel
let onScanRequested: () -> Void
let onNFCRequested: () -> Void
var body: some View {
GeometryReader { proxy in
let topInset = proxy.safeAreaInsets.top
ZStack(alignment: .bottom) {
AppScrollScreen(compactLayout: true, bottomPadding: 220) {
Color.clear.frame(height: topInset + 8)
.padding(.top, -AppLayout.compactVerticalPadding)
PairingHero()
PairingStepsCard()
PairingChecklistCard()
}
.ignoresSafeArea(.container, edges: .vertical)
EdgeFade(edge: .top, height: topInset)
.frame(maxHeight: .infinity, alignment: .top)
.ignoresSafeArea()
.allowsHitTesting(false)
EdgeFade(edge: .bottom, height: 200)
.frame(maxHeight: .infinity, alignment: .bottom)
.ignoresSafeArea()
.allowsHitTesting(false)
PairingActionDock(
isAuthenticating: model.isAuthenticating,
onScanRequested: onScanRequested,
onNFCRequested: onNFCRequested
)
}
}
.navigationTitle("")
.toolbar(.hidden, for: .navigationBar)
}
}
private struct EdgeFade: View {
enum Edge { case top, bottom }
let edge: Edge
let height: CGFloat
var body: some View {
LinearGradient(
stops: edge == .top ? [
.init(color: Color.idpGroupedBackground, location: 0.0),
.init(color: Color.idpGroupedBackground.opacity(0.95), location: 0.4),
.init(color: Color.idpGroupedBackground.opacity(0.0), location: 1.0)
] : [
.init(color: Color.idpGroupedBackground.opacity(0.0), location: 0.0),
.init(color: Color.idpGroupedBackground.opacity(0.95), location: 0.6),
.init(color: Color.idpGroupedBackground, location: 1.0)
],
startPoint: .top,
endPoint: .bottom
)
.frame(height: height)
}
}
private struct PairingHero: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 10) {
HeroGlyph()
ShadcnBadge(
title: "Passport setup",
tone: .accent,
leading: Image(systemName: "lock.shield")
)
}
VStack(alignment: .leading, spacing: 8) {
Text("Your passport, in your pocket")
.font(.title2.weight(.bold))
.fixedSize(horizontal: false, vertical: true)
Text("Link this iPhone to your idp.global web session to approve sign-ins, receive security alerts, and prove your identity with NFC.")
.font(.subheadline)
.foregroundStyle(Color.idpMutedForeground)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
}
}
private struct HeroGlyph: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.fill(IdP.tint)
Image(systemName: "shield.lefthalf.filled")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color.idpPrimaryForeground)
}
.frame(width: 40, height: 40)
}
}
private struct PairingStepsCard: View {
var body: some View {
AppSectionCard(
title: "How pairing works",
subtitle: "Three quick steps to finish setup.",
compactLayout: true
) {
VStack(spacing: 14) {
PairingStepRow(
number: 1,
title: "Start from the web",
message: "Open idp.global in your browser and begin a new device pairing."
)
PairingStepRow(
number: 2,
title: "Scan the pairing QR",
message: "Point this iPhone at the QR shown by that browser session."
)
PairingStepRow(
number: 3,
title: "Approve future sign-ins",
message: "Once linked, this phone receives approval requests and alerts."
)
}
}
}
}
private struct PairingChecklistCard: View {
var body: some View {
AppSectionCard(
title: "Before you scan",
subtitle: "A few quick checks help the link complete cleanly.",
compactLayout: true
) {
VStack(alignment: .leading, spacing: 12) {
PairingNoteRow(
icon: "safari",
text: "Keep the pairing page open in your browser until this phone confirms the link."
)
PairingNoteRow(
icon: "camera.fill",
text: "Grant camera access when prompted so the QR can be read directly."
)
PairingNoteRow(
icon: "doc.on.clipboard",
text: "No camera? The scanner also accepts a pasted pairing link."
)
PairingNoteRow(
icon: "wave.3.right",
text: "Organizations using NFC tags can link with NFC instead of QR."
)
}
}
}
}
private struct PairingActionDock: View {
let isAuthenticating: Bool
let onScanRequested: () -> Void
let onNFCRequested: () -> Void
var body: some View {
VStack(spacing: 10) {
Button(action: onScanRequested) {
HStack(spacing: 10) {
if isAuthenticating {
ProgressView()
.progressViewStyle(.circular)
.tint(Color.idpPrimaryForeground)
} else {
Image(systemName: "qrcode.viewfinder")
}
Text(isAuthenticating ? "Linking device..." : "Scan pairing QR")
}
}
.buttonStyle(PrimaryActionStyle())
.disabled(isAuthenticating)
Button(action: onNFCRequested) {
Label("Use NFC tag instead", systemImage: "wave.3.right")
}
.buttonStyle(SecondaryActionStyle())
.disabled(isAuthenticating)
}
.padding(16)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
}
private struct PairingStepRow: View {
let number: Int
let title: String
let message: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(IdP.tint.opacity(0.12))
Text("\(number)")
.font(.caption.weight(.bold))
.foregroundStyle(IdP.tint)
}
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(message)
.font(.footnote)
.foregroundStyle(Color.idpMutedForeground)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
}
}
private struct PairingNoteRow: View {
let icon: String
let text: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.caption.weight(.semibold))
.foregroundStyle(IdP.tint)
.frame(width: 24, height: 24)
.background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 6, style: .continuous))
Text(text)
.font(.footnote)
.foregroundStyle(Color.idpForeground)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
}
}
#endif
#if os(macOS)
private struct MacPairingView: View {
@ObservedObject var model: AppViewModel
var body: some View {
VStack(alignment: .leading, spacing: 18) {
HStack(spacing: 12) {
Image(systemName: "shield.lefthalf.filled")
.font(.title2)
.foregroundStyle(IdP.tint)
VStack(alignment: .leading, spacing: 2) {
Text("Set up idp.global")
.font(.headline)
Text("Use the demo payload or paste a pairing link.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
TextEditor(text: $model.manualPairingPayload)
.font(.footnote.monospaced())
.scrollContentBackground(.hidden)
.frame(minHeight: 140)
.padding(10)
.background(Color.idpSecondaryGroupedBackground, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
VStack(spacing: 10) {
Button("Use demo payload") {
Task {
await model.signInWithSuggestedPayload()
}
}
.buttonStyle(PrimaryActionStyle())
Button("Link with payload") {
Task {
await model.signInWithManualPayload()
}
}
.buttonStyle(SecondaryActionStyle())
}
}
.padding(20)
}
}
#endif