Overhaul native approval UX and add widget surfaces
Some checks failed
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

View File

@@ -1,330 +1,346 @@
import SwiftUI
struct RequestList: View {
let requests: [ApprovalRequest]
let compactLayout: Bool
let activeRequestID: ApprovalRequest.ID?
let onApprove: ((ApprovalRequest) -> Void)?
let onReject: ((ApprovalRequest) -> Void)?
let onOpenRequest: (ApprovalRequest) -> Void
extension ApprovalRequest {
var appDisplayName: String {
source
.replacingOccurrences(of: "auth.", with: "")
.replacingOccurrences(of: ".idp.global", with: ".idp.global")
}
var body: some View {
VStack(spacing: 14) {
ForEach(requests) { request in
RequestCard(
request: request,
compactLayout: compactLayout,
isBusy: activeRequestID == request.id,
onApprove: onApprove == nil ? nil : { onApprove?(request) },
onReject: onReject == nil ? nil : { onReject?(request) },
onOpenRequest: { onOpenRequest(request) }
)
}
var inboxTitle: String {
"Sign in to \(appDisplayName)"
}
var locationSummary: String {
"Berlin, DE"
}
var deviceSummary: String {
switch kind {
case .signIn:
"Safari on Berlin iPhone"
case .accessGrant:
"Chrome on iPad Pro"
case .elevatedAction:
"Berlin MacBook Pro"
}
}
var networkSummary: String {
switch kind {
case .signIn:
"Home Wi-Fi"
case .accessGrant:
"Shared office Wi-Fi"
case .elevatedAction:
"Ethernet"
}
}
var ipSummary: String {
risk == .elevated ? "84.187.12.44" : "84.187.12.36"
}
var trustColor: Color {
switch (status, risk) {
case (.rejected, _):
.red
case (.approved, _), (_, .routine):
.green
case (.pending, .elevated):
.yellow
}
}
var trustExplanation: String {
switch (status, risk) {
case (.approved, _):
"This proof came from a signed device session that matches your usual sign-in pattern."
case (.rejected, _):
"This request was denied, so no data will be shared unless a new sign-in is started."
case (.pending, .routine):
"The origin and device pattern look familiar for this account."
case (.pending, .elevated):
"The request is valid, but it is asking for a stronger proof than usual."
}
}
var expiresAt: Date {
createdAt.addingTimeInterval(risk == .elevated ? 180 : 300)
}
}
private enum NotificationPresentationStatus {
case approved
case denied
case expired
var title: String {
switch self {
case .approved: "Approved"
case .denied: "Denied"
case .expired: "Expired"
}
}
var color: Color {
switch self {
case .approved: .green
case .denied: .red
case .expired: .secondary
}
}
}
private struct RequestCard: View {
let request: ApprovalRequest
let compactLayout: Bool
let isBusy: Bool
let onApprove: (() -> Void)?
let onReject: (() -> Void)?
let onOpenRequest: () -> Void
extension AppNotification {
fileprivate var presentationStatus: NotificationPresentationStatus {
let haystack = "\(title) \(message)".lowercased()
if haystack.contains("declined") || haystack.contains("denied") {
return .denied
}
if haystack.contains("expired") || haystack.contains("quiet hours") {
return .expired
}
return .approved
}
}
struct StatusPill: View {
let title: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: request.kind.systemImage)
Text(title)
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(color.opacity(0.12), in: Capsule(style: .continuous))
.foregroundStyle(color)
}
}
struct TimeChip: View {
let date: Date
var compact = false
var body: some View {
Text(date, format: .dateTime.hour().minute())
.font(compact ? .caption2.weight(.medium) : .caption.weight(.medium))
.monospacedDigit()
.padding(.horizontal, compact ? 8 : 10)
.padding(.vertical, compact ? 4 : 6)
.background(Color.idpTertiaryFill, in: Capsule(style: .continuous))
.foregroundStyle(.secondary)
}
}
struct ApprovalRow: View {
let request: ApprovalRequest
let handle: String
var compact = false
var highlighted = false
var body: some View {
HStack(spacing: 12) {
MonogramAvatar(title: request.appDisplayName, size: compact ? 32 : 40)
VStack(alignment: .leading, spacing: 4) {
Text(request.inboxTitle)
.font(compact ? .subheadline.weight(.semibold) : .headline)
.foregroundStyle(.primary)
.lineLimit(2)
Text("as \(handle) · \(request.locationSummary)")
.font(compact ? .caption : .subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
HStack(spacing: 10) {
TimeChip(date: request.createdAt, compact: compact)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, compact ? 6 : 10)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(highlighted ? IdP.tint.opacity(0.06) : Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(highlighted ? IdP.tint : Color.clear, lineWidth: highlighted ? 1.5 : 0)
)
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.accessibilityLabel("\(request.inboxTitle), \(request.locationSummary), \(request.createdAt.formatted(date: .omitted, time: .shortened))")
}
}
struct NotificationEventRow: View {
let notification: AppNotification
var body: some View {
HStack(alignment: .top, spacing: 12) {
MonogramAvatar(title: notification.title, size: 40, tint: notification.presentationStatus.color)
VStack(alignment: .leading, spacing: 5) {
Text(notification.title)
.font(.headline)
.foregroundStyle(requestAccent)
.frame(width: 28, height: 28)
.lineLimit(2)
VStack(alignment: .leading, spacing: 4) {
Text(request.title)
.font(.headline)
.multilineTextAlignment(.leading)
Text(request.source)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
AppStatusTag(title: request.status.title, tone: statusTone)
}
Text(request.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack(spacing: 8) {
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
Text(request.scopeSummary)
.font(.footnote)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
Text(request.createdAt, style: .relative)
.font(.footnote)
.foregroundStyle(.secondary)
}
if !request.scopes.isEmpty {
Text("Proof details: \(request.scopes.joined(separator: ", "))")
.font(.footnote)
Text(notification.message)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
controls
}
.padding(compactLayout ? 18 : 20)
.appSurface(radius: 24)
}
Spacer(minLength: 8)
@ViewBuilder
private var controls: some View {
if compactLayout {
VStack(alignment: .leading, spacing: 10) {
reviewButton
decisionButtons
}
} else {
HStack(spacing: 12) {
reviewButton
Spacer(minLength: 0)
decisionButtons
}
}
}
private var reviewButton: some View {
Button {
onOpenRequest()
} label: {
Label("Review proof", systemImage: "arrow.up.forward.app")
}
.buttonStyle(.bordered)
}
@ViewBuilder
private var decisionButtons: some View {
if request.status == .pending, let onApprove, let onReject {
Button {
onApprove()
} label: {
if isBusy {
ProgressView()
} else {
Label("Verify", systemImage: "checkmark.circle.fill")
}
}
.buttonStyle(.borderedProminent)
.disabled(isBusy)
Button(role: .destructive) {
onReject()
} label: {
Label("Decline", systemImage: "xmark.circle.fill")
}
.buttonStyle(.bordered)
.disabled(isBusy)
}
}
private var statusTone: Color {
switch request.status {
case .pending:
.orange
case .approved:
.green
case .rejected:
.red
}
}
private var requestAccent: Color {
switch request.status {
case .approved:
.green
case .rejected:
.red
case .pending:
request.risk == .routine ? dashboardAccent : .orange
StatusPill(title: notification.presentationStatus.title, color: notification.presentationStatus.color)
}
.padding(.vertical, 8)
.accessibilityElement(children: .combine)
}
}
struct NotificationList: View {
let notifications: [AppNotification]
let compactLayout: Bool
let onMarkRead: (AppNotification) -> Void
struct NotificationPermissionCard: View {
@ObservedObject var model: AppViewModel
var body: some View {
VStack(spacing: 14) {
ForEach(notifications) { notification in
NotificationCard(
notification: notification,
compactLayout: compactLayout,
onMarkRead: { onMarkRead(notification) }
)
}
}
}
}
VStack(alignment: .leading, spacing: 14) {
Label("Allow sign-in alerts", systemImage: model.notificationPermission.systemImage)
.font(.headline)
private struct NotificationCard: View {
let notification: AppNotification
let compactLayout: Bool
let onMarkRead: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: notification.kind.systemImage)
.font(.headline)
.foregroundStyle(accentColor)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) {
Text(notification.title)
.font(.headline)
HStack(spacing: 8) {
AppStatusTag(title: notification.kind.title, tone: accentColor)
if notification.isUnread {
AppStatusTag(title: "Unread", tone: .orange)
}
}
}
Spacer(minLength: 0)
}
Text(notification.message)
Text(model.notificationPermission.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if compactLayout {
VStack(alignment: .leading, spacing: 10) {
timestamp
if notification.isUnread {
markReadButton
VStack(spacing: 10) {
Button("Enable Notifications") {
Task {
await model.requestNotificationAccess()
}
}
} else {
HStack {
timestamp
Spacer(minLength: 0)
if notification.isUnread {
markReadButton
.buttonStyle(PrimaryActionStyle())
Button("Send Test Alert") {
Task {
await model.sendTestNotification()
}
}
.buttonStyle(SecondaryActionStyle())
}
}
.padding(compactLayout ? 18 : 20)
.appSurface(radius: 24)
}
private var timestamp: some View {
Text(notification.sentAt.formatted(date: .abbreviated, time: .shortened))
.font(.footnote)
.foregroundStyle(.secondary)
}
private var markReadButton: some View {
Button {
onMarkRead()
} label: {
Label("Mark read", systemImage: "checkmark")
}
.buttonStyle(.bordered)
}
private var accentColor: Color {
switch notification.kind {
case .approval:
.green
case .security:
.orange
case .system:
.blue
}
.approvalCard()
}
}
struct NotificationBellButton: View {
@ObservedObject var model: AppViewModel
struct DevicePresentation: Identifiable, Hashable {
let id: UUID
let name: String
let systemImage: String
let lastSeen: Date
let isCurrent: Bool
let isTrusted: Bool
init(
id: UUID = UUID(),
name: String,
systemImage: String,
lastSeen: Date,
isCurrent: Bool,
isTrusted: Bool
) {
self.id = id
self.name = name
self.systemImage = systemImage
self.lastSeen = lastSeen
self.isCurrent = isCurrent
self.isTrusted = isTrusted
}
}
struct DeviceItemRow: View {
let device: DevicePresentation
var body: some View {
Button {
model.isNotificationCenterPresented = true
} label: {
Image(systemName: imageName)
HStack(spacing: 12) {
Image(systemName: device.systemImage)
.font(.headline)
.foregroundStyle(iconTone)
.frame(width: 28, height: 28, alignment: .center)
.background(alignment: .center) {
#if os(iOS)
GeometryReader { proxy in
Color.clear
.preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global))
}
#endif
}
.foregroundStyle(IdP.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 3) {
Text(device.name)
.font(.body.weight(.medium))
Text(device.isCurrent ? "This device" : "Seen \(device.lastSeen, style: .relative)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
StatusDot(color: device.isTrusted ? .green : .yellow)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.accessibilityLabel("Notifications")
}
private var imageName: String {
#if os(iOS)
model.unreadNotificationCount == 0 ? "bell" : "bell.fill"
#else
model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill"
#endif
}
private var iconTone: some ShapeStyle {
model.unreadNotificationCount == 0 ? Color.primary : dashboardAccent
.deviceRowStyle()
.accessibilityElement(children: .combine)
}
}
struct NotificationCenterSheet: View {
@ObservedObject var model: AppViewModel
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
struct TrustSignalBanner: View {
let request: ApprovalRequest
var body: some View {
NavigationStack {
AppScrollScreen(
compactLayout: compactLayout,
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
) {
NotificationsPanel(model: model, compactLayout: compactLayout)
}
.navigationTitle("Notifications")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
dismiss()
}
}
HStack(alignment: .top, spacing: 12) {
Image(systemName: symbolName)
.font(.headline)
.foregroundStyle(request.trustColor)
VStack(alignment: .leading, spacing: 4) {
Text(request.trustHeadline)
.font(.subheadline.weight(.semibold))
Text(request.trustExplanation)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
#if os(iOS)
.presentationDetents(compactLayout ? [.large] : [.medium, .large])
#endif
.padding(.vertical, 8)
}
private var compactLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
private var symbolName: String {
switch request.trustColor {
case .green:
return "checkmark.shield.fill"
case .yellow:
return "exclamationmark.triangle.fill"
default:
return "xmark.shield.fill"
}
}
}
struct EmptyPaneView: View {
let title: String
let message: String
let systemImage: String
var body: some View {
ContentUnavailableView {
Label(title, systemImage: systemImage)
} description: {
Text(message)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

View File

@@ -1,317 +1,467 @@
import SwiftUI
struct OverviewPanel: View {
struct InboxListView: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
@Binding var selectedRequestID: ApprovalRequest.ID?
@Binding var searchText: String
@Binding var isSearchPresented: Bool
var usesSelection = false
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if let profile = model.profile, let session = model.session {
OverviewHero(
profile: profile,
session: session,
pendingCount: model.pendingRequests.count,
unreadCount: model.unreadNotificationCount,
compactLayout: compactLayout
)
}
private var filteredRequests: [ApprovalRequest] {
guard !searchText.isEmpty else {
return model.requests
}
return model.requests.filter {
$0.inboxTitle.localizedCaseInsensitiveContains(searchText)
|| $0.source.localizedCaseInsensitiveContains(searchText)
|| $0.subtitle.localizedCaseInsensitiveContains(searchText)
}
}
}
struct RequestsPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
let onOpenRequest: (ApprovalRequest) -> Void
private var recentRequests: [ApprovalRequest] {
filteredRequests.filter { Date.now.timeIntervalSince($0.createdAt) <= 60 * 30 }
}
private var earlierRequests: [ApprovalRequest] {
filteredRequests.filter { Date.now.timeIntervalSince($0.createdAt) > 60 * 30 }
}
private var highlightedRequestID: ApprovalRequest.ID? {
filteredRequests.first?.id
}
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if model.requests.isEmpty {
AppPanel(compactLayout: compactLayout) {
EmptyStateCopy(
title: "No checks waiting",
systemImage: "checkmark.circle",
message: "Identity proof requests from sites and devices appear here."
)
}
List {
if filteredRequests.isEmpty {
EmptyPaneView(
title: "No sign-in requests",
message: "New approval requests will appear here as soon as a relying party asks for proof.",
systemImage: "tray"
)
.listRowBackground(Color.clear)
} else {
RequestList(
requests: model.requests,
compactLayout: compactLayout,
activeRequestID: model.activeRequestID,
onApprove: { request in
Task { await model.approve(request) }
},
onReject: { request in
Task { await model.reject(request) }
},
onOpenRequest: onOpenRequest
)
}
}
}
}
struct ActivityPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if model.notifications.isEmpty {
AppPanel(compactLayout: compactLayout) {
EmptyStateCopy(
title: "No proof activity yet",
systemImage: "clock.badge.xmark",
message: "Identity proofs and security events will appear here."
)
ForEach(recentRequests) { request in
row(for: request, compact: false)
.transition(.move(edge: .top).combined(with: .opacity))
}
} else {
NotificationList(
notifications: model.notifications,
compactLayout: compactLayout,
onMarkRead: { notification in
Task { await model.markNotificationRead(notification) }
}
)
}
}
}
}
struct NotificationsPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
AppSectionCard(title: "Delivery", compactLayout: compactLayout) {
NotificationPermissionSummary(model: model, compactLayout: compactLayout)
}
AppSectionCard(title: "Alerts", compactLayout: compactLayout) {
if model.notifications.isEmpty {
EmptyStateCopy(
title: "No alerts yet",
systemImage: "bell.slash",
message: "New passport and identity-proof alerts will accumulate here."
)
} else {
NotificationList(
notifications: model.notifications,
compactLayout: compactLayout,
onMarkRead: { notification in
Task { await model.markNotificationRead(notification) }
if !earlierRequests.isEmpty {
Section {
ForEach(earlierRequests) { request in
row(for: request, compact: true)
.transition(.move(edge: .top).combined(with: .opacity))
}
)
}
}
}
}
}
struct AccountPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if let profile = model.profile, let session = model.session {
AccountHero(profile: profile, session: session, compactLayout: compactLayout)
AppSectionCard(title: "Session", compactLayout: compactLayout) {
AccountFactsGrid(profile: profile, session: session, compactLayout: compactLayout)
}
}
AppSectionCard(title: "Pairing payload", compactLayout: compactLayout) {
AppTextSurface(text: model.suggestedPairingPayload, monospaced: true)
}
AppSectionCard(title: "Actions", compactLayout: compactLayout) {
Button(role: .destructive) {
model.signOut()
} label: {
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
}
.buttonStyle(.bordered)
}
}
}
}
private struct OverviewHero: View {
let profile: MemberProfile
let session: AuthSession
let pendingCount: Int
let unreadCount: Int
let compactLayout: Bool
private var detailColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
}
private var metricColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 16), count: 3)
}
var body: some View {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "Digital passport", tone: dashboardAccent)
VStack(alignment: .leading, spacing: 6) {
Text(profile.name)
.font(.system(size: compactLayout ? 30 : 38, weight: .bold, design: .rounded))
.lineLimit(2)
Text("\(profile.handle)\(profile.organization)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
AppStatusTag(title: "Passport active", tone: dashboardAccent)
AppStatusTag(title: session.pairingTransport.title, tone: dashboardGold)
}
Divider()
LazyVGrid(columns: detailColumns, alignment: .leading, spacing: 16) {
AppKeyValue(label: "Device", value: session.deviceName)
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
}
Divider()
LazyVGrid(columns: metricColumns, alignment: .leading, spacing: 16) {
AppMetric(title: "Pending", value: "\(pendingCount)")
AppMetric(title: "Alerts", value: "\(unreadCount)")
AppMetric(title: "Devices", value: "\(profile.deviceCount)")
}
}
}
}
private struct NotificationPermissionSummary: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: model.notificationPermission.systemImage)
.font(.headline)
.foregroundStyle(dashboardAccent)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) {
Text(model.notificationPermission.title)
.font(.headline)
Text(model.notificationPermission.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if compactLayout {
VStack(alignment: .leading, spacing: 12) {
permissionButtons
}
} else {
HStack(spacing: 12) {
permissionButtons
} header: {
Text("Earlier today")
.textCase(nil)
}
}
}
}
.listStyle(.plain)
.navigationTitle("Inbox")
.animation(.spring(response: 0.35, dampingFraction: 0.88), value: filteredRequests.map(\.id))
.idpSearchable(text: $searchText, isPresented: $isSearchPresented)
}
@ViewBuilder
private var permissionButtons: some View {
Button {
Task { await model.requestNotificationAccess() }
} label: {
Label("Enable notifications", systemImage: "bell.and.waves.left.and.right.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
Task { await model.sendTestNotification() }
} label: {
Label("Send test alert", systemImage: "paperplane.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
private struct AccountHero: View {
let profile: MemberProfile
let session: AuthSession
let compactLayout: Bool
var body: some View {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "Account", tone: dashboardAccent)
Text(profile.name)
.font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded))
.lineLimit(2)
Text(profile.handle)
.font(.headline)
.foregroundStyle(.secondary)
Text("Active client: \(session.deviceName)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
private struct AccountFactsGrid: View {
let profile: MemberProfile
let session: AuthSession
let compactLayout: Bool
private var columns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
}
var body: some View {
LazyVGrid(columns: columns, alignment: .leading, spacing: 16) {
AppKeyValue(label: "Organization", value: profile.organization)
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
AppKeyValue(label: "Linked At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
AppKeyValue(label: "Method", value: session.pairingTransport.title)
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
AppKeyValue(label: "Recovery", value: profile.recoverySummary)
if let signedGPSPosition = session.signedGPSPosition {
AppKeyValue(
label: "Signed GPS",
value: "\(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)",
monospaced: true
private func row(for request: ApprovalRequest, compact: Bool) -> some View {
if usesSelection {
Button {
selectedRequestID = request.id
Haptics.selection()
} label: {
ApprovalRow(
request: request,
handle: model.profile?.handle ?? "@you",
compact: compact,
highlighted: highlightedRequestID == request.id
)
}
AppKeyValue(label: "Trusted Devices", value: "\(profile.deviceCount)")
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
} else {
NavigationLink(value: request.id) {
ApprovalRow(
request: request,
handle: model.profile?.handle ?? "@you",
compact: compact,
highlighted: highlightedRequestID == request.id
)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
}
}
private struct EmptyStateCopy: View {
let title: String
let systemImage: String
let message: String
struct NotificationCenterView: View {
@ObservedObject var model: AppViewModel
private var groupedNotifications: [(String, [AppNotification])] {
let calendar = Calendar.current
let groups = Dictionary(grouping: model.notifications) { calendar.startOfDay(for: $0.sentAt) }
return groups
.keys
.sorted(by: >)
.map { day in
(sectionTitle(for: day), groups[day]?.sorted(by: { $0.sentAt > $1.sentAt }) ?? [])
}
}
var body: some View {
ContentUnavailableView(
title,
systemImage: systemImage,
description: Text(message)
)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
Group {
if model.notifications.isEmpty {
EmptyPaneView(
title: "All clear",
message: "You'll see new sign-in requests here.",
systemImage: "shield"
)
} else {
List {
if model.notificationPermission == .unknown || model.notificationPermission == .denied {
NotificationPermissionCard(model: model)
.listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
ForEach(groupedNotifications, id: \.0) { section in
Section {
ForEach(section.1) { notification in
Button {
guard notification.isUnread else { return }
Task {
await model.markNotificationRead(notification)
}
} label: {
NotificationEventRow(notification: notification)
}
.buttonStyle(.plain)
}
} header: {
Text(section.0)
.textCase(nil)
}
}
}
.listStyle(.plain)
}
}
.navigationTitle("Notifications")
}
private func sectionTitle(for date: Date) -> String {
if Calendar.current.isDateInToday(date) {
return "Today"
}
if Calendar.current.isDateInYesterday(date) {
return "Yesterday"
}
return date.formatted(.dateTime.month(.wide).day())
}
}
struct DevicesView: View {
@ObservedObject var model: AppViewModel
@State private var isPairingCodePresented = false
private var devices: [DevicePresentation] {
guard let session else { return [] }
let current = DevicePresentation(
name: session.deviceName,
systemImage: symbolName(for: session.deviceName),
lastSeen: .now,
isCurrent: true,
isTrusted: true
)
let others = [
DevicePresentation(name: "Phil's iPad Pro", systemImage: "ipad", lastSeen: .now.addingTimeInterval(-60 * 18), isCurrent: false, isTrusted: true),
DevicePresentation(name: "Berlin MacBook Pro", systemImage: "laptopcomputer", lastSeen: .now.addingTimeInterval(-60 * 74), isCurrent: false, isTrusted: true),
DevicePresentation(name: "Apple Watch", systemImage: "applewatch", lastSeen: .now.addingTimeInterval(-60 * 180), isCurrent: false, isTrusted: false)
]
let count = max((model.profile?.deviceCount ?? 1) - 1, 0)
return [current] + Array(others.prefix(count))
}
private var session: AuthSession? {
model.session
}
var body: some View {
Form {
Section("This device") {
if let current = devices.first {
DeviceItemRow(device: current)
}
}
Section("Other devices · \(max(devices.count - 1, 0))") {
ForEach(Array(devices.dropFirst())) { device in
DeviceItemRow(device: device)
}
}
Section {
VStack(spacing: 12) {
Button("Pair another device") {
isPairingCodePresented = true
}
.buttonStyle(PrimaryActionStyle())
Button("Sign out everywhere") {
model.signOut()
}
.buttonStyle(DestructiveStyle())
}
.padding(.vertical, 6)
}
}
.navigationTitle("Devices")
.sheet(isPresented: $isPairingCodePresented) {
if let session {
OneTimePasscodeSheet(session: session)
}
}
}
private func symbolName(for deviceName: String) -> String {
let lowercased = deviceName.lowercased()
if lowercased.contains("ipad") {
return "ipad"
}
if lowercased.contains("watch") {
return "applewatch"
}
if lowercased.contains("mac") || lowercased.contains("safari") {
return "laptopcomputer"
}
return "iphone"
}
}
struct IdentityView: View {
@ObservedObject var model: AppViewModel
var body: some View {
Form {
if let profile = model.profile {
Section("Identity") {
LabeledContent("Name", value: profile.name)
LabeledContent("Handle", value: profile.handle)
LabeledContent("Organization", value: profile.organization)
}
Section("Recovery") {
Text(profile.recoverySummary)
.font(.body)
}
}
if let session = model.session {
Section("Session") {
LabeledContent("Device", value: session.deviceName)
LabeledContent("Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
LabeledContent("Origin", value: session.originHost)
LabeledContent("Transport", value: session.pairingTransport.title)
}
Section("Pairing payload") {
Text(session.pairingCode)
.font(.footnote.monospaced())
.textSelection(.enabled)
}
}
}
.navigationTitle("Identity")
}
}
struct SettingsView: View {
@ObservedObject var model: AppViewModel
var body: some View {
Form {
Section("Alerts") {
LabeledContent("Notifications", value: model.notificationPermission.title)
Button("Enable Notifications") {
Task {
await model.requestNotificationAccess()
}
}
.buttonStyle(SecondaryActionStyle())
Button("Send Test Notification") {
Task {
await model.sendTestNotification()
}
}
.buttonStyle(SecondaryActionStyle())
}
Section("Demo") {
Button("Simulate Incoming Request") {
Task {
await model.simulateIncomingRequest()
}
}
.buttonStyle(PrimaryActionStyle())
Button("Refresh") {
Task {
await model.refreshDashboard()
}
}
.buttonStyle(SecondaryActionStyle())
}
}
.navigationTitle("Settings")
}
}
enum PreviewFixtures {
static let profile = MemberProfile(
name: "Jurgen Meyer",
handle: "@jurgen",
organization: "idp.global",
deviceCount: 4,
recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified."
)
static let session = AuthSession(
deviceName: "iPhone 17 Pro",
originHost: "github.com",
pairedAt: .now.addingTimeInterval(-60 * 90),
tokenPreview: "berlin",
pairingCode: "idp.global://pair?token=swiftapp-demo-berlin&origin=github.com&device=iPhone%2017%20Pro",
pairingTransport: .preview
)
static let requests: [ApprovalRequest] = [
ApprovalRequest(
title: "Prove identity for GitHub",
subtitle: "GitHub is asking for a routine sign-in confirmation.",
source: "github.com",
createdAt: .now.addingTimeInterval(-60 * 4),
kind: .signIn,
risk: .routine,
scopes: ["email", "profile", "session:read"],
status: .pending
),
ApprovalRequest(
title: "Prove identity for workspace",
subtitle: "Your secure workspace needs a stronger proof before unlocking.",
source: "workspace.idp.global",
createdAt: .now.addingTimeInterval(-60 * 42),
kind: .elevatedAction,
risk: .elevated,
scopes: ["profile", "device", "location"],
status: .pending
),
ApprovalRequest(
title: "CLI session",
subtitle: "A CLI login was completed earlier today.",
source: "cli.idp.global",
createdAt: .now.addingTimeInterval(-60 * 120),
kind: .signIn,
risk: .routine,
scopes: ["profile"],
status: .approved
)
]
static let notifications: [AppNotification] = [
AppNotification(
title: "GitHub sign-in approved",
message: "Your latest sign-in request for github.com was approved.",
sentAt: .now.addingTimeInterval(-60 * 9),
kind: .approval,
isUnread: true
),
AppNotification(
title: "Recovery check passed",
message: "Backup recovery channels were verified in the last 24 hours.",
sentAt: .now.addingTimeInterval(-60 * 110),
kind: .system,
isUnread: false
),
AppNotification(
title: "Session expired",
message: "A pending workstation approval expired before it could be completed.",
sentAt: .now.addingTimeInterval(-60 * 1_500),
kind: .security,
isUnread: false
)
]
@MainActor
static func model() -> AppViewModel {
let model = AppViewModel(
service: MockIDPService.shared,
notificationCoordinator: PreviewNotificationCoordinator(),
appStateStore: PreviewStateStore(),
launchArguments: []
)
model.session = session
model.profile = profile
model.requests = requests
model.notifications = notifications
model.selectedSection = .inbox
model.manualPairingPayload = session.pairingCode
model.suggestedPairingPayload = session.pairingCode
model.notificationPermission = .allowed
return model
}
}
private struct PreviewNotificationCoordinator: NotificationCoordinating {
func authorizationStatus() async -> NotificationPermissionState { .allowed }
func requestAuthorization() async throws -> NotificationPermissionState { .allowed }
func scheduleTestNotification(title: String, body: String) async throws {}
}
private struct PreviewStateStore: AppStateStoring {
func load() -> PersistedAppState? { nil }
func save(_ state: PersistedAppState) {}
func clear() {}
}
#Preview("Inbox Light") {
NavigationStack {
InboxPreviewHost()
}
}
#Preview("Inbox Dark") {
NavigationStack {
InboxPreviewHost()
}
.preferredColorScheme(.dark)
}
@MainActor
private struct InboxPreviewHost: View {
@State private var selectedRequestID = PreviewFixtures.requests.first?.id
@State private var searchText = ""
@State private var isSearchPresented = false
@State private var model = PreviewFixtures.model()
var body: some View {
InboxListView(
model: model,
selectedRequestID: $selectedRequestID,
searchText: $searchText,
isSearchPresented: $isSearchPresented
)
}
}

View File

@@ -1,78 +1,73 @@
import SwiftUI
let dashboardAccent = AppTheme.accent
let dashboardGold = AppTheme.warmAccent
extension View {
@ViewBuilder
func inlineNavigationTitleOnIOS() -> some View {
#if os(iOS)
navigationBarTitleDisplayMode(.inline)
#else
self
#endif
}
@ViewBuilder
func cleanTabBarOnIOS() -> some View {
#if os(iOS)
toolbarBackground(.visible, for: .tabBar)
.toolbarBackground(AppTheme.chromeFill, for: .tabBar)
#else
self
#endif
}
}
struct HomeRootView: View {
@ObservedObject var model: AppViewModel
@State private var notificationBellFrame: CGRect?
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var selectedRequestID: ApprovalRequest.ID?
@State private var searchText = ""
@State private var isSearchPresented = false
var body: some View {
Group {
if usesCompactNavigation {
CompactHomeContainer(model: model)
} else {
RegularHomeContainer(model: model)
}
}
.onPreferenceChange(NotificationBellFrameKey.self) { notificationBellFrame = $0 }
.overlay(alignment: .topLeading) {
if usesCompactNavigation {
NotificationBellBadgeOverlay(
unreadCount: model.unreadNotificationCount,
bellFrame: notificationBellFrame
if usesRegularNavigation {
RegularHomeContainer(
model: model,
selectedRequestID: $selectedRequestID,
searchText: $searchText,
isSearchPresented: $isSearchPresented
)
} else {
CompactHomeContainer(
model: model,
selectedRequestID: $selectedRequestID,
searchText: $searchText,
isSearchPresented: $isSearchPresented
)
.ignoresSafeArea()
}
}
.sheet(isPresented: $model.isNotificationCenterPresented) {
NotificationCenterSheet(model: model)
.onAppear(perform: syncSelection)
.onChange(of: model.requests.map(\.id)) { _, _ in
syncSelection()
}
}
private var usesCompactNavigation: Bool {
private var usesRegularNavigation: Bool {
#if os(iOS)
true
horizontalSizeClass == .regular
#else
false
#endif
}
private func syncSelection() {
if let selectedRequestID,
model.requests.contains(where: { $0.id == selectedRequestID }) {
return
}
selectedRequestID = model.pendingRequests.first?.id ?? model.requests.first?.id
}
}
private struct CompactHomeContainer: View {
@ObservedObject var model: AppViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Binding var selectedRequestID: ApprovalRequest.ID?
@Binding var searchText: String
@Binding var isSearchPresented: Bool
var body: some View {
TabView(selection: $model.selectedSection) {
ForEach(AppSection.allCases) { section in
NavigationStack {
HomeSectionScreen(model: model, section: section, compactLayout: compactLayout)
.navigationTitle(section.title)
.inlineNavigationTitleOnIOS()
sectionContent(for: section)
.navigationDestination(for: ApprovalRequest.ID.self) { requestID in
ApprovalDetailView(model: model, requestID: requestID, dismissOnResolve: true)
}
.toolbar {
DashboardToolbar(model: model)
if section == .inbox {
InboxToolbar(model: model, isSearchPresented: $isSearchPresented)
}
}
}
.tag(section)
@@ -81,239 +76,130 @@ private struct CompactHomeContainer: View {
}
}
}
.cleanTabBarOnIOS()
.idpTabBarChrome()
}
private var compactLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
@ViewBuilder
private func sectionContent(for section: AppSection) -> some View {
switch section {
case .inbox:
InboxListView(
model: model,
selectedRequestID: $selectedRequestID,
searchText: $searchText,
isSearchPresented: $isSearchPresented,
usesSelection: false
)
case .notifications:
NotificationCenterView(model: model)
case .devices:
DevicesView(model: model)
case .identity:
IdentityView(model: model)
case .settings:
SettingsView(model: model)
}
}
}
private struct RegularHomeContainer: View {
@ObservedObject var model: AppViewModel
@Binding var selectedRequestID: ApprovalRequest.ID?
@Binding var searchText: String
@Binding var isSearchPresented: Bool
var body: some View {
NavigationSplitView {
Sidebar(model: model)
.navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 320)
SidebarView(model: model)
.navigationSplitViewColumnWidth(min: 250, ideal: 280, max: 320)
} content: {
contentColumn
} detail: {
HomeSectionScreen(model: model, section: model.selectedSection, compactLayout: false)
.navigationTitle(model.selectedSection.title)
.toolbar {
DashboardToolbar(model: model)
}
detailColumn
}
.navigationSplitViewStyle(.balanced)
}
}
private struct DashboardToolbar: ToolbarContent {
@ObservedObject var model: AppViewModel
var body: some ToolbarContent {
ToolbarItemGroup(placement: .primaryAction) {
NotificationBellButton(model: model)
}
}
}
struct NotificationBellFrameKey: PreferenceKey {
static var defaultValue: CGRect? = nil
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
value = nextValue() ?? value
}
}
private struct NotificationBellBadgeOverlay: View {
let unreadCount: Int
let bellFrame: CGRect?
var body: some View {
GeometryReader { proxy in
if unreadCount > 0, let bellFrame {
let rootFrame = proxy.frame(in: .global)
Text("\(min(unreadCount, 9))")
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.frame(minWidth: 18, minHeight: 18)
.padding(.horizontal, 3)
.background(Color.orange, in: Capsule())
.position(
x: bellFrame.maxX - rootFrame.minX - 2,
y: bellFrame.minY - rootFrame.minY + 2
)
}
}
.allowsHitTesting(false)
}
}
private struct HomeSectionScreen: View {
@ObservedObject var model: AppViewModel
let section: AppSection
let compactLayout: Bool
@State private var focusedRequest: ApprovalRequest?
@State private var isOTPPresented = false
@StateObject private var identifyReader = NFCIdentifyReader()
var body: some View {
AppScrollScreen(
compactLayout: compactLayout,
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
) {
HomeTopActions(
@ViewBuilder
private var contentColumn: some View {
switch model.selectedSection {
case .inbox:
InboxListView(
model: model,
identifyReader: identifyReader,
onScanQR: { model.isScannerPresented = true },
onShowOTP: { isOTPPresented = true }
selectedRequestID: $selectedRequestID,
searchText: $searchText,
isSearchPresented: $isSearchPresented,
usesSelection: true
)
.toolbar {
InboxToolbar(model: model, isSearchPresented: $isSearchPresented)
}
case .notifications:
NotificationCenterView(model: model)
case .devices:
DevicesView(model: model)
case .identity:
IdentityView(model: model)
case .settings:
SettingsView(model: model)
}
}
switch section {
case .overview:
OverviewPanel(model: model, compactLayout: compactLayout)
case .requests:
RequestsPanel(model: model, compactLayout: compactLayout, onOpenRequest: { focusedRequest = $0 })
case .activity:
ActivityPanel(model: model, compactLayout: compactLayout)
case .account:
AccountPanel(model: model, compactLayout: compactLayout)
}
}
.task {
identifyReader.onAuthenticationRequestDetected = { request in
Task {
await model.identifyWithNFC(request)
}
}
identifyReader.onError = { message in
model.errorMessage = message
}
}
.sheet(item: $focusedRequest) { request in
RequestDetailSheet(request: request, model: model)
}
.sheet(isPresented: $model.isScannerPresented) {
QRScannerSheet(
seededPayload: model.session?.pairingCode ?? model.suggestedPairingPayload,
title: "Scan proof QR",
description: "Use the camera to scan an idp.global QR challenge from the site or device asking you to prove that it is really you.",
navigationTitle: "Scan Proof QR",
onCodeScanned: { payload in
Task {
await model.identifyWithPayload(payload, transport: .qr)
}
}
@ViewBuilder
private var detailColumn: some View {
switch model.selectedSection {
case .inbox:
ApprovalDetailView(model: model, requestID: selectedRequestID)
case .notifications:
EmptyPaneView(
title: "Notification history",
message: "Select the inbox to review request context side by side.",
systemImage: "bell"
)
case .devices:
EmptyPaneView(
title: "Trusted hardware",
message: "Device trust and last-seen state appear here while you manage your passport.",
systemImage: "desktopcomputer"
)
case .identity:
EmptyPaneView(
title: "Identity overview",
message: "Your profile, recovery status, and pairing state stay visible here.",
systemImage: "person.crop.rectangle.stack"
)
case .settings:
EmptyPaneView(
title: "Preferences",
message: "Notification delivery and demo controls live in settings.",
systemImage: "gearshape"
)
}
.sheet(isPresented: $isOTPPresented) {
if let session = model.session {
OneTimePasscodeSheet(session: session)
}
}
}
}
private struct HomeTopActions: View {
@ObservedObject var model: AppViewModel
@ObservedObject var identifyReader: NFCIdentifyReader
let onScanQR: () -> Void
let onShowOTP: () -> Void
var body: some View {
LazyVGrid(columns: columns, spacing: 12) {
identifyButton
qrButton
otpButton
}
}
private var columns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 12), count: 3)
}
private var identifyButton: some View {
Button {
identifyReader.beginScanning()
} label: {
AppActionTile(
title: identifyReader.isScanning ? "Scanning NFC" : "Tap NFC",
systemImage: "dot.radiowaves.left.and.right",
tone: dashboardAccent,
isBusy: identifyReader.isScanning || model.isIdentifying
)
}
.buttonStyle(.plain)
.disabled(identifyReader.isScanning || !identifyReader.isSupported || model.isIdentifying)
}
private var qrButton: some View {
Button {
onScanQR()
} label: {
AppActionTile(
title: "Scan QR",
systemImage: "qrcode.viewfinder",
tone: dashboardAccent
)
}
.buttonStyle(.plain)
}
private var otpButton: some View {
Button {
onShowOTP()
} label: {
AppActionTile(
title: "OTP",
systemImage: "number.square.fill",
tone: dashboardGold
)
}
.buttonStyle(.plain)
}
}
private struct Sidebar: View {
struct SidebarView: View {
@ObservedObject var model: AppViewModel
var body: some View {
List {
Section {
SidebarStatusCard(
profile: model.profile,
pendingCount: model.pendingRequests.count,
unreadCount: model.unreadNotificationCount
)
}
Section("Workspace") {
ForEach(AppSection.allCases) { section in
Button {
model.selectedSection = section
} label: {
HStack {
Label(section.title, systemImage: section.systemImage)
Spacer()
if badgeCount(for: section) > 0 {
AppStatusTag(title: "\(badgeCount(for: section))", tone: dashboardAccent)
}
ForEach(Array(AppSection.allCases.enumerated()), id: \.element.id) { index, section in
Button {
model.selectedSection = section
Haptics.selection()
} label: {
HStack(spacing: 12) {
Label(section.title, systemImage: section.systemImage)
Spacer()
if badgeCount(for: section) > 0 {
StatusPill(title: "\(badgeCount(for: section))", color: IdP.tint)
}
}
.buttonStyle(.plain)
.listRowBackground(
model.selectedSection == section
? dashboardAccent.opacity(0.10)
: Color.clear
)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
.listRowBackground(model.selectedSection == section ? IdP.tint.opacity(0.08) : Color.clear)
.keyboardShortcut(shortcut(for: index), modifiers: .command)
}
}
.navigationTitle("idp.global")
@@ -321,36 +207,57 @@ private struct Sidebar: View {
private func badgeCount(for section: AppSection) -> Int {
switch section {
case .overview:
0
case .requests:
case .inbox:
model.pendingRequests.count
case .activity:
case .notifications:
model.unreadNotificationCount
case .account:
case .devices:
max((model.profile?.deviceCount ?? 1) - 1, 0)
case .identity, .settings:
0
}
}
}
private struct SidebarStatusCard: View {
let profile: MemberProfile?
let pendingCount: Int
let unreadCount: Int
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Digital Passport")
.font(.headline)
Text(profile?.handle ?? "No passport active")
.foregroundStyle(.secondary)
HStack(spacing: 8) {
AppStatusTag(title: "\(pendingCount) pending", tone: dashboardAccent)
AppStatusTag(title: "\(unreadCount) unread", tone: dashboardGold)
}
}
.padding(.vertical, 6)
private func shortcut(for index: Int) -> KeyEquivalent {
let value = max(1, min(index + 1, 9))
return KeyEquivalent(Character("\(value)"))
}
}
private struct InboxToolbar: ToolbarContent {
@ObservedObject var model: AppViewModel
@Binding var isSearchPresented: Bool
var body: some ToolbarContent {
ToolbarItem(placement: .idpTrailingToolbar) {
HStack(spacing: 8) {
Button {
isSearchPresented = true
} label: {
Image(systemName: "magnifyingglass")
.font(.headline)
.foregroundStyle(.primary)
}
.accessibilityLabel("Search inbox")
Button {
model.selectedSection = .identity
} label: {
MonogramAvatar(title: model.profile?.name ?? "idp.global", size: 28)
}
.accessibilityLabel("Open identity")
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.clear)
.idpGlassChrome()
)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color.white.opacity(0.16), lineWidth: 1)
)
}
}
}

View File

@@ -1,122 +1,299 @@
import SwiftUI
struct ApprovalDetailView: View {
@ObservedObject var model: AppViewModel
let requestID: ApprovalRequest.ID?
var dismissOnResolve = false
@Environment(\.dismiss) private var dismiss
private var request: ApprovalRequest? {
guard let requestID else { return nil }
return model.requests.first(where: { $0.id == requestID })
}
var body: some View {
Group {
if let request {
VStack(spacing: 0) {
RequestHeroCard(
request: request,
handle: model.profile?.handle ?? "@you"
)
.padding(.horizontal, 16)
.padding(.top, 16)
Form {
Section("Context") {
LabeledContent("From device", value: request.deviceSummary)
LabeledContent("Location", value: request.locationSummary)
LabeledContent("Network", value: request.networkSummary)
LabeledContent("IP") {
Text(request.ipSummary)
.monospacedDigit()
}
}
Section("Will share") {
ForEach(request.scopes, id: \.self) { scope in
Label(scope, systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
Section("Trust signals") {
TrustSignalBanner(request: request)
}
}
.scrollContentBackground(.hidden)
.background(Color.idpGroupedBackground)
}
.background(Color.idpGroupedBackground)
.navigationTitle(request.appDisplayName)
.idpInlineNavigationTitle()
.toolbar {
ToolbarItem(placement: .idpTrailingToolbar) {
IdPGlassCapsule(padding: EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) {
Text(request.expiresAt, style: .timer)
.font(.caption.weight(.semibold))
.monospacedDigit()
}
}
}
.safeAreaInset(edge: .bottom) {
if request.status == .pending {
HStack(spacing: 12) {
Button("Deny") {
Task {
await performReject(request)
}
}
.buttonStyle(SecondaryActionStyle())
HoldToApproveButton(isBusy: model.activeRequestID == request.id) {
await performApprove(request)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background {
Rectangle()
.fill(.clear)
.idpGlassChrome()
}
}
}
.background {
keyboardShortcuts(for: request)
}
} else {
EmptyPaneView(
title: "Nothing selected",
message: "Choose a sign-in request from the inbox to review the full context.",
systemImage: "checkmark.circle"
)
}
}
}
@ViewBuilder
private func keyboardShortcuts(for request: ApprovalRequest) -> some View {
Group {
Button("Approve") {
Task {
await performApprove(request)
}
}
.keyboardShortcut(.return, modifiers: .command)
.hidden()
.accessibilityHidden(true)
Button("Deny") {
Task {
await performReject(request)
}
}
.keyboardShortcut(.delete, modifiers: .command)
.hidden()
.accessibilityHidden(true)
}
}
private func performApprove(_ request: ApprovalRequest) async {
guard model.activeRequestID != request.id else { return }
await model.approve(request)
if dismissOnResolve {
dismiss()
}
}
private func performReject(_ request: ApprovalRequest) async {
guard model.activeRequestID != request.id else { return }
Haptics.warning()
await model.reject(request)
if dismissOnResolve {
dismiss()
}
}
}
struct RequestDetailSheet: View {
let request: ApprovalRequest
@ObservedObject var model: AppViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
AppScrollScreen(
compactLayout: true,
bottomPadding: AppLayout.compactBottomDockPadding
) {
RequestDetailHero(request: request)
AppSectionCard(title: "Summary", compactLayout: true) {
AppKeyValue(label: "Source", value: request.source)
AppKeyValue(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened))
AppKeyValue(label: "Risk", value: request.risk.summary)
AppKeyValue(label: "Type", value: request.kind.title)
}
AppSectionCard(title: "Proof details", compactLayout: true) {
if request.scopes.isEmpty {
Text("No explicit proof details were provided by the mock backend.")
.foregroundStyle(.secondary)
} else {
Text(request.scopes.joined(separator: "\n"))
.font(.body.monospaced())
.foregroundStyle(.secondary)
}
}
AppSectionCard(title: "Guidance", compactLayout: true) {
Text(request.trustDetail)
.foregroundStyle(.secondary)
Text(request.risk.guidance)
.font(.headline)
}
if request.status == .pending {
AppSectionCard(title: "Actions", compactLayout: true) {
VStack(spacing: 12) {
Button {
Task {
await model.approve(request)
dismiss()
}
} label: {
if model.activeRequestID == request.id {
ProgressView()
} else {
Label("Verify identity", systemImage: "checkmark.circle.fill")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.disabled(model.activeRequestID == request.id)
Button(role: .destructive) {
Task {
await model.reject(request)
dismiss()
}
} label: {
Label("Decline", systemImage: "xmark.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(model.activeRequestID == request.id)
}
}
}
}
.navigationTitle("Review Proof")
.inlineNavigationTitleOnIOS()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
ApprovalDetailView(model: model, requestID: request.id, dismissOnResolve: true)
}
}
}
private struct RequestDetailHero: View {
let request: ApprovalRequest
struct HoldToApproveButton: View {
var title = "Hold to approve"
var isBusy = false
let action: () async -> Void
private var accent: Color {
switch request.status {
case .approved:
.green
case .rejected:
.red
case .pending:
request.risk == .routine ? dashboardAccent : .orange
@State private var progress: CGFloat = 0
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.fill(isBusy ? Color.secondary.opacity(0.24) : IdP.tint)
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.stroke(Color.white.opacity(0.16), lineWidth: 1)
label
.padding(.horizontal, 20)
.padding(.vertical, 14)
GeometryReader { geometry in
RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.trim(from: 0, to: progress)
.stroke(Color.white.opacity(0.85), style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
.padding(2)
}
}
.frame(minHeight: 52)
.contentShape(RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous))
.onLongPressGesture(minimumDuration: 0.6, maximumDistance: 20, pressing: updateProgress) {
guard !isBusy else { return }
Task {
Haptics.success()
await action()
progress = 0
}
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(title)
.accessibilityHint("Press and hold to approve this request.")
}
@ViewBuilder
private var label: some View {
if isBusy {
ProgressView()
.tint(.white)
} else {
Text(title)
.font(.headline)
.foregroundStyle(.white)
}
}
private func updateProgress(_ isPressing: Bool) {
guard !isBusy else { return }
withAnimation(.linear(duration: isPressing ? 0.6 : 0.15)) {
progress = isPressing ? 1 : 0
}
}
}
struct NFCSheet: View {
var title = "Hold near reader"
var message = "Tap to confirm sign-in. Your location will be signed and sent."
var actionTitle = "Approve"
let onSubmit: (PairingAuthenticationRequest) async -> Void
@Environment(\.dismiss) private var dismiss
@StateObject private var reader = NFCIdentifyReader()
@State private var pendingRequest: PairingAuthenticationRequest?
@State private var isSubmitting = false
@State private var pulse = false
private var isPreview: Bool {
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}
var body: some View {
AppPanel(compactLayout: true, radius: AppLayout.largeCardRadius) {
AppBadge(title: request.kind.title, tone: accent)
VStack(spacing: 24) {
ZStack {
ForEach(0..<3, id: \.self) { index in
Circle()
.stroke(IdP.tint.opacity(0.16), lineWidth: 1.5)
.frame(width: 88 + CGFloat(index * 34), height: 88 + CGFloat(index * 34))
.scaleEffect(pulse ? 1.08 : 0.92)
.opacity(pulse ? 0.2 : 0.6)
.animation(.easeInOut(duration: 1.4).repeatForever().delay(Double(index) * 0.12), value: pulse)
}
Text(request.title)
.font(.system(size: 30, weight: .bold, design: .rounded))
.lineLimit(3)
Image(systemName: "wave.3.right")
.font(.system(size: 34, weight: .semibold))
.foregroundStyle(IdP.tint)
}
.frame(height: 160)
Text(request.subtitle)
.foregroundStyle(.secondary)
VStack(spacing: 8) {
Text(title)
.font(.title3.weight(.semibold))
HStack(spacing: 8) {
AppStatusTag(title: request.status.title, tone: accent)
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
VStack(spacing: 12) {
Button("Cancel") {
dismiss()
}
.buttonStyle(SecondaryActionStyle())
Button(primaryTitle) {
guard let pendingRequest else { return }
Task {
isSubmitting = true
await onSubmit(pendingRequest)
isSubmitting = false
dismiss()
}
}
.buttonStyle(PrimaryActionStyle())
.disabled(pendingRequest == nil || isSubmitting)
}
}
.padding(24)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
.task {
pulse = true
reader.onAuthenticationRequestDetected = { request in
pendingRequest = request
Haptics.selection()
}
reader.onError = { _ in }
guard !isPreview else { return }
reader.beginScanning()
}
}
private var primaryTitle: String {
if isSubmitting {
return "Approving…"
}
return pendingRequest == nil ? "Waiting…" : actionTitle
}
}
@@ -124,7 +301,6 @@ struct OneTimePasscodeSheet: View {
let session: AuthSession
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
NavigationStack {
@@ -132,42 +308,32 @@ struct OneTimePasscodeSheet: View {
let code = OneTimePasscodeGenerator.code(for: session.pairingCode, at: context.date)
let secondsRemaining = OneTimePasscodeGenerator.renewalCountdown(at: context.date)
AppScrollScreen(compactLayout: compactLayout) {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "One-time passcode", tone: dashboardGold)
VStack(alignment: .leading, spacing: 18) {
Text("One-time pairing code")
.font(.title3.weight(.semibold))
Text("OTP")
.font(.system(size: compactLayout ? 32 : 40, weight: .bold, design: .rounded))
Text("Use this code on the next device you want to pair with your idp.global passport.")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Share this code only with the site or device asking you to prove that it is really you.")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(code)
.font(.system(size: 42, weight: .bold, design: .rounded).monospacedDigit())
.tracking(5)
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(Color.idpSecondaryGroupedBackground, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
Text(code)
.font(.system(size: compactLayout ? 42 : 54, weight: .bold, design: .rounded).monospacedDigit())
.tracking(compactLayout ? 4 : 6)
.frame(maxWidth: .infinity)
.padding(.vertical, compactLayout ? 16 : 20)
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
HStack(spacing: 8) {
AppStatusTag(title: "Renews in \(secondsRemaining)s", tone: dashboardGold)
AppStatusTag(title: session.originHost, tone: dashboardAccent)
}
Divider()
AppKeyValue(label: "Client", value: session.deviceName)
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
HStack {
StatusPill(title: "Renews in \(secondsRemaining)s", color: IdP.tint)
StatusPill(title: session.originHost, color: .secondary)
}
Spacer()
}
.padding(24)
}
.navigationTitle("OTP")
.inlineNavigationTitleOnIOS()
.navigationTitle("Pair Device")
.idpInlineNavigationTitle()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
@@ -177,12 +343,170 @@ struct OneTimePasscodeSheet: View {
}
}
}
}
private var compactLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
struct MenuBarPopover: View {
@ObservedObject var model: AppViewModel
@State private var notificationsPaused = false
@State private var isPairingCodePresented = false
var body: some View {
VStack(alignment: .leading, spacing: 18) {
header
if let request = model.pendingRequests.first {
RequestHeroCard(request: request, handle: model.profile?.handle ?? "@you")
} else {
EmptyPaneView(
title: "Inbox clear",
message: "New sign-in requests will appear here.",
systemImage: "shield"
)
.approvalCard()
}
if model.pendingRequests.count > 1 {
VStack(alignment: .leading, spacing: 6) {
Text("Queued")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(model.pendingRequests.dropFirst().prefix(3)) { request in
ApprovalRow(request: request, handle: model.profile?.handle ?? "@you", compact: true)
}
}
}
Divider()
VStack(spacing: 8) {
Button {
model.selectedSection = .inbox
} label: {
MenuRowLabel(title: "Open inbox", systemImage: "tray.full")
}
.buttonStyle(.plain)
.keyboardShortcut("o", modifiers: .command)
Button {
isPairingCodePresented = true
} label: {
MenuRowLabel(title: "Pair new device", systemImage: "plus.viewfinder")
}
.buttonStyle(.plain)
.keyboardShortcut("n", modifiers: .command)
Button {
notificationsPaused.toggle()
Haptics.selection()
} label: {
MenuRowLabel(title: notificationsPaused ? "Resume notifications" : "Pause notifications", systemImage: notificationsPaused ? "bell.badge" : "bell.slash")
}
.buttonStyle(.plain)
Button {
model.selectedSection = .settings
} label: {
MenuRowLabel(title: "Preferences", systemImage: "gearshape")
}
.buttonStyle(.plain)
.keyboardShortcut(",", modifiers: .command)
}
}
.padding(20)
.sheet(isPresented: $isPairingCodePresented) {
if let session = model.session {
OneTimePasscodeSheet(session: session)
}
}
}
private var header: some View {
HStack(alignment: .center, spacing: 12) {
Image(systemName: "shield.lefthalf.filled")
.font(.title2)
.foregroundStyle(IdP.tint)
VStack(alignment: .leading, spacing: 2) {
Text("idp.global")
.font(.headline)
StatusPill(title: "Connected", color: .green)
}
Spacer()
}
}
}
private struct MenuRowLabel: View {
let title: String
let systemImage: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: systemImage)
.frame(width: 18)
.foregroundStyle(IdP.tint)
Text(title)
Spacer()
}
.padding(.vertical, 6)
.contentShape(Rectangle())
}
}
#Preview("Approval Detail Light") {
NavigationStack {
ApprovalDetailPreviewHost()
}
}
#Preview("Approval Detail Dark") {
NavigationStack {
ApprovalDetailPreviewHost()
}
.preferredColorScheme(.dark)
}
#Preview("NFC Sheet Light") {
NFCSheet { _ in }
}
#Preview("NFC Sheet Dark") {
NFCSheet { _ in }
.preferredColorScheme(.dark)
}
#Preview("Request Hero Card Light") {
RequestHeroCard(request: PreviewFixtures.requests[0], handle: PreviewFixtures.profile.handle)
.padding()
}
#Preview("Request Hero Card Dark") {
RequestHeroCard(request: PreviewFixtures.requests[0], handle: PreviewFixtures.profile.handle)
.padding()
.preferredColorScheme(.dark)
}
#if os(macOS)
#Preview("Menu Bar Popover Light") {
MenuBarPopover(model: PreviewFixtures.model())
.frame(width: 420)
}
#Preview("Menu Bar Popover Dark") {
MenuBarPopover(model: PreviewFixtures.model())
.frame(width: 420)
.preferredColorScheme(.dark)
}
#endif
@MainActor
private struct ApprovalDetailPreviewHost: View {
@State private var model = PreviewFixtures.model()
var body: some View {
ApprovalDetailView(model: model, requestID: PreviewFixtures.requests.first?.id)
}
}