Files
swiftapp/swift/Sources/App/AppViewModel.swift
T
jkunz a298b5e421
CI / test (push) Has been cancelled
replace mock passport flows with live server integration
Switch the app to the real passport enrollment, dashboard, device, alert, and challenge APIs so it can pair with idp.global and act on server-backed state instead of demo data.
2026-04-20 13:21:39 +00:00

446 lines
14 KiB
Swift

import Combine
import Foundation
import SwiftUI
#if canImport(WidgetKit)
import WidgetKit
#endif
@MainActor
final class AppViewModel: ObservableObject {
@Published var suggestedPairingPayload = ""
@Published var manualPairingPayload = ""
@Published var session: AuthSession?
@Published var profile: MemberProfile?
@Published var requests: [ApprovalRequest] = []
@Published var notifications: [AppNotification] = []
@Published var devices: [PassportDeviceRecord] = []
@Published var notificationPermission: NotificationPermissionState = .unknown
@Published var selectedSection: AppSection = .inbox
@Published var isBootstrapping = false
@Published var isAuthenticating = false
@Published var isIdentifying = false
@Published var isRefreshing = false
@Published var isNotificationCenterPresented = false
@Published var isShowingPairingSuccess = false
@Published var activeRequestID: ApprovalRequest.ID?
@Published var isScannerPresented = false
@Published var errorMessage: String?
private var hasBootstrapped = false
private let service: IDPServicing
private let notificationCoordinator: NotificationCoordinating
private let appStateStore: AppStateStoring
private let launchArguments: [String]
private var preferredLaunchSection: AppSection? {
guard let argument = launchArguments.first(where: { $0.hasPrefix("--mock-section=") }) else {
return nil
}
let rawValue = String(argument.dropFirst("--mock-section=".count))
switch rawValue {
case "requests", "inbox":
return .inbox
case "notifications", "activity":
return .notifications
case "devices", "account":
return .devices
case "identity", "overview":
return .identity
case "settings":
return .settings
default:
return AppSection(rawValue: rawValue)
}
}
init(
service: IDPServicing = DefaultIDPService.shared,
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
launchArguments: [String] = ProcessInfo.processInfo.arguments
) {
self.service = service
self.notificationCoordinator = notificationCoordinator
self.appStateStore = appStateStore
self.launchArguments = launchArguments
}
var pendingRequests: [ApprovalRequest] {
requests
.filter { $0.status == .pending }
.sorted { $0.createdAt > $1.createdAt }
}
var handledRequests: [ApprovalRequest] {
requests
.filter { $0.status != .pending }
.sorted { $0.createdAt > $1.createdAt }
}
var unreadNotificationCount: Int {
notifications.filter(\.isUnread).count
}
var elevatedPendingCount: Int {
pendingRequests.filter { $0.risk == .elevated }.count
}
var latestNotification: AppNotification? {
notifications.first
}
var pairedDeviceSummary: String {
session?.deviceName ?? "No active device"
}
func bootstrap() async {
guard !hasBootstrapped else { return }
hasBootstrapped = true
restorePersistedState()
isBootstrapping = true
defer { isBootstrapping = false }
notificationPermission = await notificationCoordinator.authorizationStatus()
do {
let bootstrap = try await service.bootstrap()
suggestedPairingPayload = bootstrap.suggestedPairingPayload
manualPairingPayload = session?.pairingCode ?? bootstrap.suggestedPairingPayload
if launchArguments.contains("--mock-auto-pair"),
session == nil {
await signIn(with: bootstrap.suggestedPairingPayload, transport: .preview)
if let preferredLaunchSection {
selectedSection = preferredLaunchSection
}
} else if session != nil {
await refreshDashboard()
}
} catch {
if session == nil {
errorMessage = "Unable to prepare the app."
}
}
}
func signInWithManualPayload() async {
await signIn(with: manualPairingPayload, transport: .manual)
}
func signInWithSuggestedPayload() async {
manualPairingPayload = suggestedPairingPayload
await signIn(with: suggestedPairingPayload, transport: .preview)
}
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 pairing payload first."
return
}
let normalizedRequest = PairingAuthenticationRequest(
pairingPayload: trimmed,
transport: request.transport,
signedGPSPosition: request.signedGPSPosition
)
isAuthenticating = true
defer { isAuthenticating = false }
let wasSignedOut = session == nil
do {
let result = try await service.signIn(with: normalizedRequest)
session = result.session
apply(snapshot: result.snapshot)
persistCurrentState()
notificationPermission = await notificationCoordinator.authorizationStatus()
selectedSection = .inbox
errorMessage = nil
isScannerPresented = false
if wasSignedOut {
isShowingPairingSuccess = true
Haptics.success()
Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(1200))
guard let self, self.session != nil else { return }
self.isShowingPairingSuccess = false
}
}
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Unable to complete sign-in."
}
}
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)
persistCurrentState()
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 }
isRefreshing = true
defer { isRefreshing = false }
do {
let snapshot = try await service.refreshDashboard()
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Unable to refresh the dashboard."
}
}
func approve(_ request: ApprovalRequest) async {
await mutateRequest(request, approve: true)
}
func reject(_ request: ApprovalRequest) async {
await mutateRequest(request, approve: false)
}
func simulateIncomingRequest() async {
guard session != nil else { return }
do {
let snapshot = try await service.simulateIncomingRequest()
apply(snapshot: snapshot)
persistCurrentState()
selectedSection = .inbox
errorMessage = nil
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Unable to create a mock identity check right now."
}
}
func requestNotificationAccess() async {
do {
notificationPermission = try await notificationCoordinator.requestAuthorization()
errorMessage = nil
} catch {
errorMessage = "Unable to update notification permission."
}
}
func sendTestNotification() async {
do {
try await notificationCoordinator.scheduleTestNotification(
title: "idp.global identity proof requested",
body: "A mock identity proof request is waiting in the app."
)
notificationPermission = await notificationCoordinator.authorizationStatus()
errorMessage = nil
} catch {
errorMessage = "Unable to schedule a test notification."
}
}
func markNotificationRead(_ notification: AppNotification) async {
do {
let snapshot = try await service.markNotificationRead(id: notification.id)
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Unable to update the notification."
}
}
func signOut() {
appStateStore.clear()
session = nil
profile = nil
requests = []
notifications = []
devices = []
selectedSection = .inbox
manualPairingPayload = suggestedPairingPayload
isShowingPairingSuccess = false
errorMessage = nil
Task {
await ApprovalActivityController.endAll()
#if canImport(WidgetKit)
WidgetCenter.shared.reloadAllTimelines()
#endif
}
}
func openDeepLink(_ url: URL) {
let destination = (url.host ?? url.lastPathComponent).lowercased()
switch destination {
case "inbox":
selectedSection = .inbox
case "notifications":
selectedSection = .notifications
case "devices":
selectedSection = .devices
case "identity":
selectedSection = .identity
case "settings":
selectedSection = .settings
default:
break
}
}
private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
guard session != nil else { return }
activeRequestID = request.id
defer { activeRequestID = nil }
do {
let snapshot = approve
? try await service.approveRequest(id: request.id)
: try await service.rejectRequest(id: request.id)
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
errorMessage = "Unable to update the identity check."
}
}
private func restorePersistedState() {
guard let state = appStateStore.load() else {
return
}
session = state.session
manualPairingPayload = state.session.pairingCode
apply(
snapshot: DashboardSnapshot(
profile: state.profile,
requests: state.requests,
notifications: state.notifications,
devices: state.devices
)
)
}
private func persistCurrentState() {
guard let session, let profile else {
appStateStore.clear()
return
}
appStateStore.save(
PersistedAppState(
session: session,
profile: profile,
requests: requests,
notifications: notifications,
devices: devices
)
)
}
private func apply(snapshot: DashboardSnapshot) {
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
self.profile = snapshot.profile
self.requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
self.notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
self.devices = snapshot.devices.sorted {
($0.lastSeenAt ?? .distantPast) > ($1.lastSeenAt ?? .distantPast)
}
}
let profileValue = snapshot.profile
let requestsValue = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
Task {
await ApprovalActivityController.sync(requests: requestsValue, profile: profileValue)
#if canImport(WidgetKit)
WidgetCenter.shared.reloadAllTimelines()
#endif
}
}
}