2026-04-17 22:08:27 +02:00
|
|
|
import Combine
|
|
|
|
|
import Foundation
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
final class AppViewModel: ObservableObject {
|
2026-04-18 01:05:22 +02:00
|
|
|
@Published var suggestedPairingPayload = ""
|
|
|
|
|
@Published var manualPairingPayload = ""
|
2026-04-17 22:08:27 +02:00
|
|
|
@Published var session: AuthSession?
|
|
|
|
|
@Published var profile: MemberProfile?
|
|
|
|
|
@Published var requests: [ApprovalRequest] = []
|
|
|
|
|
@Published var notifications: [AppNotification] = []
|
|
|
|
|
@Published var notificationPermission: NotificationPermissionState = .unknown
|
|
|
|
|
@Published var selectedSection: AppSection = .overview
|
|
|
|
|
@Published var isBootstrapping = false
|
|
|
|
|
@Published var isAuthenticating = false
|
2026-04-18 01:05:22 +02:00
|
|
|
@Published var isIdentifying = false
|
2026-04-17 22:08:27 +02:00
|
|
|
@Published var isRefreshing = false
|
|
|
|
|
@Published var isNotificationCenterPresented = 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
|
2026-04-18 12:29:32 +02:00
|
|
|
private let appStateStore: AppStateStoring
|
2026-04-17 22:08:27 +02:00
|
|
|
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))
|
|
|
|
|
if rawValue == "notifications" {
|
|
|
|
|
return .activity
|
|
|
|
|
}
|
|
|
|
|
return AppSection(rawValue: rawValue)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
init(
|
|
|
|
|
service: IDPServicing = MockIDPService(),
|
|
|
|
|
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
|
2026-04-18 12:29:32 +02:00
|
|
|
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
|
2026-04-17 22:08:27 +02:00
|
|
|
launchArguments: [String] = ProcessInfo.processInfo.arguments
|
|
|
|
|
) {
|
|
|
|
|
self.service = service
|
|
|
|
|
self.notificationCoordinator = notificationCoordinator
|
2026-04-18 12:29:32 +02:00
|
|
|
self.appStateStore = appStateStore
|
2026-04-17 22:08:27 +02:00
|
|
|
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
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
restorePersistedState()
|
|
|
|
|
|
2026-04-17 22:08:27 +02:00
|
|
|
isBootstrapping = true
|
|
|
|
|
defer { isBootstrapping = false }
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
notificationPermission = await notificationCoordinator.authorizationStatus()
|
|
|
|
|
|
2026-04-17 22:08:27 +02:00
|
|
|
do {
|
|
|
|
|
let bootstrap = try await service.bootstrap()
|
2026-04-18 01:05:22 +02:00
|
|
|
suggestedPairingPayload = bootstrap.suggestedPairingPayload
|
2026-04-18 12:29:32 +02:00
|
|
|
manualPairingPayload = session?.pairingCode ?? bootstrap.suggestedPairingPayload
|
2026-04-17 22:08:27 +02:00
|
|
|
|
|
|
|
|
if launchArguments.contains("--mock-auto-pair"),
|
|
|
|
|
session == nil {
|
2026-04-18 01:05:22 +02:00
|
|
|
await signIn(with: bootstrap.suggestedPairingPayload, transport: .preview)
|
2026-04-17 22:08:27 +02:00
|
|
|
|
|
|
|
|
if let preferredLaunchSection {
|
|
|
|
|
selectedSection = preferredLaunchSection
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
2026-04-18 12:29:32 +02:00
|
|
|
if session == nil {
|
|
|
|
|
errorMessage = "Unable to prepare the app."
|
|
|
|
|
}
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 01:05:22 +02:00
|
|
|
func signInWithManualPayload() async {
|
|
|
|
|
await signIn(with: manualPairingPayload, transport: .manual)
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-18 01:05:22 +02:00
|
|
|
func signInWithSuggestedPayload() async {
|
|
|
|
|
manualPairingPayload = suggestedPairingPayload
|
|
|
|
|
await signIn(with: suggestedPairingPayload, transport: .preview)
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-18 01:05:22 +02:00
|
|
|
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)
|
2026-04-17 22:08:27 +02:00
|
|
|
guard !trimmed.isEmpty else {
|
2026-04-18 01:05:22 +02:00
|
|
|
errorMessage = "Paste or scan a pairing payload first."
|
2026-04-17 22:08:27 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 01:05:22 +02:00
|
|
|
let normalizedRequest = PairingAuthenticationRequest(
|
|
|
|
|
pairingPayload: trimmed,
|
|
|
|
|
transport: request.transport,
|
|
|
|
|
signedGPSPosition: request.signedGPSPosition
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-17 22:08:27 +02:00
|
|
|
isAuthenticating = true
|
|
|
|
|
defer { isAuthenticating = false }
|
|
|
|
|
|
|
|
|
|
do {
|
2026-04-18 01:05:22 +02:00
|
|
|
let result = try await service.signIn(with: normalizedRequest)
|
2026-04-17 22:08:27 +02:00
|
|
|
session = result.session
|
|
|
|
|
apply(snapshot: result.snapshot)
|
2026-04-18 12:29:32 +02:00
|
|
|
persistCurrentState()
|
2026-04-17 22:08:27 +02:00
|
|
|
notificationPermission = await notificationCoordinator.authorizationStatus()
|
|
|
|
|
selectedSection = .overview
|
2026-04-18 01:05:22 +02:00
|
|
|
errorMessage = nil
|
2026-04-17 22:08:27 +02:00
|
|
|
isScannerPresented = false
|
|
|
|
|
} catch let error as AppError {
|
|
|
|
|
errorMessage = error.errorDescription
|
|
|
|
|
} catch {
|
|
|
|
|
errorMessage = "Unable to complete sign-in."
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 01:05:22 +02:00
|
|
|
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)
|
2026-04-18 12:29:32 +02:00
|
|
|
persistCurrentState()
|
2026-04-18 01:05:22 +02:00
|
|
|
errorMessage = nil
|
|
|
|
|
isScannerPresented = false
|
|
|
|
|
} catch let error as AppError {
|
|
|
|
|
errorMessage = error.errorDescription
|
|
|
|
|
} catch {
|
|
|
|
|
errorMessage = "Unable to complete identity proof."
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 22:08:27 +02:00
|
|
|
func refreshDashboard() async {
|
|
|
|
|
guard session != nil else { return }
|
|
|
|
|
|
|
|
|
|
isRefreshing = true
|
|
|
|
|
defer { isRefreshing = false }
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let snapshot = try await service.refreshDashboard()
|
|
|
|
|
apply(snapshot: snapshot)
|
2026-04-18 12:29:32 +02:00
|
|
|
persistCurrentState()
|
2026-04-18 01:05:22 +02:00
|
|
|
errorMessage = nil
|
2026-04-17 22:08:27 +02:00
|
|
|
} 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)
|
2026-04-18 12:29:32 +02:00
|
|
|
persistCurrentState()
|
2026-04-17 22:08:27 +02:00
|
|
|
selectedSection = .requests
|
2026-04-18 01:05:22 +02:00
|
|
|
errorMessage = nil
|
2026-04-17 22:08:27 +02:00
|
|
|
} catch {
|
2026-04-18 01:05:22 +02:00
|
|
|
errorMessage = "Unable to create a mock identity check right now."
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func requestNotificationAccess() async {
|
|
|
|
|
do {
|
|
|
|
|
notificationPermission = try await notificationCoordinator.requestAuthorization()
|
2026-04-18 01:05:22 +02:00
|
|
|
errorMessage = nil
|
2026-04-17 22:08:27 +02:00
|
|
|
} catch {
|
|
|
|
|
errorMessage = "Unable to update notification permission."
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sendTestNotification() async {
|
|
|
|
|
do {
|
|
|
|
|
try await notificationCoordinator.scheduleTestNotification(
|
2026-04-18 01:05:22 +02:00
|
|
|
title: "idp.global identity proof requested",
|
|
|
|
|
body: "A mock identity proof request is waiting in the app."
|
2026-04-17 22:08:27 +02:00
|
|
|
)
|
|
|
|
|
notificationPermission = await notificationCoordinator.authorizationStatus()
|
2026-04-18 01:05:22 +02:00
|
|
|
errorMessage = nil
|
2026-04-17 22:08:27 +02:00
|
|
|
} 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)
|
2026-04-18 12:29:32 +02:00
|
|
|
persistCurrentState()
|
2026-04-18 01:05:22 +02:00
|
|
|
errorMessage = nil
|
2026-04-17 22:08:27 +02:00
|
|
|
} catch {
|
|
|
|
|
errorMessage = "Unable to update the notification."
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func signOut() {
|
2026-04-18 12:29:32 +02:00
|
|
|
appStateStore.clear()
|
2026-04-17 22:08:27 +02:00
|
|
|
session = nil
|
|
|
|
|
profile = nil
|
|
|
|
|
requests = []
|
|
|
|
|
notifications = []
|
|
|
|
|
selectedSection = .overview
|
2026-04-18 01:05:22 +02:00
|
|
|
manualPairingPayload = suggestedPairingPayload
|
|
|
|
|
errorMessage = nil
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-04-18 12:29:32 +02:00
|
|
|
persistCurrentState()
|
2026-04-18 01:05:22 +02:00
|
|
|
errorMessage = nil
|
2026-04-17 22:08:27 +02:00
|
|
|
} catch {
|
2026-04-18 01:05:22 +02:00
|
|
|
errorMessage = "Unable to update the identity check."
|
2026-04-17 22:08:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 12:29:32 +02:00
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func persistCurrentState() {
|
|
|
|
|
guard let session, let profile else {
|
|
|
|
|
appStateStore.clear()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
appStateStore.save(
|
|
|
|
|
PersistedAppState(
|
|
|
|
|
session: session,
|
|
|
|
|
profile: profile,
|
|
|
|
|
requests: requests,
|
|
|
|
|
notifications: notifications
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 22:08:27 +02:00
|
|
|
private func apply(snapshot: DashboardSnapshot) {
|
|
|
|
|
profile = snapshot.profile
|
|
|
|
|
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
|
|
|
|
|
notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
|
|
|
|
|
}
|
|
|
|
|
}
|