import Combine import Foundation @MainActor final class AppViewModel: ObservableObject { @Published var suggestedQRCodePayload = "" @Published var manualQRCodePayload = "" @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 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 private let service: IDPServicing private let notificationCoordinator: NotificationCoordinating 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(), launchArguments: [String] = ProcessInfo.processInfo.arguments ) { self.service = service self.notificationCoordinator = notificationCoordinator 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 isBootstrapping = true defer { isBootstrapping = false } do { let bootstrap = try await service.bootstrap() suggestedQRCodePayload = bootstrap.suggestedQRCodePayload manualQRCodePayload = bootstrap.suggestedQRCodePayload notificationPermission = await notificationCoordinator.authorizationStatus() if launchArguments.contains("--mock-auto-pair"), session == nil { await signIn(with: bootstrap.suggestedQRCodePayload) if let preferredLaunchSection { selectedSection = preferredLaunchSection } } } catch { errorMessage = "Unable to prepare the app." } } func signInWithManualCode() async { await signIn(with: manualQRCodePayload) } func signInWithSuggestedCode() async { manualQRCodePayload = suggestedQRCodePayload await signIn(with: suggestedQRCodePayload) } func signIn(with payload: String) async { let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { errorMessage = "Paste or scan a QR payload first." return } isAuthenticating = true defer { isAuthenticating = false } do { let result = try await service.signIn(withQRCode: trimmed) session = result.session apply(snapshot: result.snapshot) notificationPermission = await notificationCoordinator.authorizationStatus() selectedSection = .overview bannerMessage = "Paired with \(result.session.deviceName)." isScannerPresented = false } catch let error as AppError { errorMessage = error.errorDescription } catch { errorMessage = "Unable to complete sign-in." } } func refreshDashboard() async { guard session != nil else { return } isRefreshing = true defer { isRefreshing = false } do { let snapshot = try await service.refreshDashboard() apply(snapshot: snapshot) } 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) selectedSection = .requests bannerMessage = "A new mock approval request arrived." } catch { errorMessage = "Unable to seed a new request right now." } } func requestNotificationAccess() async { do { notificationPermission = try await notificationCoordinator.requestAuthorization() if notificationPermission == .allowed || notificationPermission == .provisional { bannerMessage = "Notifications are ready on this device." } } catch { errorMessage = "Unable to update notification permission." } } func sendTestNotification() async { do { try await notificationCoordinator.scheduleTestNotification( title: "idp.global approval pending", body: "A mock request is waiting for approval in the app." ) bannerMessage = "A local test notification will appear in a few seconds." notificationPermission = await notificationCoordinator.authorizationStatus() } 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) } catch { errorMessage = "Unable to update the notification." } } func signOut() { session = nil profile = nil requests = [] notifications = [] selectedSection = .overview bannerMessage = nil manualQRCodePayload = suggestedQRCodePayload } 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) bannerMessage = approve ? "Request approved for \(request.source)." : "Request rejected for \(request.source)." } catch { errorMessage = "Unable to update the request." } } private func apply(snapshot: DashboardSnapshot) { profile = snapshot.profile requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt } notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt } } }