refine pairing onboarding and scanner presentation

This commit is contained in:
2026-04-20 19:18:03 +00:00
parent 5b6de05b11
commit 75f5a4e7e8
3 changed files with 580 additions and 222 deletions
+195 -95
View File
@@ -54,110 +54,187 @@ private struct PairingWelcomeView: View {
let onNFCRequested: () -> Void
var body: some View {
AppScrollScreen(compactLayout: true, bottomPadding: 150) {
AppPanel(compactLayout: true, radius: 30) {
ShadcnBadge(
title: "Passport setup",
tone: .accent,
leading: Image(systemName: "shield.lefthalf.filled")
AppScrollScreen(compactLayout: true, bottomPadding: 170) {
PairingHero()
PairingStepsCard()
PairingChecklistCard()
}
.navigationTitle("")
.toolbar(.hidden, for: .navigationBar)
.safeAreaInset(edge: .bottom) {
PairingActionDock(
isAuthenticating: model.isAuthenticating,
onScanRequested: onScanRequested,
onNFCRequested: onNFCRequested
)
}
}
}
private struct PairingHero: View {
var body: some View {
VStack(alignment: .leading, spacing: 18) {
HeroGlyph()
VStack(alignment: .leading, spacing: 10) {
Text("Your passport, in your pocket")
.font(.system(size: 30, weight: .bold, design: .default))
.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, 8)
}
}
private struct HeroGlyph: View {
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(
LinearGradient(
colors: [
IdP.tint,
IdP.tint.opacity(0.78)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 76, height: 76)
.shadow(color: IdP.tint.opacity(0.35), radius: 18, x: 0, y: 10)
HStack(alignment: .top, spacing: 16) {
ZStack {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(IdP.tint.opacity(0.14))
.frame(width: 72, height: 72)
RoundedRectangle(cornerRadius: 26, style: .continuous)
.stroke(Color.white.opacity(0.22), lineWidth: 1)
.frame(width: 76, height: 76)
Image(systemName: "qrcode.viewfinder")
.font(.system(size: 30, weight: .semibold))
.foregroundStyle(IdP.tint)
}
Image(systemName: "shield.lefthalf.filled")
.font(.system(size: 34, weight: .semibold))
.foregroundStyle(.white)
}
}
}
VStack(alignment: .leading, spacing: 8) {
Text("Link this iPhone to your idp.global account")
.font(.title2.weight(.bold))
.fixedSize(horizontal: false, vertical: true)
Text("Start pairing in your browser, then scan the fresh QR on the next screen. This device becomes your passport for approvals, alerts, and identity proofs.")
.font(.subheadline)
.foregroundStyle(Color.idpMutedForeground)
}
}
Text("No camera? The scanner screen also lets you paste a pairing link manually.")
private struct PairingStepsCard: View {
var body: some View {
AppPanel(compactLayout: true, radius: 22) {
VStack(alignment: .leading, spacing: 4) {
Text("How pairing works")
.font(.headline)
Text("Three quick steps to finish setup.")
.font(.footnote)
.foregroundStyle(Color.idpMutedForeground)
}
AppSectionCard(
title: "How pairing works",
subtitle: "The QR is created by the web session you want to trust.",
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 flow."
)
VStack(spacing: 0) {
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 QR",
message: "Use the camera on the next screen to scan the pairing QR shown by that browser session."
)
StepDivider()
PairingStepRow(
number: 3,
title: "Approve future sign-ins",
message: "Once linked, this iPhone can receive approval requests and security alerts."
)
}
}
PairingStepRow(
number: 2,
title: "Scan the pairing QR",
message: "Point this iPhone at the QR shown by that browser session."
)
AppSectionCard(
title: "Before you scan",
subtitle: "A few quick checks help the link complete cleanly.",
compactLayout: true
) {
VStack(alignment: .leading, spacing: 10) {
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: "wave.3.right", text: "If your organization uses NFC tags, you can link with NFC instead of QR.")
}
StepDivider()
PairingStepRow(
number: 3,
title: "Approve future sign-ins",
message: "Once linked, this phone receives approval requests and alerts."
)
}
}
.navigationTitle("Welcome")
.idpInlineNavigationTitle()
.safeAreaInset(edge: .bottom) {
VStack(spacing: 10) {
Button(action: onScanRequested) {
HStack(spacing: 10) {
if model.isAuthenticating {
ProgressView()
.progressViewStyle(.circular)
.tint(Color.idpPrimaryForeground)
} else {
Image(systemName: "camera.viewfinder")
}
}
}
Text(model.isAuthenticating ? "Linking device..." : "Scan pairing QR")
private struct PairingChecklistCard: View {
var body: some View {
AppPanel(compactLayout: true, radius: 22) {
VStack(alignment: .leading, spacing: 4) {
Text("Before you scan")
.font(.headline)
Text("A few quick checks help the link complete cleanly.")
.font(.footnote)
.foregroundStyle(Color.idpMutedForeground)
}
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")
}
}
.buttonStyle(PrimaryActionStyle())
.disabled(model.isAuthenticating)
Button(action: onNFCRequested) {
Label("Use NFC tag instead", systemImage: "wave.3.right")
Text(isAuthenticating ? "Linking device..." : "Scan pairing QR")
}
.buttonStyle(SecondaryActionStyle())
.disabled(model.isAuthenticating)
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 14)
.background(.regularMaterial)
.buttonStyle(PrimaryActionStyle())
.disabled(isAuthenticating)
Button(action: onNFCRequested) {
Label("Use NFC tag instead", systemImage: "wave.3.right")
}
.buttonStyle(SecondaryActionStyle())
.disabled(isAuthenticating)
}
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 14)
.background(
Rectangle()
.fill(.regularMaterial)
.ignoresSafeArea()
.overlay(alignment: .top) {
Rectangle()
.fill(Color.idpSeparator.opacity(0.5))
.frame(height: 0.5)
}
)
}
}
@@ -167,20 +244,39 @@ private struct PairingStepRow: View {
let message: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Text("\(number)")
.font(.subheadline.weight(.bold))
.frame(width: 28, height: 28)
.foregroundStyle(IdP.tint)
.background(IdP.tint.opacity(0.12), in: Circle())
HStack(alignment: .top, spacing: 14) {
ZStack {
Circle()
.fill(IdP.tint.opacity(0.14))
Text("\(number)")
.font(.subheadline.weight(.bold))
.foregroundStyle(IdP.tint)
}
.frame(width: 30, height: 30)
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(message)
.font(.footnote)
.foregroundStyle(Color.idpMutedForeground)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.padding(.vertical, 10)
}
}
private struct StepDivider: View {
var body: some View {
HStack(spacing: 14) {
Rectangle()
.fill(Color.idpSeparator.opacity(0.6))
.frame(width: 1, height: 10)
.padding(.leading, 14)
Spacer()
}
}
}
@@ -190,15 +286,19 @@ private struct PairingNoteRow: View {
let text: String
var body: some View {
HStack(alignment: .top, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(IdP.tint)
.frame(width: 18, height: 18)
.frame(width: 26, height: 26)
.background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
Text(text)
.font(.footnote)
.foregroundStyle(Color.idpMutedForeground)
.foregroundStyle(Color.idpForeground.opacity(0.82))
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
}
}