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
+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() {}
}