Refocus app around identity proof flows

This commit is contained in:
2026-04-18 01:05:22 +02:00
parent d195037eb6
commit ea6b45388f
45 changed files with 2784 additions and 3159 deletions

View File

@@ -0,0 +1,409 @@
import SwiftUI
enum AppTheme {
static let accent = Color(red: 0.12, green: 0.40, blue: 0.31)
static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48)
static let border = Color.black.opacity(0.08)
static let shadow = Color.black.opacity(0.05)
static let cardFill = Color.white.opacity(0.96)
static let mutedFill = Color(red: 0.972, green: 0.976, blue: 0.970)
}
enum AppLayout {
static let compactHorizontalPadding: CGFloat = 16
static let regularHorizontalPadding: CGFloat = 28
static let compactVerticalPadding: CGFloat = 18
static let regularVerticalPadding: CGFloat = 28
static let compactContentWidth: CGFloat = 720
static let regularContentWidth: CGFloat = 920
static let cardRadius: CGFloat = 24
static let largeCardRadius: CGFloat = 30
static let compactSectionPadding: CGFloat = 18
static let regularSectionPadding: CGFloat = 24
static let compactSectionSpacing: CGFloat = 18
static let regularSectionSpacing: CGFloat = 24
static let compactBottomDockPadding: CGFloat = 120
static let regularBottomPadding: CGFloat = 56
static func horizontalPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactHorizontalPadding : regularHorizontalPadding
}
static func verticalPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactVerticalPadding : regularVerticalPadding
}
static func contentWidth(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactContentWidth : regularContentWidth
}
static func sectionPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactSectionPadding : regularSectionPadding
}
static func sectionSpacing(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactSectionSpacing : regularSectionSpacing
}
}
extension View {
func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View {
background(
fill,
in: RoundedRectangle(cornerRadius: radius, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
.shadow(color: AppTheme.shadow, radius: 12, y: 3)
}
}
struct AppBackground: View {
var body: some View {
LinearGradient(
colors: [
Color(red: 0.975, green: 0.978, blue: 0.972),
Color.white
],
startPoint: .top,
endPoint: .bottom
)
.overlay(alignment: .top) {
Rectangle()
.fill(Color.black.opacity(0.02))
.frame(height: 160)
.blur(radius: 60)
.offset(y: -90)
}
.ignoresSafeArea()
}
}
struct AppScrollScreen<Content: View>: View {
let compactLayout: Bool
var bottomPadding: CGFloat? = nil
let content: () -> Content
init(
compactLayout: Bool,
bottomPadding: CGFloat? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.bottomPadding = bottomPadding
self.content = content
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
content()
}
.frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
.padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
.padding(.top, AppLayout.verticalPadding(for: compactLayout))
.padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
}
.scrollIndicators(.hidden)
}
}
struct AppPanel<Content: View>: View {
let compactLayout: Bool
let radius: CGFloat
let content: () -> Content
init(
compactLayout: Bool,
radius: CGFloat = AppLayout.cardRadius,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.radius = radius
self.content = content
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
content()
}
.padding(AppLayout.sectionPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: .leading)
.appSurface(radius: radius)
}
}
struct AppBadge: View {
let title: String
var tone: Color = AppTheme.accent
var body: some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(tone)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(tone.opacity(0.10), in: Capsule())
}
}
struct AppSectionCard<Content: View>: View {
let title: String
var subtitle: String? = nil
let compactLayout: Bool
let content: () -> Content
init(
title: String,
subtitle: String? = nil,
compactLayout: Bool,
@ViewBuilder content: @escaping () -> Content
) {
self.title = title
self.subtitle = subtitle
self.compactLayout = compactLayout
self.content = content
}
var body: some View {
AppPanel(compactLayout: compactLayout) {
AppSectionTitle(title: title, subtitle: subtitle)
content()
}
}
}
struct AppSectionTitle: View {
let title: String
var subtitle: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.title3.weight(.semibold))
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
struct AppNotice: View {
let message: String
var tone: Color = AppTheme.accent
var body: some View {
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.font(.footnote.weight(.bold))
.foregroundStyle(tone)
Text(message)
.font(.subheadline.weight(.semibold))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(tone.opacity(0.08), in: Capsule())
.overlay(
Capsule()
.stroke(AppTheme.border, lineWidth: 1)
)
}
}
struct AppStatusTag: View {
let title: String
var tone: Color = AppTheme.accent
var body: some View {
Text(title)
.font(.caption.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
.fixedSize(horizontal: true, vertical: false)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(tone.opacity(0.12), in: Capsule())
.foregroundStyle(tone)
}
}
struct AppKeyValue: View {
let label: String
let value: String
var monospaced: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label.uppercased())
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
Text(value)
.font(monospaced ? .subheadline.monospaced() : .subheadline.weight(.semibold))
.lineLimit(2)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct AppMetric: View {
let title: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title.uppercased())
.font(.caption.weight(.bold))
.foregroundStyle(.secondary)
Text(value)
.font(.title3.weight(.bold))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct AppTextSurface: View {
let text: String
var monospaced: Bool = false
var body: some View {
content
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
}
@ViewBuilder
private var content: some View {
#if os(watchOS)
Text(text)
.font(monospaced ? .body.monospaced() : .body)
#else
Text(text)
.font(monospaced ? .body.monospaced() : .body)
.textSelection(.enabled)
#endif
}
}
struct AppTextEditorField: View {
@Binding var text: String
var minHeight: CGFloat = 120
var monospaced: Bool = true
var body: some View {
editor
.frame(minHeight: minHeight)
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
}
@ViewBuilder
private var editor: some View {
#if os(watchOS)
Text(text)
.font(monospaced ? .body.monospaced() : .body)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
#else
TextEditor(text: $text)
.font(monospaced ? .body.monospaced() : .body)
.scrollContentBackground(.hidden)
.autocorrectionDisabled()
.padding(14)
#endif
}
}
struct AppActionRow: View {
let title: String
var subtitle: String? = nil
let systemImage: String
var tone: Color = AppTheme.accent
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
.font(.subheadline.weight(.semibold))
.foregroundStyle(tone)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
}
Spacer(minLength: 0)
Image(systemName: "arrow.right")
.font(.footnote.weight(.bold))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct AppActionTile: View {
let title: String
let systemImage: String
var tone: Color = AppTheme.accent
var isBusy: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .center) {
ZStack {
Circle()
.fill(tone.opacity(0.10))
.frame(width: 38, height: 38)
if isBusy {
ProgressView()
.tint(tone)
} else {
Image(systemName: systemImage)
.font(.headline.weight(.semibold))
.foregroundStyle(tone)
}
}
Spacer(minLength: 0)
Image(systemName: "arrow.up.right")
.font(.caption.weight(.bold))
.foregroundStyle(.secondary)
}
Text(title)
.font(.headline)
.multilineTextAlignment(.leading)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
.frame(maxWidth: .infinity, minHeight: 92, alignment: .topLeading)
.appSurface(radius: 22)
}
}

