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
@@ -0,0 +1,72 @@
import SwiftUI
struct PrimaryActionStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
PrimaryActionBody(configuration: configuration)
}
private struct PrimaryActionBody: View {
let configuration: Configuration
@Environment(\.isEnabled) private var isEnabled
var body: some View {
configuration.label
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.foregroundStyle(.white)
.background(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.fill(isEnabled ? IdP.tint : Color.secondary.opacity(0.25))
)
.opacity(configuration.isPressed ? 0.92 : 1)
.scaleEffect(configuration.isPressed ? 0.985 : 1)
.animation(.easeOut(duration: 0.16), value: configuration.isPressed)
}
}
}
struct SecondaryActionStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.foregroundStyle(.primary)
.background(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.fill(Color.idpSecondaryGroupedBackground)
)
.overlay(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.stroke(Color.idpSeparator.opacity(0.55), lineWidth: 1)
)
.opacity(configuration.isPressed ? 0.9 : 1)
.scaleEffect(configuration.isPressed ? 0.985 : 1)
.animation(.easeOut(duration: 0.16), value: configuration.isPressed)
}
}
struct DestructiveStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.foregroundStyle(.red)
.background(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.fill(Color.red.opacity(0.10))
)
.overlay(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.stroke(Color.red.opacity(0.18), lineWidth: 1)
)
.opacity(configuration.isPressed ? 0.9 : 1)
.scaleEffect(configuration.isPressed ? 0.985 : 1)
.animation(.easeOut(duration: 0.16), value: configuration.isPressed)
}
}
+100
View File
@@ -0,0 +1,100 @@
import SwiftUI
struct ApprovalCardModifier: ViewModifier {
var highlighted = false
func body(content: Content) -> some View {
content
.padding(18)
.background(
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
.fill(Color.idpSecondaryGroupedBackground)
)
.overlay(
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
.stroke(highlighted ? IdP.tint.opacity(0.7) : Color.idpSeparator.opacity(0.55), lineWidth: highlighted ? 1.5 : 1)
)
.overlay {
if highlighted {
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
.stroke(IdP.tint.opacity(0.12), lineWidth: 6)
.padding(-2)
}
}
}
}
extension View {
func approvalCard(highlighted: Bool = false) -> some View {
modifier(ApprovalCardModifier(highlighted: highlighted))
}
func deviceRowStyle() -> some View {
modifier(DeviceRowStyle())
}
}
struct RequestHeroCard: View {
let request: ApprovalRequest
let handle: String
var body: some View {
HStack(alignment: .top, spacing: 16) {
MonogramAvatar(title: request.source, size: 64)
VStack(alignment: .leading, spacing: 8) {
Text("\(request.source) wants to sign in as you")
.font(.title3.weight(.semibold))
.fixedSize(horizontal: false, vertical: true)
Text("Continue as \(Text(handle).foregroundStyle(IdP.tint))")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Label(request.kind.title, systemImage: request.kind.systemImage)
Text(request.createdAt, style: .relative)
}
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
}
.approvalCard(highlighted: true)
}
}
struct MonogramAvatar: View {
let title: String
var size: CGFloat = 40
var tint: Color = IdP.tint
private var monogram: String {
String(title.trimmingCharacters(in: .whitespacesAndNewlines).first ?? "I").uppercased()
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: size * 0.34, style: .continuous)
.fill(tint.opacity(0.14))
Image("AppMonogram")
.resizable()
.scaledToFit()
.frame(width: size * 0.44, height: size * 0.44)
.opacity(0.18)
Text(monogram)
.font(.system(size: size * 0.42, weight: .semibold, design: .rounded))
.foregroundStyle(tint)
}
.frame(width: size, height: size)
.accessibilityHidden(true)
}
}
struct DeviceRowStyle: ViewModifier {
func body(content: Content) -> some View {
content
.padding(.vertical, 4)
}
}
@@ -0,0 +1,39 @@
import SwiftUI
public extension View {
@ViewBuilder
func idpGlassChrome() -> some View {
if #available(iOS 26, macOS 26, *) {
self.glassEffect(.regular)
} else {
self.background(.regularMaterial)
}
}
}
struct IdPGlassCapsule<Content: View>: View {
let padding: EdgeInsets
let content: Content
init(
padding: EdgeInsets = EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16),
@ViewBuilder content: () -> Content
) {
self.padding = padding
self.content = content()
}
var body: some View {
content
.padding(padding)
.background(
Capsule(style: .continuous)
.fill(.clear)
.idpGlassChrome()
)
.overlay(
Capsule(style: .continuous)
.stroke(Color.white.opacity(0.16), lineWidth: 1)
)
}
}
+33
View File
@@ -0,0 +1,33 @@
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
enum Haptics {
static func success() {
#if os(iOS)
UINotificationFeedbackGenerator().notificationOccurred(.success)
#elseif os(macOS)
NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .now)
#endif
}
static func warning() {
#if os(iOS)
UINotificationFeedbackGenerator().notificationOccurred(.warning)
#elseif os(macOS)
NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now)
#endif
}
static func selection() {
#if os(iOS)
UISelectionFeedbackGenerator().selectionChanged()
#elseif os(macOS)
NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now)
#endif
}
}
+109
View File
@@ -0,0 +1,109 @@
import SwiftUI
#if os(macOS)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
public enum IdP {
public static let tint = Color("IdPTint")
public static let cardRadius: CGFloat = 22
public static let controlRadius: CGFloat = 14
public static let badgeRadius: CGFloat = 8
static func horizontalPadding(compact: Bool) -> CGFloat {
compact ? 16 : 24
}
static func verticalPadding(compact: Bool) -> CGFloat {
compact ? 16 : 24
}
}
extension Color {
static var idpGroupedBackground: Color {
#if os(macOS)
Color(nsColor: .windowBackgroundColor)
#elseif os(watchOS)
.black
#else
Color(uiColor: .systemGroupedBackground)
#endif
}
static var idpSecondaryGroupedBackground: Color {
#if os(macOS)
Color(nsColor: .controlBackgroundColor)
#elseif os(watchOS)
Color.white.opacity(0.08)
#else
Color(uiColor: .secondarySystemGroupedBackground)
#endif
}
static var idpTertiaryFill: Color {
#if os(macOS)
Color(nsColor: .quaternaryLabelColor).opacity(0.08)
#elseif os(watchOS)
Color.white.opacity(0.12)
#else
Color(uiColor: .tertiarySystemFill)
#endif
}
static var idpSeparator: Color {
#if os(macOS)
Color(nsColor: .separatorColor)
#elseif os(watchOS)
Color.white.opacity(0.14)
#else
Color(uiColor: .separator)
#endif
}
}
extension View {
func idpScreenPadding(compact: Bool) -> some View {
padding(.horizontal, IdP.horizontalPadding(compact: compact))
.padding(.vertical, IdP.verticalPadding(compact: compact))
}
@ViewBuilder
func idpInlineNavigationTitle() -> some View {
#if os(macOS)
self
#else
navigationBarTitleDisplayMode(.inline)
#endif
}
@ViewBuilder
func idpTabBarChrome() -> some View {
#if os(macOS)
self
#else
toolbarBackground(.visible, for: .tabBar)
.toolbarBackground(.regularMaterial, for: .tabBar)
#endif
}
@ViewBuilder
func idpSearchable(text: Binding<String>, isPresented: Binding<Bool>) -> some View {
#if os(macOS)
searchable(text: text, isPresented: isPresented)
#else
searchable(text: text, isPresented: isPresented, placement: .navigationBarDrawer(displayMode: .always))
#endif
}
}
extension ToolbarItemPlacement {
static var idpTrailingToolbar: ToolbarItemPlacement {
#if os(macOS)
.primaryAction
#else
.topBarTrailing
#endif
}
}
+16
View File
@@ -0,0 +1,16 @@
import SwiftUI
struct StatusDot: View {
let color: Color
var body: some View {
Circle()
.fill(color)
.frame(width: 10, height: 10)
.overlay(
Circle()
.stroke(Color.white.opacity(0.65), lineWidth: 1)
)
.accessibilityHidden(true)
}
}
+17 -14
View File
@@ -2,28 +2,31 @@ import CryptoKit
import Foundation
enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable {
case overview
case requests
case activity
case account
case inbox
case notifications
case devices
case identity
case settings
var id: String { rawValue }
var title: String {
switch self {
case .overview: "Passport"
case .requests: "Requests"
case .activity: "Activity"
case .account: "Account"
case .inbox: "Inbox"
case .notifications: "Notifications"
case .devices: "Devices"
case .identity: "Identity"
case .settings: "Settings"
}
}
var systemImage: String {
switch self {
case .overview: "person.crop.square.fill"
case .requests: "checklist.checked"
case .activity: "clock.arrow.trianglehead.counterclockwise.rotate.90"
case .account: "person.crop.circle.fill"
case .inbox: "tray.full.fill"
case .notifications: "bell.badge.fill"
case .devices: "desktopcomputer"
case .identity: "person.crop.rectangle.stack.fill"
case .settings: "gearshape.fill"
}
}
}
@@ -301,8 +304,8 @@ enum ApprovalStatus: String, Hashable, Codable {
var title: String {
switch self {
case .pending: "Pending"
case .approved: "Verified"
case .rejected: "Declined"
case .approved: "Approved"
case .rejected: "Denied"
}
}
@@ -0,0 +1,59 @@
import Foundation
#if canImport(ActivityKit) && os(iOS)
import ActivityKit
#endif
struct ApprovalActivityPayload: Codable, Hashable {
let requestID: String
let title: String
let appName: String
let source: String
let handle: String
let location: String
let createdAt: Date
}
extension ApprovalRequest {
var activityAppName: String {
source
.replacingOccurrences(of: "auth.", with: "")
.replacingOccurrences(of: ".idp.global", with: ".idp.global")
}
var activityLocation: String {
"Berlin, DE"
}
var activityExpiryDate: Date {
createdAt.addingTimeInterval(risk == .elevated ? 180 : 300)
}
func activityPayload(handle: String) -> ApprovalActivityPayload {
ApprovalActivityPayload(
requestID: id.uuidString,
title: title,
appName: activityAppName,
source: source,
handle: handle,
location: activityLocation,
createdAt: createdAt
)
}
}
#if canImport(ActivityKit) && os(iOS)
struct ApprovalActivityAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
let requestID: String
let title: String
let appName: String
let source: String
let handle: String
let location: String
}
let requestID: String
let createdAt: Date
}
#endif
@@ -1,5 +1,13 @@
import Foundation
enum SharedDefaults {
static let appGroupIdentifier = "group.global.idp.app"
static var userDefaults: UserDefaults {
UserDefaults(suiteName: appGroupIdentifier) ?? .standard
}
}
struct PersistedAppState: Codable, Equatable {
let session: AuthSession
let profile: MemberProfile
@@ -19,7 +27,7 @@ final class UserDefaultsAppStateStore: AppStateStoring {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(defaults: UserDefaults = .standard, storageKey: String = "persisted-app-state") {
init(defaults: UserDefaults = SharedDefaults.userDefaults, storageKey: String = "persisted-app-state") {
self.defaults = defaults
self.storageKey = storageKey
}
@@ -12,6 +12,8 @@ protocol IDPServicing {
}
actor MockIDPService: IDPServicing {
static let shared = MockIDPService()
private let profile = MemberProfile(
name: "Phil Kunz",
handle: "phil@idp.global",
@@ -20,15 +22,24 @@ actor MockIDPService: IDPServicing {
recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified."
)
private let appStateStore: AppStateStoring
private var requests: [ApprovalRequest] = []
private var notifications: [AppNotification] = []
init() {
requests = Self.seedRequests()
notifications = Self.seedNotifications()
init(appStateStore: AppStateStoring = UserDefaultsAppStateStore()) {
self.appStateStore = appStateStore
if let state = appStateStore.load() {
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
} else {
requests = Self.seedRequests()
notifications = Self.seedNotifications()
}
}
func bootstrap() async throws -> BootstrapContext {
restoreSharedState()
try await Task.sleep(for: .milliseconds(120))
return BootstrapContext(
suggestedPairingPayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
@@ -36,6 +47,7 @@ actor MockIDPService: IDPServicing {
}
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
restoreSharedState()
try await Task.sleep(for: .milliseconds(260))
try validateSignedGPSPosition(in: request)
@@ -51,6 +63,8 @@ actor MockIDPService: IDPServicing {
at: 0
)
persistSharedStateIfAvailable()
return SignInResult(
session: session,
snapshot: snapshot()
@@ -58,6 +72,7 @@ actor MockIDPService: IDPServicing {
}
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(180))
try validateSignedGPSPosition(in: request)
@@ -73,15 +88,19 @@ actor MockIDPService: IDPServicing {
at: 0
)
persistSharedStateIfAvailable()
return snapshot()
}
func refreshDashboard() async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(180))
return snapshot()
}
func approveRequest(id: UUID) async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(150))
guard let index = requests.firstIndex(where: { $0.id == id }) else {
@@ -100,10 +119,13 @@ actor MockIDPService: IDPServicing {
at: 0
)
persistSharedStateIfAvailable()
return snapshot()
}
func rejectRequest(id: UUID) async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(150))
guard let index = requests.firstIndex(where: { $0.id == id }) else {
@@ -122,10 +144,13 @@ actor MockIDPService: IDPServicing {
at: 0
)
persistSharedStateIfAvailable()
return snapshot()
}
func simulateIncomingRequest() async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(120))
let syntheticRequest = ApprovalRequest(
@@ -151,10 +176,13 @@ actor MockIDPService: IDPServicing {
at: 0
)
persistSharedStateIfAvailable()
return snapshot()
}
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(80))
guard let index = notifications.firstIndex(where: { $0.id == id }) else {
@@ -162,6 +190,7 @@ actor MockIDPService: IDPServicing {
}
notifications[index].isUnread = false
persistSharedStateIfAvailable()
return snapshot()
}
@@ -227,6 +256,30 @@ actor MockIDPService: IDPServicing {
return "An identity proof was completed for \(context.deviceName) on \(context.originHost)."
}
private func restoreSharedState() {
guard let state = appStateStore.load() else {
requests = Self.seedRequests()
notifications = Self.seedNotifications()
return
}
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
}
private func persistSharedStateIfAvailable() {
guard let state = appStateStore.load() else { return }
appStateStore.save(
PersistedAppState(
session: state.session,
profile: state.profile,
requests: requests,
notifications: notifications
)
)
}
private static func seedRequests() -> [ApprovalRequest] {
[
ApprovalRequest(