Files
swiftapp/swift/Sources/Features/Home/HomeCards.swift
T

468 lines
14 KiB
Swift
Raw Normal View History

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)
}
}