View File

@@ -3,8 +3,8 @@ import Foundation
@MainActor
final class AppViewModel: ObservableObject {
@Published var suggestedQRCodePayload = ""
@Published var manualQRCodePayload = ""
@Published var suggestedPairingPayload = ""
@Published var manualPairingPayload = ""
@Published var session: AuthSession?
@Published var profile: MemberProfile?
@Published var requests: [ApprovalRequest] = []
@@ -13,11 +13,11 @@ final class AppViewModel: ObservableObject {
@Published var selectedSection: AppSection = .overview
@Published var isBootstrapping = false
@Published var isAuthenticating = false
@Published var isIdentifying = false
@Published var isRefreshing = false
@Published var isNotificationCenterPresented = false
@Published var activeRequestID: ApprovalRequest.ID?
@Published var isScannerPresented = false
@Published var bannerMessage: String?
@Published var errorMessage: String?
private var hasBootstrapped = false
@@ -84,13 +84,13 @@ final class AppViewModel: ObservableObject {
do {
let bootstrap = try await service.bootstrap()
suggestedQRCodePayload = bootstrap.suggestedQRCodePayload
manualQRCodePayload = bootstrap.suggestedQRCodePayload
suggestedPairingPayload = bootstrap.suggestedPairingPayload
manualPairingPayload = bootstrap.suggestedPairingPayload
notificationPermission = await notificationCoordinator.authorizationStatus()
if launchArguments.contains("--mock-auto-pair"),
session == nil {
await signIn(with: bootstrap.suggestedQRCodePayload)
await signIn(with: bootstrap.suggestedPairingPayload, transport: .preview)
if let preferredLaunchSection {
selectedSection = preferredLaunchSection
@@ -101,32 +101,52 @@ final class AppViewModel: ObservableObject {
}
}
func signInWithManualCode() async {
await signIn(with: manualQRCodePayload)
func signInWithManualPayload() async {
await signIn(with: manualPairingPayload, transport: .manual)
}
func signInWithSuggestedCode() async {
manualQRCodePayload = suggestedQRCodePayload
await signIn(with: suggestedQRCodePayload)
func signInWithSuggestedPayload() async {
manualPairingPayload = suggestedPairingPayload
await signIn(with: suggestedPairingPayload, transport: .preview)
}
func signIn(with payload: String) async {
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
func signIn(
with payload: String,
transport: PairingTransport = .manual,
signedGPSPosition: SignedGPSPosition? = nil
) async {
await signIn(
with: PairingAuthenticationRequest(
pairingPayload: payload,
transport: transport,
signedGPSPosition: signedGPSPosition
)
)
}
func signIn(with request: PairingAuthenticationRequest) async {
let trimmed = request.pairingPayload.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
errorMessage = "Paste or scan a QR payload first."
errorMessage = "Paste or scan a pairing payload first."
return
}
let normalizedRequest = PairingAuthenticationRequest(
pairingPayload: trimmed,
transport: request.transport,
signedGPSPosition: request.signedGPSPosition
)
isAuthenticating = true
defer { isAuthenticating = false }
do {
let result = try await service.signIn(withQRCode: trimmed)
let result = try await service.signIn(with: normalizedRequest)
session = result.session
apply(snapshot: result.snapshot)
notificationPermission = await notificationCoordinator.authorizationStatus()
selectedSection = .overview
bannerMessage = "Paired with \(result.session.deviceName)."
errorMessage = nil
isScannerPresented = false
} catch let error as AppError {
errorMessage = error.errorDescription
@@ -135,6 +155,60 @@ final class AppViewModel: ObservableObject {
}
}
func identifyWithNFC(_ request: PairingAuthenticationRequest) async {
guard session != nil else {
errorMessage = "Set up this passport before proving your identity with NFC."
return
}
await submitIdentityProof(
payload: request.pairingPayload,
transport: .nfc,
signedGPSPosition: request.signedGPSPosition
)
}
func identifyWithPayload(_ payload: String, transport: PairingTransport = .qr) async {
guard session != nil else {
errorMessage = "Set up this passport before proving your identity."
return
}
await submitIdentityProof(payload: payload, transport: transport)
}
private func submitIdentityProof(
payload: String,
transport: PairingTransport,
signedGPSPosition: SignedGPSPosition? = nil
) async {
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
errorMessage = "The provided idp.global payload was empty."
return
}
let normalizedRequest = PairingAuthenticationRequest(
pairingPayload: trimmed,
transport: transport,
signedGPSPosition: signedGPSPosition
)
isIdentifying = true
defer { isIdentifying = false }
do {
let snapshot = try await service.identify(with: normalizedRequest)
apply(snapshot: snapshot)
errorMessage = nil
isScannerPresented = false
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Unable to complete identity proof."
}
}
func refreshDashboard() async {
guard session != nil else { return }
@@ -144,6 +218,7 @@ final class AppViewModel: ObservableObject {
do {
let snapshot = try await service.refreshDashboard()
apply(snapshot: snapshot)
errorMessage = nil
} catch {
errorMessage = "Unable to refresh the dashboard."
}
@@ -164,18 +239,16 @@ final class AppViewModel: ObservableObject {
let snapshot = try await service.simulateIncomingRequest()
apply(snapshot: snapshot)
selectedSection = .requests
bannerMessage = "A new mock approval request arrived."
errorMessage = nil
} catch {
errorMessage = "Unable to seed a new request right now."
errorMessage = "Unable to create a mock identity check right now."
}
}
func requestNotificationAccess() async {
do {
notificationPermission = try await notificationCoordinator.requestAuthorization()
if notificationPermission == .allowed || notificationPermission == .provisional {
bannerMessage = "Notifications are ready on this device."
}
errorMessage = nil
} catch {
errorMessage = "Unable to update notification permission."
}
@@ -184,11 +257,11 @@ final class AppViewModel: ObservableObject {
func sendTestNotification() async {
do {
try await notificationCoordinator.scheduleTestNotification(
title: "idp.global approval pending",
body: "A mock request is waiting for approval in the app."
title: "idp.global identity proof requested",
body: "A mock identity proof request is waiting in the app."
)
bannerMessage = "A local test notification will appear in a few seconds."
notificationPermission = await notificationCoordinator.authorizationStatus()
errorMessage = nil
} catch {
errorMessage = "Unable to schedule a test notification."
}
@@ -198,6 +271,7 @@ final class AppViewModel: ObservableObject {
do {
let snapshot = try await service.markNotificationRead(id: notification.id)
apply(snapshot: snapshot)
errorMessage = nil
} catch {
errorMessage = "Unable to update the notification."
}
@@ -209,8 +283,8 @@ final class AppViewModel: ObservableObject {
requests = []
notifications = []
selectedSection = .overview
bannerMessage = nil
manualQRCodePayload = suggestedQRCodePayload
manualPairingPayload = suggestedPairingPayload
errorMessage = nil
}
private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
@@ -224,9 +298,9 @@ final class AppViewModel: ObservableObject {
? try await service.approveRequest(id: request.id)
: try await service.rejectRequest(id: request.id)
apply(snapshot: snapshot)
bannerMessage = approve ? "Request approved for \(request.source)." : "Request rejected for \(request.source)."
errorMessage = nil
} catch {
errorMessage = "Unable to update the request."
errorMessage = "Unable to update the identity check."
}
}

View File

@@ -7,7 +7,7 @@ struct IDPGlobalApp: App {
var body: some Scene {
WindowGroup {
RootView(model: model)
.tint(Color(red: 0.12, green: 0.40, blue: 0.31))
.tint(AppTheme.accent)
.task {
await model.bootstrap()
}
@@ -47,17 +47,8 @@ private struct RootView: View {
HomeRootView(model: model)
}
}
.background(
LinearGradient(
colors: [
Color(red: 0.96, green: 0.97, blue: 0.94),
Color(red: 0.89, green: 0.94, blue: 0.92),
Color(red: 0.94, green: 0.91, blue: 0.84)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.background {
AppBackground()
}
}
}