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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user