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
+57
View File
@@ -0,0 +1,57 @@
import SwiftUI
struct PrimaryActionStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.horizontal, 12)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.fill(IdP.tint)
)
.foregroundStyle(.white)
.opacity(configuration.isPressed ? 0.92 : 1)
}
}
struct SecondaryActionStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.horizontal, 12)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.fill(Color.idpSecondaryGroupedBackground)
)
.overlay(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.stroke(Color.idpSeparator, lineWidth: 1)
)
.foregroundStyle(.white)
.opacity(configuration.isPressed ? 0.92 : 1)
}
}
struct DestructiveStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.horizontal, 12)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.fill(Color.red.opacity(0.18))
)
.overlay(
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.stroke(Color.red.opacity(0.25), lineWidth: 1)
)
.foregroundStyle(.red)
.opacity(configuration.isPressed ? 0.92 : 1)
}
}
+65
View File
@@ -0,0 +1,65 @@
import SwiftUI
struct ApprovalCardModifier: ViewModifier {
var highlighted = false
func body(content: Content) -> some View {
content
.padding(14)
.background(
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
.fill(Color.idpSecondaryGroupedBackground)
)
.overlay(
RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)
.stroke(highlighted ? IdP.tint.opacity(0.75) : Color.idpSeparator, lineWidth: highlighted ? 1.5 : 1)
)
}
}
extension View {
func approvalCard(highlighted: Bool = false) -> some View {
modifier(ApprovalCardModifier(highlighted: highlighted))
}
}
struct RequestHeroCard: View {
let request: ApprovalRequest
let handle: String
var body: some View {
HStack(spacing: 12) {
MonogramAvatar(title: request.source, size: 40)
VStack(alignment: .leading, spacing: 4) {
Text(request.source)
.font(.headline)
.foregroundStyle(.white)
Text(handle)
.font(.footnote)
.foregroundStyle(IdP.tint)
}
}
.approvalCard(highlighted: true)
}
}
struct MonogramAvatar: View {
let title: String
var size: CGFloat = 22
private var monogram: String {
String(title.trimmingCharacters(in: .whitespacesAndNewlines).first ?? "I").uppercased()
}
var body: some View {
RoundedRectangle(cornerRadius: size * 0.34, style: .continuous)
.fill(IdP.tint.opacity(0.2))
.frame(width: size, height: size)
.overlay {
Text(monogram)
.font(.system(size: size * 0.48, weight: .semibold, design: .rounded))
.foregroundStyle(IdP.tint)
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import SwiftUI
public extension View {
@ViewBuilder
func idpGlassChrome() -> some View {
self.background(.thinMaterial)
}
}
+16
View File
@@ -0,0 +1,16 @@
import SwiftUI
import WatchKit
enum Haptics {
static func success() {
WKInterfaceDevice.current().play(.success)
}
static func warning() {
WKInterfaceDevice.current().play(.failure)
}
static func selection() {
WKInterfaceDevice.current().play(.click)
}
}
+15
View File
@@ -0,0 +1,15 @@
import SwiftUI
public enum IdP {
public static let tint = Color("IdPTint")
public static let cardRadius: CGFloat = 20
public static let controlRadius: CGFloat = 14
public static let badgeRadius: CGFloat = 8
}
extension Color {
static var idpGroupedBackground: Color { .black }
static var idpSecondaryGroupedBackground: Color { Color.white.opacity(0.08) }
static var idpTertiaryFill: Color { Color.white.opacity(0.12) }
static var idpSeparator: Color { Color.white.opacity(0.14) }
}
+11
View File
@@ -0,0 +1,11 @@
import SwiftUI
struct StatusDot: View {
let color: Color
var body: some View {
Circle()
.fill(color)
.frame(width: 8, height: 8)
}
}
+305 -503
View File
@@ -1,11 +1,8 @@
import Foundation
import SwiftUI
private let watchAccent = AppTheme.accent
private let watchGold = AppTheme.warmAccent
struct WatchRootView: View {
@ObservedObject var model: AppViewModel
@State private var showsQueue = false
var body: some View {
NavigationStack {
@@ -13,12 +10,21 @@ struct WatchRootView: View {
if model.session == nil {
WatchPairingView(model: model)
} else {
WatchDashboardView(model: model)
if showsQueue {
WatchQueueView(model: model)
} else {
WatchHomeView(model: model)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.background(Color.idpGroupedBackground.ignoresSafeArea())
}
.tint(IdP.tint)
.onOpenURL { url in
if (url.host ?? url.lastPathComponent).lowercased() == "inbox" {
showsQueue = true
}
}
.tint(watchAccent)
}
}
@@ -26,395 +32,148 @@ private struct WatchPairingView: View {
@ObservedObject var model: AppViewModel
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 10) {
AppBadge(title: "Preview passport", tone: watchAccent)
VStack(alignment: .leading, spacing: 12) {
Text("Link your watch")
.font(.headline)
.foregroundStyle(.white)
Text("Prove identity from your wrist")
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
Text("Use the shared demo passport so approvals stay visible on your wrist.")
.font(.footnote)
.foregroundStyle(.white.opacity(0.72))
Text("Link this watch to the preview passport so identity checks and alerts stay visible on your wrist.")
.font(.footnote)
.foregroundStyle(.white.opacity(0.72))
HStack(spacing: 8) {
AppStatusTag(title: "Wrist-ready", tone: watchAccent)
AppStatusTag(title: "Proof focus", tone: watchGold)
}
Button("Use demo payload") {
Task {
await model.signInWithSuggestedPayload()
}
.watchCard()
if model.isBootstrapping {
HStack(spacing: 8) {
ProgressView()
.tint(watchAccent)
Text("Preparing preview passport...")
.font(.footnote)
.foregroundStyle(.white.opacity(0.72))
}
.frame(maxWidth: .infinity, alignment: .leading)
.watchCard()
}
Button {
Task {
await model.signInWithSuggestedPayload()
}
} label: {
if model.isAuthenticating {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Label("Link Preview Passport", systemImage: "applewatch")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(watchAccent)
.disabled(model.isBootstrapping || model.suggestedPairingPayload.isEmpty || model.isAuthenticating)
VStack(alignment: .leading, spacing: 10) {
Text("What this watch does")
.font(.headline)
.foregroundStyle(.white)
WatchSetupFeatureRow(
systemImage: "checkmark.shield",
title: "Review identity checks",
subtitle: "See pending proof prompts quickly."
)
WatchSetupFeatureRow(
systemImage: "bell.badge",
title: "Surface important alerts",
subtitle: "Keep passport activity visible at a glance."
)
WatchSetupFeatureRow(
systemImage: "iphone.radiowaves.left.and.right",
title: "Stay in sync with the phone preview",
subtitle: "Use the same mocked passport context."
)
}
.watchCard()
}
.padding(.horizontal, 8)
.padding(.top, 6)
.padding(.bottom, 20)
.buttonStyle(PrimaryActionStyle())
}
.background(Color.black.ignoresSafeArea())
.navigationTitle("Link Watch")
.approvalCard(highlighted: true)
.padding(10)
.navigationTitle("idp.global")
}
}
private struct WatchSetupFeatureRow: View {
let systemImage: String
let title: String
let subtitle: String
private struct WatchHomeView: View {
@ObservedObject var model: AppViewModel
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: systemImage)
.font(.footnote.weight(.semibold))
.foregroundStyle(watchAccent)
.frame(width: 18, height: 18)
Group {
if let request = model.pendingRequests.first {
WatchApprovalView(model: model, requestID: request.id)
} else {
WatchQueueView(model: model)
}
}
}
}
struct WatchApprovalView: View {
@ObservedObject var model: AppViewModel
let requestID: ApprovalRequest.ID
private var request: ApprovalRequest? {
model.requests.first(where: { $0.id == requestID })
}
var body: some View {
Group {
if let request {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
MonogramAvatar(title: request.watchAppDisplayName, size: 42)
Text("Sign in as \(model.profile?.handle ?? "@you")?")
.font(.headline)
.foregroundStyle(.white)
Text(request.watchLocationSummary)
.font(.footnote)
.foregroundStyle(.white.opacity(0.72))
HStack(spacing: 8) {
Button {
Task {
Haptics.warning()
await model.reject(request)
}
} label: {
Image(systemName: "xmark")
.frame(maxWidth: .infinity)
}
.buttonStyle(SecondaryActionStyle())
.frame(maxWidth: .infinity)
WatchHoldToApproveButton(isBusy: model.activeRequestID == request.id) {
await model.approve(request)
}
.frame(maxWidth: .infinity)
}
}
.approvalCard(highlighted: true)
.padding(10)
}
.navigationTitle("Approve")
.toolbar {
ToolbarItem(placement: .bottomBar) {
NavigationLink("Queue") {
WatchQueueView(model: model)
}
}
}
} else {
WatchEmptyState(
title: "No request",
message: "This sign-in is no longer pending.",
systemImage: "checkmark.circle"
)
}
}
}
}
private struct WatchQueueView: View {
@ObservedObject var model: AppViewModel
var body: some View {
List {
if model.requests.isEmpty {
WatchEmptyState(
title: "All clear",
message: "New sign-in requests will appear on your watch here.",
systemImage: "shield"
)
} else {
ForEach(model.requests) { request in
NavigationLink {
WatchRequestDetailView(model: model, requestID: request.id)
} label: {
WatchQueueRow(request: request)
}
}
}
}
.navigationTitle("Queue")
}
}
private struct WatchQueueRow: View {
let request: ApprovalRequest
var body: some View {
HStack(spacing: 8) {
MonogramAvatar(title: request.watchAppDisplayName, size: 22)
VStack(alignment: .leading, spacing: 2) {
Text(title)
Text(request.watchAppDisplayName)
.font(.footnote.weight(.semibold))
.foregroundStyle(.white)
Text(subtitle)
Text(request.createdAt, style: .time)
.font(.caption2)
.foregroundStyle(.white.opacity(0.68))
}
}
}
}
private extension View {
func watchCard() -> some View {
padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1)
)
}
}
private struct WatchDashboardView: View {
@ObservedObject var model: AppViewModel
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
WatchPassportCard(model: model)
.watchCard()
WatchSectionHeader(
title: "Pending",
detail: model.pendingRequests.isEmpty ? nil : "\(model.pendingRequests.count)"
)
if model.pendingRequests.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("No checks waiting.")
.font(.footnote.weight(.semibold))
.foregroundStyle(.white)
Text("New identity checks will appear here when a site or device asks you to prove it is really you.")
.font(.caption2)
.foregroundStyle(.white.opacity(0.68))
Button("Seed Identity Check") {
Task {
await model.simulateIncomingRequest()
}
}
.buttonStyle(.bordered)
.tint(watchAccent)
}
.watchCard()
} else {
ForEach(model.pendingRequests) { request in
NavigationLink {
WatchRequestDetailView(model: model, requestID: request.id)
} label: {
WatchRequestRow(request: request)
.watchCard()
}
.buttonStyle(.plain)
}
}
WatchSectionHeader(title: "Activity")
if model.notifications.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("No recent alerts.")
.font(.footnote.weight(.semibold))
.foregroundStyle(.white)
Text("Passport activity and security events will show up here.")
.font(.caption2)
.foregroundStyle(.white.opacity(0.68))
}
.watchCard()
} else {
ForEach(model.notifications.prefix(3)) { notification in
NavigationLink {
WatchNotificationDetailView(model: model, notificationID: notification.id)
} label: {
WatchNotificationRow(notification: notification)
.watchCard()
}
.buttonStyle(.plain)
}
}
WatchSectionHeader(title: "Actions")
VStack(alignment: .leading, spacing: 10) {
Button("Refresh") {
Task {
await model.refreshDashboard()
}
}
.buttonStyle(.bordered)
.tint(watchAccent)
.disabled(model.isRefreshing)
Button("Send Test Alert") {
Task {
await model.sendTestNotification()
}
}
.buttonStyle(.bordered)
if model.notificationPermission == .unknown || model.notificationPermission == .denied {
Button("Enable Alerts") {
Task {
await model.requestNotificationAccess()
}
}
.buttonStyle(.bordered)
}
Button("Sign Out", role: .destructive) {
model.signOut()
}
.buttonStyle(.bordered)
}
.watchCard()
if let profile = model.profile {
WatchSectionHeader(title: "Identity")
VStack(alignment: .leading, spacing: 8) {
Text(profile.handle)
.font(.footnote.weight(.semibold))
.foregroundStyle(.white)
Text(profile.organization)
.font(.caption2)
.foregroundStyle(.white.opacity(0.68))
Text("Notifications: \(model.notificationPermission.title)")
.font(.caption2)
.foregroundStyle(.white.opacity(0.68))
}
.watchCard()
}
}
.padding(.horizontal, 8)
.padding(.top, 12)
.padding(.bottom, 20)
}
.background(Color.black.ignoresSafeArea())
.navigationTitle("Passport")
.refreshable {
await model.refreshDashboard()
}
}
}
private struct WatchSectionHeader: View {
let title: String
var detail: String? = nil
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(title)
.font(.headline)
.foregroundStyle(.white)
if let detail, !detail.isEmpty {
Text(detail)
.font(.caption2.weight(.semibold))
.foregroundStyle(.white.opacity(0.58))
}
}
.padding(.horizontal, 2)
}
}
private struct WatchPassportCard: View {
@ObservedObject var model: AppViewModel
var body: some View {
VStack(alignment: .leading, spacing: 10) {
AppBadge(title: "Passport active", tone: watchAccent)
VStack(alignment: .leading, spacing: 2) {
Text(model.profile?.name ?? "Preview Session")
.font(.headline)
.foregroundStyle(.white)
Text(model.pairedDeviceSummary)
.font(.footnote)
.foregroundStyle(.white.opacity(0.72))
if let session = model.session {
Text("Via \(session.pairingTransport.title)")
.font(.caption2)
.foregroundStyle(.white.opacity(0.58))
}
}
HStack(spacing: 8) {
WatchMetricPill(title: "Pending", value: "\(model.pendingRequests.count)", accent: watchAccent)
WatchMetricPill(title: "Unread", value: "\(model.unreadNotificationCount)", accent: watchGold)
}
}
.padding(.vertical, 6)
}
}
private struct WatchMetricPill: View {
let title: String
let value: String
let accent: Color
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(value)
.font(.headline.monospacedDigit())
.foregroundStyle(.white)
Text(title)
.font(.caption2)
.foregroundStyle(.white.opacity(0.68))
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
private struct WatchRequestRow: View {
let request: ApprovalRequest
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .top, spacing: 6) {
Text(request.title)
.font(.headline)
.lineLimit(2)
.foregroundStyle(.white)
Spacer(minLength: 6)
Image(systemName: request.risk == .elevated ? "exclamationmark.shield.fill" : "checkmark.shield.fill")
.foregroundStyle(request.risk == .elevated ? .orange : watchAccent)
}
Text(request.source)
.font(.footnote)
.foregroundStyle(.white.opacity(0.72))
HStack(spacing: 8) {
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? watchAccent : .orange)
AppStatusTag(title: request.status.title, tone: request.status == .pending ? .orange : watchAccent)
}
Text(request.createdAt.watchRelativeString)
.font(.caption2)
.foregroundStyle(.white.opacity(0.58))
}
}
}
private struct WatchNotificationRow: View {
let notification: AppNotification
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .top, spacing: 6) {
Text(notification.title)
.font(.headline)
.lineLimit(2)
.foregroundStyle(.white)
Spacer(minLength: 6)
if notification.isUnread {
Circle()
.fill(watchAccent)
.frame(width: 8, height: 8)
}
}
Text(notification.message)
.font(.footnote)
.foregroundStyle(.white.opacity(0.72))
.lineLimit(2)
Text(notification.sentAt.watchRelativeString)
.font(.caption2)
.foregroundStyle(.white.opacity(0.58))
}
.padding(.vertical, 2)
}
}
@@ -431,159 +190,202 @@ private struct WatchRequestDetailView: View {
if let request {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
detailHeader(
title: request.title,
subtitle: request.source,
badge: request.status.title
)
RequestHeroCard(request: request, handle: model.profile?.handle ?? "@you")
Text(request.subtitle)
Text(request.watchTrustExplanation)
.font(.footnote)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
Text("Trust Summary")
.font(.headline)
Text(request.trustHeadline)
.font(.subheadline.weight(.semibold))
Text(request.trustDetail)
.font(.footnote)
.foregroundStyle(.secondary)
Text(request.risk.guidance)
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
if !request.scopes.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Scopes")
.font(.headline)
ForEach(request.scopes, id: \.self) { scope in
Label(scope, systemImage: "checkmark.seal.fill")
.font(.footnote)
}
}
}
.foregroundStyle(.white.opacity(0.72))
if request.status == .pending {
if model.activeRequestID == request.id {
ProgressView("Updating proof...")
} else {
Button("Verify") {
Task {
await model.approve(request)
}
}
.buttonStyle(.borderedProminent)
WatchHoldToApproveButton(isBusy: model.activeRequestID == request.id) {
await model.approve(request)
}
Button("Decline", role: .destructive) {
Task {
await model.reject(request)
}
Button("Deny") {
Task {
Haptics.warning()
await model.reject(request)
}
}
.buttonStyle(SecondaryActionStyle())
}
}
.padding(.horizontal, 8)
.padding(.bottom, 20)
.padding(10)
}
} else {
Text("This request is no longer available.")
.foregroundStyle(.secondary)
WatchEmptyState(
title: "No request",
message: "This sign-in is no longer pending.",
systemImage: "shield"
)
}
}
.navigationTitle("Identity Check")
}
private func detailHeader(title: String, subtitle: String, badge: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
Text(badge)
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(watchAccent.opacity(0.14), in: Capsule())
}
.navigationTitle("Details")
}
}
private struct WatchNotificationDetailView: View {
@ObservedObject var model: AppViewModel
let notificationID: AppNotification.ID
private struct WatchHoldToApproveButton: View {
var isBusy = false
let action: () async -> Void
private var notification: AppNotification? {
model.notifications.first(where: { $0.id == notificationID })
}
@State private var progress: CGFloat = 0
var body: some View {
Group {
if let notification {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text(notification.title)
.font(.headline)
Text(notification.kind.title)
.font(.footnote.weight(.semibold))
.foregroundStyle(watchAccent)
Text(notification.sentAt.watchRelativeString)
.font(.caption2)
.foregroundStyle(.secondary)
}
ZStack {
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.fill(isBusy ? Color.white.opacity(0.18) : IdP.tint)
Text(notification.message)
.font(.footnote)
.foregroundStyle(.secondary)
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.stroke(Color.white.opacity(0.16), lineWidth: 1)
VStack(alignment: .leading, spacing: 6) {
Text("Alert posture")
.font(.headline)
Text(model.notificationPermission.summary)
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
Text(isBusy ? "Working…" : "Approve")
.font(.headline)
.foregroundStyle(.white)
.padding(.vertical, 12)
if notification.isUnread {
Button("Mark Read") {
Task {
await model.markNotificationRead(notification)
}
}
}
}
.padding(.horizontal, 8)
.padding(.bottom, 20)
}
} else {
Text("This activity item has already been cleared.")
.foregroundStyle(.secondary)
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.trim(from: 0, to: progress)
.stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
.rotationEffect(.degrees(-90))
.padding(2)
}
.contentShape(RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous))
.onLongPressGesture(minimumDuration: 0.6, maximumDistance: 18, pressing: updateProgress) {
guard !isBusy else { return }
Task {
Haptics.success()
await action()
progress = 0
}
}
.navigationTitle("Activity")
.watchPrimaryActionGesture()
.accessibilityAddTraits(.isButton)
.accessibilityHint("Press and hold to approve the sign-in request.")
}
private func updateProgress(_ isPressing: Bool) {
guard !isBusy else { return }
withAnimation(.linear(duration: isPressing ? 0.6 : 0.15)) {
progress = isPressing ? 1 : 0
}
}
}
private extension Date {
var watchRelativeString: String {
WatchFormatters.relative.localizedString(for: self, relativeTo: .now)
private extension View {
@ViewBuilder
func watchPrimaryActionGesture() -> some View {
if #available(watchOS 11.0, *) {
self.handGestureShortcut(.primaryAction)
} else {
self
}
}
}
private enum WatchFormatters {
static let relative: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter
}()
private extension ApprovalRequest {
var watchAppDisplayName: String {
source.replacingOccurrences(of: "auth.", with: "")
}
var watchTrustExplanation: String {
risk == .elevated
? "This request needs a higher-assurance proof before it can continue."
: "This request matches a familiar device and sign-in pattern."
}
var watchLocationSummary: String {
"Berlin, DE"
}
}
private struct WatchEmptyState: View {
let title: String
let message: String
let systemImage: String
var body: some View {
ContentUnavailableView {
Label(title, systemImage: systemImage)
} description: {
Text(message)
}
}
}
#Preview("Watch Approval Light") {
WatchApprovalPreviewHost()
}
#Preview("Watch Approval Dark") {
WatchApprovalPreviewHost()
.preferredColorScheme(.dark)
}
@MainActor
private struct WatchApprovalPreviewHost: View {
@State private var model = WatchPreviewFixtures.model()
var body: some View {
WatchApprovalView(model: model, requestID: WatchPreviewFixtures.requests[0].id)
}
}
private enum WatchPreviewFixtures {
static let profile = MemberProfile(
name: "Jurgen Meyer",
handle: "@jurgen",
organization: "idp.global",
deviceCount: 3,
recoverySummary: "Recovery kit healthy."
)
static let session = AuthSession(
deviceName: "Apple Watch",
originHost: "github.com",
pairedAt: .now.addingTimeInterval(-60 * 45),
tokenPreview: "berlin",
pairingCode: "idp.global://pair?token=swiftapp-demo-berlin&origin=github.com&device=Apple%20Watch",
pairingTransport: .preview
)
static let requests: [ApprovalRequest] = [
ApprovalRequest(
title: "GitHub sign-in",
subtitle: "A sign-in request is waiting on your iPhone.",
source: "github.com",
createdAt: .now.addingTimeInterval(-60 * 2),
kind: .signIn,
risk: .routine,
scopes: ["profile", "email"],
status: .pending
)
]
@MainActor
static func model() -> AppViewModel {
let model = AppViewModel(
service: MockIDPService.shared,
notificationCoordinator: WatchPreviewCoordinator(),
appStateStore: WatchPreviewStore(),
launchArguments: []
)
model.session = session
model.profile = profile
model.requests = requests
model.notifications = []
model.notificationPermission = .allowed
return model
}
}
private struct WatchPreviewCoordinator: NotificationCoordinating {
func authorizationStatus() async -> NotificationPermissionState { .allowed }
func requestAuthorization() async throws -> NotificationPermissionState { .allowed }
func scheduleTestNotification(title: String, body: String) async throws {}
}
private struct WatchPreviewStore: AppStateStoring {
func load() -> PersistedAppState? { nil }
func save(_ state: PersistedAppState) {}
func clear() {}
}
@@ -0,0 +1,292 @@
import SwiftUI
import WidgetKit
#if os(iOS)
import ActivityKit
import AppIntents
import UIKit
#endif
struct ApprovalWidgetEntry: TimelineEntry {
let date: Date
let pendingCount: Int
let topPayload: ApprovalActivityPayload?
}
struct ApprovalWidgetProvider: TimelineProvider {
func placeholder(in context: Context) -> ApprovalWidgetEntry {
ApprovalWidgetEntry(
date: .now,
pendingCount: 2,
topPayload: ApprovalActivityPayload(
requestID: UUID().uuidString,
title: "github.com wants to sign in",
appName: "github.com",
source: "github.com",
handle: "@jurgen",
location: "Berlin, DE",
createdAt: .now
)
)
}
func getSnapshot(in context: Context, completion: @escaping (ApprovalWidgetEntry) -> Void) {
completion(makeEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<ApprovalWidgetEntry>) -> Void) {
let entry = makeEntry()
completion(Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(60))))
}
private func makeEntry() -> ApprovalWidgetEntry {
let state = UserDefaultsAppStateStore().load()
let pendingRequests = (state?.requests ?? [])
.filter { $0.status == .pending }
.sorted { $0.createdAt > $1.createdAt }
let handle = state?.profile.handle ?? "@you"
return ApprovalWidgetEntry(
date: .now,
pendingCount: pendingRequests.count,
topPayload: pendingRequests.first?.activityPayload(handle: handle)
)
}
}
@main
struct IDPGlobalWidgetsBundle: WidgetBundle {
var body: some Widget {
#if os(iOS)
ApprovalLiveActivityWidget()
#endif
#if os(watchOS)
ApprovalAccessoryRectangularWidget()
ApprovalAccessoryCircularWidget()
ApprovalAccessoryCornerWidget()
#endif
}
}
#if os(iOS)
struct ApproveLiveActivityIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Approve"
static var openAppWhenRun = false
@Parameter(title: "Request ID")
var requestID: String
init() {}
init(requestID: String) {
self.requestID = requestID
}
func perform() async throws -> some IntentResult {
guard let id = UUID(uuidString: requestID) else {
return .result()
}
_ = try? await MockIDPService.shared.approveRequest(id: id)
await ApprovalLiveActivityActionHandler.complete(requestID: requestID, outcome: "Approved")
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}
struct DenyLiveActivityIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Deny"
static var openAppWhenRun = false
@Parameter(title: "Request ID")
var requestID: String
init() {}
init(requestID: String) {
self.requestID = requestID
}
func perform() async throws -> some IntentResult {
guard let id = UUID(uuidString: requestID) else {
return .result()
}
_ = try? await MockIDPService.shared.rejectRequest(id: id)
await ApprovalLiveActivityActionHandler.complete(requestID: requestID, outcome: "Denied")
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}
private enum ApprovalLiveActivityActionHandler {
static func complete(requestID: String, outcome: String) async {
guard let activity = Activity<ApprovalActivityAttributes>.activities.first(where: { $0.attributes.requestID == requestID }) else {
return
}
let state = ApprovalActivityAttributes.ContentState(
requestID: requestID,
title: outcome,
appName: activity.content.state.appName,
source: activity.content.state.source,
handle: activity.content.state.handle,
location: activity.content.state.location
)
await activity.end(ActivityContent(state: state, staleDate: .now), dismissalPolicy: .immediate)
}
}
struct ApprovalLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: ApprovalActivityAttributes.self) { context in
VStack(alignment: .leading, spacing: 12) {
Text(context.state.title)
.font(.headline)
Text("Sign in as \(Text(context.state.handle).foregroundStyle(.purple))")
.font(.subheadline)
Text(context.state.location)
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
Button(intent: DenyLiveActivityIntent(requestID: context.state.requestID)) {
Text("Deny")
}
.buttonStyle(.bordered)
Button(intent: ApproveLiveActivityIntent(requestID: context.state.requestID)) {
Text("Approve")
}
.buttonStyle(.borderedProminent)
.tint(.purple)
}
}
.padding(16)
.activityBackgroundTint(Color(uiColor: .systemBackground))
.activitySystemActionForegroundColor(.purple)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "shield.lefthalf.filled")
.foregroundStyle(.purple)
}
DynamicIslandExpandedRegion(.trailing) {
MonogramBubble(title: context.state.appName)
}
DynamicIslandExpandedRegion(.center) {
VStack(alignment: .leading, spacing: 4) {
Text(context.state.title)
.font(.headline)
Text(context.state.handle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
DynamicIslandExpandedRegion(.bottom) {
HStack(spacing: 10) {
Button(intent: DenyLiveActivityIntent(requestID: context.state.requestID)) {
Text("Deny")
}
.buttonStyle(.bordered)
Button(intent: ApproveLiveActivityIntent(requestID: context.state.requestID)) {
Text("Approve")
}
.buttonStyle(.borderedProminent)
.tint(.purple)
}
}
} compactLeading: {
Image(systemName: "shield.lefthalf.filled")
.foregroundStyle(.purple)
} compactTrailing: {
MonogramBubble(title: context.state.appName)
} minimal: {
Image(systemName: "shield")
.foregroundStyle(.purple)
}
}
}
}
private struct MonogramBubble: View {
let title: String
private var letter: String {
String(title.trimmingCharacters(in: .whitespacesAndNewlines).first ?? "I").uppercased()
}
var body: some View {
ZStack {
Circle()
.fill(Color.purple.opacity(0.18))
Text(letter)
.font(.caption.weight(.bold))
.foregroundStyle(.purple)
}
.frame(width: 24, height: 24)
}
}
#endif
#if os(watchOS)
struct ApprovalAccessoryRectangularWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "IDPGlobalAccessoryRectangular", provider: ApprovalWidgetProvider()) { entry in
VStack(alignment: .leading, spacing: 3) {
Label("idp.global", systemImage: "shield.lefthalf.filled")
.font(.caption2)
Text("\(entry.pendingCount) requests")
.font(.headline)
Text(entry.topPayload?.appName ?? "Inbox")
.font(.caption2)
.foregroundStyle(.secondary)
}
.widgetURL(URL(string: "idpglobal://inbox"))
}
.configurationDisplayName("Approval Queue")
.description("Pending sign-in requests.")
.supportedFamilies([.accessoryRectangular])
}
}
struct ApprovalAccessoryCircularWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "IDPGlobalAccessoryCircular", provider: ApprovalWidgetProvider()) { entry in
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 2) {
Image(systemName: "shield.lefthalf.filled")
Text("\(entry.pendingCount)")
.font(.caption2.weight(.bold))
}
}
.widgetURL(URL(string: "idpglobal://inbox"))
}
.configurationDisplayName("Approval Count")
.supportedFamilies([.accessoryCircular])
}
}
struct ApprovalAccessoryCornerWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "IDPGlobalAccessoryCorner", provider: ApprovalWidgetProvider()) { entry in
Text("\(entry.pendingCount)")
.widgetCurvesContent()
.widgetLabel {
Image(systemName: "shield.lefthalf.filled")
}
.widgetURL(URL(string: "idpglobal://inbox"))
}
.configurationDisplayName("Approval Corner")
.supportedFamilies([.accessoryCorner])
}
}
#endif
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>idp.global Widgets</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).IDPGlobalWidgetsBundle</string>
</dict>
</dict>
</plist>