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
}
}
}
@@ -0,0 +1,63 @@
import Foundation
#if canImport(ActivityKit) && os(iOS)
import ActivityKit
enum ApprovalActivityController {
static func sync(requests: [ApprovalRequest], profile: MemberProfile) async {
let pendingRequest = requests.first(where: { $0.status == .pending })
guard let pendingRequest else {
await endAll()
return
}
let payload = pendingRequest.activityPayload(handle: profile.handle)
let contentState = ApprovalActivityAttributes.ContentState(
requestID: payload.requestID,
title: payload.title,
appName: payload.appName,
source: payload.source,
handle: payload.handle,
location: payload.location
)
let content = ActivityContent(state: contentState, staleDate: pendingRequest.activityExpiryDate)
if let currentActivity = Activity<ApprovalActivityAttributes>.activities.first(where: { $0.attributes.requestID == payload.requestID }) {
await currentActivity.update(content)
} else {
do {
_ = try Activity.request(
attributes: ApprovalActivityAttributes(requestID: payload.requestID, createdAt: payload.createdAt),
content: content
)
} catch {
}
}
for activity in Activity<ApprovalActivityAttributes>.activities where activity.attributes.requestID != payload.requestID {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
static func sync(requests: [ApprovalRequest], profile: MemberProfile?) async {
guard let profile else {
await endAll()
return
}
await sync(requests: requests, profile: profile)
}
static func endAll() async {
for activity in Activity<ApprovalActivityAttributes>.activities {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
}
#else
enum ApprovalActivityController {
static func sync(requests: [ApprovalRequest], profile: MemberProfile?) async {}
static func endAll() async {}
}
#endif
+51 -7
View File
@@ -5,9 +5,27 @@ struct IDPGlobalApp: App {
@StateObject private var model = AppViewModel()
var body: some Scene {
WindowGroup {
RootView(model: model)
.tint(AppTheme.accent)
#if os(macOS)
MenuBarExtra("idp.global", systemImage: "shield.lefthalf.filled") {
RootSceneContent(model: model)
.frame(minWidth: 400, minHeight: 560)
.tint(IdP.tint)
.task {
await model.bootstrap()
}
.alert("Something went wrong", isPresented: errorPresented) {
Button("OK") {
model.errorMessage = nil
}
} message: {
Text(model.errorMessage ?? "")
}
}
.menuBarExtraStyle(.window)
#else
WindowGroup {
RootSceneContent(model: model)
.tint(IdP.tint)
.task {
await model.bootstrap()
}
@@ -19,8 +37,6 @@ struct IDPGlobalApp: App {
Text(model.errorMessage ?? "")
}
}
#if os(macOS)
.defaultSize(width: 1380, height: 920)
#endif
}
@@ -36,19 +52,47 @@ struct IDPGlobalApp: App {
}
}
private struct RootView: View {
private struct RootSceneContent: View {
@ObservedObject var model: AppViewModel
var body: some View {
Group {
if model.session == nil {
LoginRootView(model: model)
} else if model.isShowingPairingSuccess {
PairingSuccessView()
} else {
#if os(macOS)
MenuBarPopover(model: model)
#else
HomeRootView(model: model)
#endif
}
}
.background {
AppBackground()
Color.idpGroupedBackground.ignoresSafeArea()
}
.onOpenURL { url in
model.openDeepLink(url)
}
}
}
private struct PairingSuccessView: View {
var body: some View {
VStack(spacing: 18) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 72, weight: .semibold))
.foregroundStyle(.green)
Text("Passport linked")
.font(.title2.weight(.semibold))
Text("Your device is ready to approve sign-ins.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(32)
}
}