Build passport-style identity app shell
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user