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)
}
}