import Combine import Foundation @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 notificationPermission: NotificationPermissionState = .unknown @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 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)) if rawValue == "notifications" { return .activity } return AppSection(rawValue: rawValue) } init( service: IDPServicing = MockIDPService(), 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 } } } 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 } do { let result = try await service.signIn(with: normalizedRequest) session = result.session apply(snapshot: result.snapshot) persistCurrentState() notificationPermission = await notificationCoordinator.authorizationStatus() selectedSection = .overview errorMessage = nil isScannerPresented = 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 { 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 = .requests errorMessage = nil } 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 { errorMessage = "Unable to update the notification." } } func signOut() { appStateStore.clear() session = nil profile = nil requests = [] notifications = [] selectedSection = .overview manualPairingPayload = suggestedPairingPayload errorMessage = nil } 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 { 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 ) ) } private func persistCurrentState() { guard let session, let profile else { appStateStore.clear() return } appStateStore.save( PersistedAppState( session: session, profile: profile, requests: requests, notifications: notifications ) ) } private func apply(snapshot: DashboardSnapshot) { profile = snapshot.profile requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt } notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt } } }