Files
swiftapp/swift/Sources/Features/Home/HomeCards.swift
T
jkunz 61a0cc1f7d
CI / test (push) Has been cancelled
Overhaul native approval UX and add widget surfaces
Bring the SwiftUI app in line with the Apple-native mock and keep pending approvals actionable from Live Activities and watch complications.
2026-04-19 16:29:13 +02:00

347 lines
9.7 KiB
Swift

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 {
"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
}
}
}
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
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)
.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) {
Image(systemName: device.systemImage)
.font(.headline)
.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)
}
.deviceRowStyle()
.accessibilityElement(children: .combine)
}
}
struct TrustSignalBanner: View {
let request: ApprovalRequest
var body: some View {
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)
}
}
.padding(.vertical, 8)
}
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)
}
}