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
+383 -127
View File
@@ -31,42 +31,16 @@ struct QRScannerSheet: View {
var body: some View {
NavigationStack {
ZStack(alignment: .top) {
LiveQRScannerView { payload in
ScannerContent(
title: title,
description: description,
manualFallback: $manualFallback,
onCodeScanned: { payload in
onCodeScanned(payload)
dismiss()
}
.ignoresSafeArea()
VStack(spacing: 12) {
IdPGlassCapsule {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.headline)
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
ManualFallbackCard(
manualFallback: $manualFallback,
onUsePayload: {
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
guard !chosen.isEmpty else { return }
onCodeScanned(chosen)
dismiss()
},
onPasteFromClipboard: {
manualFallback = ClipboardPayloadReader.readString()
}
)
}
.padding(16)
}
},
onDismiss: { dismiss() }
)
.navigationTitle(navigationTitleText)
.applyInlineNavigationTitleDisplayMode()
.toolbar {
@@ -91,74 +65,35 @@ private extension View {
}
}
struct LiveQRScannerView: View {
private struct ScannerContent: View {
let title: String
let description: String
@Binding var manualFallback: String
let onCodeScanned: (String) -> Void
let onDismiss: () -> Void
@StateObject private var scanner = QRScannerViewModel()
@State private var didDetectCode = false
var body: some View {
ZStack {
Group {
if scanner.isPreviewAvailable {
ScannerPreview(session: scanner.captureSession)
} else {
Color.black
VStack(alignment: .leading, spacing: 10) {
Image(systemName: "video.slash.fill")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.white)
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)
}
}
LinearGradient(
colors: [Color.black.opacity(0.64), Color.clear, Color.black.opacity(0.78)],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
ScanFrameOverlay(detected: didDetectCode)
.padding(40)
VStack {
HStack {
ScannerStatusBadge(title: scanner.isPreviewAvailable ? "Camera ready" : "Manual fallback")
Spacer()
}
.padding(.horizontal, 20)
.padding(.top, 18)
Spacer()
VStack(alignment: .leading, spacing: 8) {
Text("Align the pairing QR inside the frame")
.font(.headline.weight(.semibold))
.foregroundStyle(.white)
Text(scanner.statusMessage)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.84))
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(Color.black.opacity(0.34), in: RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
Group {
if scanner.isPreviewAvailable {
LiveScannerSurface(
title: title,
description: description,
manualFallback: $manualFallback,
scanner: scanner,
didDetectCode: didDetectCode,
onCodeScanned: onCodeScanned
)
} else {
ManualOnlySurface(
title: title,
description: description,
statusMessage: scanner.statusMessage,
manualFallback: $manualFallback,
onCodeScanned: onCodeScanned
)
.padding(.horizontal, 18)
.padding(.bottom, 18)
}
}
.task {
@@ -166,9 +101,8 @@ struct LiveQRScannerView: View {
withAnimation(.spring(response: 0.3, dampingFraction: 0.82)) {
didDetectCode = true
}
Task {
try? await Task.sleep(for: .milliseconds(180))
try? await Task.sleep(for: .milliseconds(220))
onCodeScanned(payload)
}
}
@@ -180,6 +114,307 @@ struct LiveQRScannerView: View {
}
}
private struct LiveScannerSurface: View {
let title: String
let description: String
@Binding var manualFallback: String
@ObservedObject var scanner: QRScannerViewModel
let didDetectCode: Bool
let onCodeScanned: (String) -> Void
@State private var isFallbackExpanded = false
var body: some View {
ZStack {
ScannerPreview(session: scanner.captureSession)
.ignoresSafeArea()
LinearGradient(
colors: [Color.black.opacity(0.68), Color.clear, Color.black.opacity(0.82)],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
ScanFrameOverlay(detected: didDetectCode)
.padding(40)
VStack(spacing: 14) {
ScannerTopHint(title: title, description: description)
.padding(.horizontal, 18)
.padding(.top, 6)
Spacer(minLength: 0)
ScannerAlignmentHint(statusMessage: scanner.statusMessage)
.padding(.horizontal, 18)
ExpandableFallback(
isExpanded: $isFallbackExpanded,
manualFallback: $manualFallback,
onCodeScanned: onCodeScanned
)
.padding(.horizontal, 18)
.padding(.bottom, 14)
}
}
}
}
private struct ManualOnlySurface: View {
let title: String
let description: String
let statusMessage: String
@Binding var manualFallback: String
let onCodeScanned: (String) -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
ManualHero(statusMessage: statusMessage)
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.title3.weight(.bold))
.fixedSize(horizontal: false, vertical: true)
Text(description)
.font(.subheadline)
.foregroundStyle(Color.idpMutedForeground)
.fixedSize(horizontal: false, vertical: true)
}
ManualFallbackCard(
manualFallback: $manualFallback,
surface: .elevated,
onUsePayload: {
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
guard !chosen.isEmpty else { return }
onCodeScanned(chosen)
},
onPasteFromClipboard: {
manualFallback = ClipboardPayloadReader.readString()
}
)
}
.frame(maxWidth: 560, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 20)
.padding(.top, 16)
.padding(.bottom, 28)
}
.background(Color.idpGroupedBackground.ignoresSafeArea())
.scrollIndicators(.hidden)
}
}
private struct ManualHero: View {
let statusMessage: String
var body: some View {
HStack(alignment: .top, spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(
LinearGradient(
colors: [IdP.tint, IdP.tint.opacity(0.78)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Image(systemName: "doc.text.viewfinder")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(.white)
}
.frame(width: 60, height: 60)
.shadow(color: IdP.tint.opacity(0.28), radius: 14, x: 0, y: 8)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Circle()
.fill(Color.idpWarn)
.frame(width: 7, height: 7)
Text("Camera unavailable")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.idpWarn)
}
Text(statusMessage)
.font(.footnote)
.foregroundStyle(Color.idpMutedForeground)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
}
}
private struct ScannerTopHint: View {
let title: String
let description: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "qrcode.viewfinder")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.background(Color.white.opacity(0.16), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
Text(description)
.font(.caption)
.foregroundStyle(.white.opacity(0.78))
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.padding(14)
.background(Color.black.opacity(0.38), in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1)
)
}
}
private struct ScannerAlignmentHint: View {
let statusMessage: String
var body: some View {
HStack(spacing: 10) {
ProgressPulse()
Text(statusMessage)
.font(.footnote)
.foregroundStyle(.white.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.black.opacity(0.32), in: Capsule(style: .continuous))
.overlay(
Capsule(style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1)
)
}
}
private struct ProgressPulse: View {
@State private var animate = false
var body: some View {
ZStack {
Circle()
.fill(IdP.tint)
.frame(width: 8, height: 8)
Circle()
.stroke(IdP.tint.opacity(0.6), lineWidth: 1.5)
.frame(width: 16, height: 16)
.scaleEffect(animate ? 1.4 : 0.8)
.opacity(animate ? 0 : 0.8)
.animation(.easeOut(duration: 1.2).repeatForever(autoreverses: false), value: animate)
}
.onAppear { animate = true }
}
}
private struct ExpandableFallback: View {
@Binding var isExpanded: Bool
@Binding var manualFallback: String
let onCodeScanned: (String) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(.spring(response: 0.35, dampingFraction: 0.86)) {
isExpanded.toggle()
}
} label: {
HStack(spacing: 12) {
Image(systemName: "doc.on.clipboard")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
Text(isExpanded ? "Hide manual entry" : "Paste pairing link instead")
.font(.footnote.weight(.semibold))
.foregroundStyle(.white)
Spacer(minLength: 0)
Image(systemName: isExpanded ? "chevron.down" : "chevron.up")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.72))
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded {
VStack(alignment: .leading, spacing: 12) {
ManualPayloadField(manualFallback: $manualFallback, dark: true)
Button {
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
guard !chosen.isEmpty else { return }
onCodeScanned(chosen)
} label: {
Text("Use pasted payload")
}
.buttonStyle(PrimaryActionStyle())
.disabled(manualFallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button {
manualFallback = ClipboardPayloadReader.readString()
} label: {
Label("Paste from clipboard", systemImage: "doc.on.clipboard")
.foregroundStyle(.white)
}
.buttonStyle(DarkOutlineButtonStyle())
}
.padding(.horizontal, 16)
.padding(.bottom, 14)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.background(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(Color.black.opacity(0.38))
)
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1)
)
}
}
private struct DarkOutlineButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.frame(height: 44)
.foregroundStyle(.white)
.background(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.fill(Color.white.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.stroke(Color.white.opacity(0.20), lineWidth: 1)
)
.opacity(configuration.isPressed ? 0.85 : 1)
}
}
private struct ScanFrameOverlay: View {
let detected: Bool
@@ -240,8 +475,13 @@ private struct ScannerStatusBadge: View {
}
}
private enum ManualFallbackSurface {
case elevated
}
private struct ManualFallbackCard: View {
@Binding var manualFallback: String
var surface: ManualFallbackSurface = .elevated
let onUsePayload: () -> Void
let onPasteFromClipboard: () -> Void
@@ -252,63 +492,79 @@ private struct ManualFallbackCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "doc.text.viewfinder")
.font(.system(size: 18, weight: .semibold))
Image(systemName: "link")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(IdP.tint)
.frame(width: 34, height: 34)
.background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.frame(width: 32, height: 32)
.background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 3) {
Text("Paste pairing link")
.font(.headline)
.font(.subheadline.weight(.semibold))
Text("If scanning is unavailable, paste the idp.global pairing link from your browser or clipboard.")
Text("Copy the link from idp.global in your browser and paste it below.")
.font(.footnote)
.foregroundStyle(Color.idpMutedForeground)
.fixedSize(horizontal: false, vertical: true)
}
}
ZStack(alignment: .topLeading) {
TextEditor(text: $manualFallback)
.font(.footnote.monospaced())
.scrollContentBackground(.hidden)
.frame(minHeight: 110)
.padding(10)
.background(Color.idpTertiaryFill, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
if manualFallback.isEmpty {
Text("idp.global://pair?token=...")
.font(.footnote.monospaced())
.foregroundStyle(Color.idpMutedForeground)
.padding(.horizontal, 16)
.padding(.vertical, 18)
.allowsHitTesting(false)
}
}
ManualPayloadField(manualFallback: $manualFallback, dark: false)
VStack(spacing: 10) {
Button("Use pasted payload", action: onUsePayload)
.buttonStyle(PrimaryActionStyle())
.disabled(!hasManualPayload)
Button {
onPasteFromClipboard()
} label: {
Button(action: onPasteFromClipboard) {
Label("Paste from clipboard", systemImage: "doc.on.clipboard")
}
.buttonStyle(SecondaryActionStyle())
}
}
.padding(18)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.idpSecondaryGroupedBackground, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1)
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(Color.idpSeparator, lineWidth: 0.5)
)
}
}
private struct ManualPayloadField: View {
@Binding var manualFallback: String
let dark: Bool
var body: some View {
ZStack(alignment: .topLeading) {
TextEditor(text: $manualFallback)
.font(.footnote.monospaced())
.foregroundStyle(dark ? Color.white : Color.idpForeground)
.scrollContentBackground(.hidden)
.frame(minHeight: 96)
.padding(10)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(dark ? Color.white.opacity(0.08) : Color.idpTertiaryFill)
)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(dark ? Color.white.opacity(0.14) : Color.idpSeparator, lineWidth: 0.5)
)
if manualFallback.isEmpty {
Text("idpglobal://pair?token=…")
.font(.footnote.monospaced())
.foregroundStyle(dark ? Color.white.opacity(0.4) : Color.idpMutedForeground)
.padding(.horizontal, 16)
.padding(.vertical, 18)
.allowsHitTesting(false)
}
}
}
}
private enum ClipboardPayloadReader {
static func readString() -> String {
#if os(iOS)