Files
swiftapp/swift/Sources/App/AppViewModel.swift
T

448 lines
14 KiB
Swift
Raw Normal View History

2026-04-17 22:08:27 +02:00
import Combine
import Foundation
import SwiftUI
#if canImport(WidgetKit)
import WidgetKit
#endif
2026-04-17 22:08:27 +02:00
@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 devices: [PassportDeviceRecord] = []
2026-04-17 22:08:27 +02:00
@Published var notificationPermission: NotificationPermissionState = .unknown
@Published var selectedSection: AppSection = .inbox
2026-04-17 22:08:27 +02:00
@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 isShowingPairingSuccess = false
2026-04-17 22:08:27 +02:00
@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
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))
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)
2026-04-17 22:08:27 +02:00
}
}
init(
service: IDPServicing = DefaultIDPService.shared,
2026-04-17 22:08:27 +02:00
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
2026-04-17 22:08:27 +02:00
launchArguments: [String] = ProcessInfo.processInfo.arguments
) {
self.service = service
self.notificationCoordinator = notificationCoordinator
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
restorePersistedState()
2026-04-17 22:08:27 +02:00
isBootstrapping = true
defer { isBootstrapping = false }
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
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
}
} else if session != nil {
await refreshDashboard()
} else if launchArguments.contains("--show-pair-scanner") {
isScannerPresented = true
2026-04-17 22:08:27 +02:00
}
} catch {
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 }
let wasSignedOut = session == nil
2026-04-17 22:08:27 +02:00
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)
persistCurrentState()
2026-04-17 22:08:27 +02:00
notificationPermission = await notificationCoordinator.authorizationStatus()
selectedSection = .inbox
2026-04-18 01:05:22 +02:00
errorMessage = nil
2026-04-17 22:08:27 +02:00
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
}
}
2026-04-17 22:08:27 +02:00
} 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)
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)
persistCurrentState()
2026-04-18 01:05:22 +02:00
errorMessage = nil
} catch let error as AppError {
errorMessage = error.errorDescription
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)
persistCurrentState()
selectedSection = .inbox
2026-04-18 01:05:22 +02:00
errorMessage = nil
} catch let error as AppError {
errorMessage = error.errorDescription
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)
persistCurrentState()
2026-04-18 01:05:22 +02:00
errorMessage = nil
} catch let error as AppError {
errorMessage = error.errorDescription
2026-04-17 22:08:27 +02:00
} catch {
errorMessage = "Unable to update the notification."
}
}
func signOut() {
appStateStore.clear()
2026-04-17 22:08:27 +02:00
session = nil
profile = nil
requests = []
notifications = []
devices = []
selectedSection = .inbox
2026-04-18 01:05:22 +02:00
manualPairingPayload = suggestedPairingPayload
isShowingPairingSuccess = false
2026-04-18 01:05:22 +02:00
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
}
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)
persistCurrentState()
2026-04-18 01:05:22 +02:00
errorMessage = nil
} catch let error as AppError {
errorMessage = error.errorDescription
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
}
}
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
)
)
}
2026-04-17 22:08:27 +02:00
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
}
2026-04-17 22:08:27 +02:00
}
}