Overhaul native approval UX and add widget surfaces
CI / test (push) Has been cancelled

Bring the SwiftUI app in line with the Apple-native mock and keep pending approvals actionable from Live Activities and watch complications.
This commit is contained in:
2026-04-19 16:29:13 +02:00
parent a6939453f8
commit 61a0cc1f7d
63 changed files with 3496 additions and 1769 deletions
+80 -11
View File
@@ -1,5 +1,10 @@
import Combine
import Foundation
import SwiftUI
#if canImport(WidgetKit)
import WidgetKit
#endif
@MainActor
final class AppViewModel: ObservableObject {
@@ -10,12 +15,13 @@ final class AppViewModel: ObservableObject {
@Published var requests: [ApprovalRequest] = []
@Published var notifications: [AppNotification] = []
@Published var notificationPermission: NotificationPermissionState = .unknown
@Published var selectedSection: AppSection = .overview
@Published var selectedSection: AppSection = .inbox
@Published var isBootstrapping = false
@Published var isAuthenticating = false
@Published var isIdentifying = false
@Published var isRefreshing = false
@Published var isNotificationCenterPresented = false
@Published var isShowingPairingSuccess = false
@Published var activeRequestID: ApprovalRequest.ID?
@Published var isScannerPresented = false
@Published var errorMessage: String?
@@ -32,14 +38,25 @@ final class AppViewModel: ObservableObject {
}
let rawValue = String(argument.dropFirst("--mock-section=".count))
if rawValue == "notifications" {
return .activity
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)
}
return AppSection(rawValue: rawValue)
}
init(
service: IDPServicing = MockIDPService(),
service: IDPServicing = MockIDPService.shared,
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
launchArguments: [String] = ProcessInfo.processInfo.arguments
@@ -148,15 +165,28 @@ final class AppViewModel: ObservableObject {
isAuthenticating = true
defer { isAuthenticating = false }
let wasSignedOut = session == nil
do {
let result = try await service.signIn(with: normalizedRequest)
session = result.session
apply(snapshot: result.snapshot)
persistCurrentState()
notificationPermission = await notificationCoordinator.authorizationStatus()
selectedSection = .overview
selectedSection = .inbox
errorMessage = nil
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
}
}
} catch let error as AppError {
errorMessage = error.errorDescription
} catch {
@@ -250,7 +280,7 @@ final class AppViewModel: ObservableObject {
let snapshot = try await service.simulateIncomingRequest()
apply(snapshot: snapshot)
persistCurrentState()
selectedSection = .requests
selectedSection = .inbox
errorMessage = nil
} catch {
errorMessage = "Unable to create a mock identity check right now."
@@ -296,9 +326,36 @@ final class AppViewModel: ObservableObject {
profile = nil
requests = []
notifications = []
selectedSection = .overview
selectedSection = .inbox
manualPairingPayload = suggestedPairingPayload
isShowingPairingSuccess = false
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
}
}
private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
@@ -352,8 +409,20 @@ final class AppViewModel: ObservableObject {
}
private func apply(snapshot: DashboardSnapshot) {
profile = snapshot.profile
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
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 }
}
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
}
}
}