import SwiftUI extension ApprovalRequest { var appDisplayName: String { source .replacingOccurrences(of: "auth.", with: "") .replacingOccurrences(of: ".idp.global", with: ".idp.global") } var inboxTitle: String { "Sign in to \(appDisplayName)" } var locationSummary: String { locationSummaryText ?? "Location not required" } var deviceSummary: String { deviceSummaryText ?? "Trusted passport device" } var networkSummary: String { networkSummaryText ?? source } var ipSummary: String { ipSummaryText ?? "n/a" } 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 { expiresAtDate ?? 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 } } } 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 { 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 /// When non-nil the row renders inline Deny/Approve buttons below the /// header — matching the shadcn inbox card. var onApprove: (() -> Void)? = nil var onDeny: (() -> Void)? = nil var isBusy = false private var showsInlineActions: Bool { request.status == .pending && onApprove != nil && onDeny != nil && !compact } private var tint: Color { BrandTint.color(for: request.appDisplayName) } var body: some View { VStack(alignment: .leading, spacing: showsInlineActions ? 12 : 0) { header if showsInlineActions { actionRow } } .padding(showsInlineActions ? 14 : (compact ? 10 : 12)) .background(background) .overlay(stroke) .background(glow) .contentShape(Rectangle()) .accessibilityElement(children: .combine) .accessibilityLabel("\(request.inboxTitle), \(request.locationSummary), \(request.createdAt.formatted(date: .omitted, time: .shortened))") } private var header: some View { HStack(alignment: .top, spacing: 12) { MonogramAvatar( title: request.appDisplayName, size: compact ? 32 : 40, tint: tint, filled: true ) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(request.appDisplayName) .font(compact ? .footnote.weight(.semibold) : .subheadline.weight(.semibold)) .foregroundStyle(.primary) .lineLimit(1) Spacer(minLength: 4) Text(relativeTime) .font(.caption) .foregroundStyle(Color.idpMutedForeground) } Text(request.kind.title) .font(compact ? .caption : .footnote) .foregroundStyle(Color.idpMutedForeground) .lineLimit(1) if !compact { HStack(spacing: 8) { Label { Text(request.locationSummary) } icon: { Image(systemName: "location.fill") } .font(.caption) .foregroundStyle(Color.idpMutedForeground) .labelStyle(.titleAndIcon) ShadcnBadge( title: riskBadgeTitle, tone: riskBadgeTone ) } .padding(.top, 6) } } if !showsInlineActions && compact { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(Color.idpMutedForeground) .padding(.top, 4) } } } private var actionRow: some View { HStack(spacing: 8) { Button(action: { onDeny?() }) { Text("Deny") } .buttonStyle(SecondaryActionStyle()) .disabled(isBusy) Button(action: { onApprove?() }) { if isBusy { ProgressView().tint(Color.idpPrimaryForeground) } else { Label("Approve", systemImage: "checkmark") .labelStyle(.titleAndIcon) } } .buttonStyle(PrimaryActionStyle()) .disabled(isBusy) } } private var background: some View { RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) .fill(Color.idpCard) } private var stroke: some View { RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) .stroke(highlighted ? IdP.tint : Color.idpBorder, lineWidth: 1) } @ViewBuilder private var glow: some View { if highlighted { RoundedRectangle(cornerRadius: IdP.cardRadius + 3, style: .continuous) .fill(IdP.tint.opacity(0.10)) .padding(-3) } } private var relativeTime: String { let seconds = Int(Date.now.timeIntervalSince(request.createdAt)) if seconds < 60 { return "now" } if seconds < 3600 { return "\(seconds / 60)m" } if seconds < 86_400 { return "\(seconds / 3600)h" } return "\(seconds / 86_400)d" } private var riskBadgeTitle: String { switch (request.status, request.risk) { case (.approved, _): return "approved" case (.rejected, _): return "denied" case (.pending, .routine): return "trusted" case (.pending, .elevated): return "new network" } } private var riskBadgeTone: ShadcnBadge.Tone { switch (request.status, request.risk) { case (.approved, _): return .ok case (.rejected, _): return .danger case (.pending, .routine): return .ok case (.pending, .elevated): return .warn } } } 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) .lineLimit(2) Text(notification.message) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(2) } Spacer(minLength: 8) StatusPill(title: notification.presentationStatus.title, color: notification.presentationStatus.color) } .padding(.vertical, 8) .accessibilityElement(children: .combine) } } struct NotificationPermissionCard: View { @ObservedObject var model: AppViewModel var body: some View { VStack(alignment: .leading, spacing: 14) { Label("Allow sign-in alerts", systemImage: model.notificationPermission.systemImage) .font(.headline) Text(model.notificationPermission.summary) .font(.subheadline) .foregroundStyle(.secondary) VStack(spacing: 10) { Button("Enable Notifications") { Task { await model.requestNotificationAccess() } } .buttonStyle(PrimaryActionStyle()) Button("Send Test Alert") { Task { await model.sendTestNotification() } } .buttonStyle(SecondaryActionStyle()) } } .approvalCard() } } 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 { HStack(spacing: 12) { ZStack { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color.idpMuted) Image(systemName: device.systemImage) .font(.footnote.weight(.semibold)) .foregroundStyle(.primary) } .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 1) { Text(device.name) .font(.footnote.weight(.semibold)) Text(device.isCurrent ? "berlin · primary · this device" : "last seen \(device.lastSeen, style: .relative)") .font(.caption) .foregroundStyle(Color.idpMutedForeground) } Spacer(minLength: 8) ShadcnBadge( title: device.isTrusted ? "high" : "med", tone: device.isTrusted ? .ok : .warn ) } .deviceRowStyle() .accessibilityElement(children: .combine) } } struct TrustSignalBanner: View { let request: ApprovalRequest private var bg: Color { switch request.trustColor { case .green: return Color.idpOK.opacity(0.10) case .yellow: return Color.idpWarn.opacity(0.15) default: return Color.idpDestructive.opacity(0.10) } } private var fg: Color { switch request.trustColor { case .green: return Color.idpOK case .yellow: return Color(red: 0.52, green: 0.30, blue: 0.05) default: return Color.idpDestructive } } private var symbolName: String { switch request.trustColor { case .green: return "checkmark.shield" case .yellow: return "exclamationmark.triangle" default: return "xmark.shield" } } var body: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: symbolName) .font(.subheadline.weight(.semibold)) .foregroundStyle(fg) .padding(.top, 1) VStack(alignment: .leading, spacing: 2) { Text(request.trustHeadline) .font(.footnote.weight(.semibold)) .foregroundStyle(fg) Text(request.trustExplanation) .font(.caption) .foregroundStyle(Color.idpMutedForeground) } Spacer(minLength: 0) } .padding(12) .background(bg, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) .stroke(Color.idpBorder, lineWidth: 1) ) } } 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) } }