2919 lines
107 KiB
Swift
2919 lines
107 KiB
Swift
import SwiftUI
|
|
|
|
private let dashboardAccent = Color(red: 0.12, green: 0.40, blue: 0.31)
|
|
private let dashboardGold = Color(red: 0.84, green: 0.71, blue: 0.48)
|
|
|
|
struct HomeRootView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
|
|
var body: some View {
|
|
Group {
|
|
if usesCompactNavigation {
|
|
CompactHomeContainer(model: model)
|
|
} else {
|
|
RegularHomeContainer(model: model)
|
|
}
|
|
}
|
|
.sheet(isPresented: $model.isNotificationCenterPresented) {
|
|
NotificationCenterSheet(model: model)
|
|
}
|
|
}
|
|
|
|
private var usesCompactNavigation: Bool {
|
|
#if os(iOS)
|
|
horizontalSizeClass == .compact
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private struct CompactHomeContainer: View {
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
var body: some View {
|
|
TabView(selection: $model.selectedSection) {
|
|
compactTab(for: .overview)
|
|
compactTab(for: .requests)
|
|
compactTab(for: .activity)
|
|
compactTab(for: .account)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func compactTab(for section: AppSection) -> some View {
|
|
NavigationStack {
|
|
HomeSectionScreen(model: model, section: section, compactLayout: true)
|
|
.navigationTitle(section.title)
|
|
.toolbar {
|
|
DashboardToolbar(model: model, compactLayout: true)
|
|
}
|
|
}
|
|
.tag(section)
|
|
.tabItem {
|
|
Label(section.title, systemImage: section.systemImage)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct RegularHomeContainer: View {
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
Sidebar(model: model)
|
|
} detail: {
|
|
HomeSectionScreen(model: model, section: model.selectedSection, compactLayout: false)
|
|
.navigationTitle(model.selectedSection.title)
|
|
.toolbar {
|
|
DashboardToolbar(model: model, compactLayout: false)
|
|
}
|
|
}
|
|
.navigationSplitViewStyle(.balanced)
|
|
}
|
|
}
|
|
|
|
private struct DashboardToolbar: ToolbarContent {
|
|
@ObservedObject var model: AppViewModel
|
|
let compactLayout: Bool
|
|
|
|
var body: some ToolbarContent {
|
|
if compactLayout {
|
|
ToolbarItemGroup(placement: .primaryAction) {
|
|
NotificationBellButton(model: model)
|
|
|
|
Menu {
|
|
Button {
|
|
Task {
|
|
await model.refreshDashboard()
|
|
}
|
|
} label: {
|
|
Label("Refresh", systemImage: "arrow.clockwise")
|
|
}
|
|
|
|
Button {
|
|
Task {
|
|
await model.simulateIncomingRequest()
|
|
}
|
|
} label: {
|
|
Label("Mock Request", systemImage: "sparkles.rectangle.stack.fill")
|
|
}
|
|
|
|
Button {
|
|
Task {
|
|
await model.sendTestNotification()
|
|
}
|
|
} label: {
|
|
Label("Send Test Alert", systemImage: "paperplane.fill")
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
}
|
|
} else {
|
|
ToolbarItemGroup(placement: .primaryAction) {
|
|
NotificationBellButton(model: model)
|
|
|
|
Button {
|
|
Task {
|
|
await model.refreshDashboard()
|
|
}
|
|
} label: {
|
|
Label("Refresh", systemImage: "arrow.clockwise")
|
|
}
|
|
.disabled(model.isRefreshing)
|
|
|
|
Button {
|
|
Task {
|
|
await model.simulateIncomingRequest()
|
|
}
|
|
} label: {
|
|
Label("Mock Request", systemImage: "sparkles.rectangle.stack.fill")
|
|
}
|
|
|
|
Button {
|
|
Task {
|
|
await model.sendTestNotification()
|
|
}
|
|
} label: {
|
|
Label("Test Alert", systemImage: "paperplane.fill")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct HomeSectionScreen: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let section: AppSection
|
|
let compactLayout: Bool
|
|
|
|
@State private var focusedRequest: ApprovalRequest?
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) {
|
|
if let banner = model.bannerMessage {
|
|
BannerCard(message: banner, compactLayout: compactLayout)
|
|
}
|
|
|
|
switch section {
|
|
case .overview:
|
|
OverviewPanel(
|
|
model: model,
|
|
compactLayout: compactLayout,
|
|
onOpenRequest: { focusedRequest = $0 }
|
|
)
|
|
case .requests:
|
|
RequestsPanel(
|
|
model: model,
|
|
compactLayout: compactLayout,
|
|
onOpenRequest: { focusedRequest = $0 }
|
|
)
|
|
case .activity:
|
|
ActivityPanel(
|
|
model: model,
|
|
compactLayout: compactLayout,
|
|
onOpenRequest: { focusedRequest = $0 }
|
|
)
|
|
case .account:
|
|
AccountPanel(model: model, compactLayout: compactLayout)
|
|
}
|
|
}
|
|
.padding(compactLayout ? 18 : 24)
|
|
.frame(maxWidth: compactLayout ? 720 : 1120, alignment: .leading)
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
.sheet(item: $focusedRequest) { request in
|
|
RequestDetailSheet(request: request, model: model)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct Sidebar: 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
|
|
sidebarRow(for: section)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("idp.global")
|
|
}
|
|
|
|
private func badgeCount(for section: AppSection) -> Int {
|
|
switch section {
|
|
case .overview:
|
|
0
|
|
case .requests:
|
|
model.pendingRequests.count
|
|
case .activity:
|
|
0
|
|
case .account:
|
|
0
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func sidebarRow(for section: AppSection) -> some View {
|
|
Button {
|
|
model.selectedSection = section
|
|
} label: {
|
|
HStack(spacing: 14) {
|
|
Image(systemName: section.systemImage)
|
|
.font(.headline)
|
|
.frame(width: 30, height: 30)
|
|
.background {
|
|
if model.selectedSection == section {
|
|
Circle()
|
|
.fill(dashboardAccent.opacity(0.18))
|
|
} else {
|
|
Circle()
|
|
.fill(.thinMaterial)
|
|
}
|
|
}
|
|
.foregroundStyle(model.selectedSection == section ? dashboardAccent : .primary)
|
|
|
|
Text(section.title)
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
if badgeCount(for: section) > 0 {
|
|
Text("\(badgeCount(for: section))")
|
|
.font(.caption.weight(.semibold))
|
|
.padding(.horizontal, 9)
|
|
.padding(.vertical, 5)
|
|
.background(.thinMaterial, in: Capsule())
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.listRowBackground(
|
|
model.selectedSection == section
|
|
? dashboardAccent.opacity(0.12)
|
|
: Color.clear
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct SidebarStatusCard: View {
|
|
let profile: MemberProfile?
|
|
let pendingCount: Int
|
|
let unreadCount: Int
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Digital Passport")
|
|
.font(.title3.weight(.semibold))
|
|
|
|
Text(profile?.handle ?? "Not paired yet")
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack(spacing: 10) {
|
|
SmallMetricPill(title: "Pending", value: "\(pendingCount)")
|
|
SmallMetricPill(title: "Unread", value: "\(unreadCount)")
|
|
}
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|
|
|
|
private struct OverviewPanel: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let compactLayout: Bool
|
|
let onOpenRequest: (ApprovalRequest) -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) {
|
|
if let profile = model.profile, let session = model.session {
|
|
OverviewHero(
|
|
profile: profile,
|
|
session: session,
|
|
pendingCount: model.pendingRequests.count,
|
|
unreadCount: model.unreadNotificationCount,
|
|
compactLayout: compactLayout
|
|
)
|
|
}
|
|
|
|
SectionCard(
|
|
title: "Quick Actions",
|
|
subtitle: "Refresh the bound session, seed a request, or test device alerts while the backend is still mocked.",
|
|
compactLayout: compactLayout
|
|
) {
|
|
QuickActionsDeck(model: model, compactLayout: compactLayout)
|
|
}
|
|
|
|
SectionCard(
|
|
title: "Requests In Focus",
|
|
subtitle: "Your passport is the identity surface. This queue is where anything asking for access should earn trust.",
|
|
compactLayout: compactLayout
|
|
) {
|
|
if model.pendingRequests.isEmpty {
|
|
EmptyStateCopy(
|
|
title: "Nothing waiting",
|
|
systemImage: "checkmark.shield.fill",
|
|
message: "Every pending approval has been handled."
|
|
)
|
|
} else {
|
|
VStack(spacing: 16) {
|
|
if let featured = model.pendingRequests.first {
|
|
FeaturedRequestCard(
|
|
request: featured,
|
|
compactLayout: compactLayout,
|
|
onOpenRequest: { onOpenRequest(featured) }
|
|
)
|
|
}
|
|
|
|
ForEach(model.pendingRequests.dropFirst().prefix(2)) { request in
|
|
RequestCard(
|
|
request: request,
|
|
compactLayout: compactLayout,
|
|
isBusy: model.activeRequestID == request.id,
|
|
onApprove: {
|
|
Task { await model.approve(request) }
|
|
},
|
|
onReject: {
|
|
Task { await model.reject(request) }
|
|
},
|
|
onOpenRequest: {
|
|
onOpenRequest(request)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SectionCard(
|
|
title: "Recent Activity",
|
|
subtitle: "Keep the full timeline in its own view, and use the bell above for alerts that need device-level attention.",
|
|
compactLayout: compactLayout
|
|
) {
|
|
ActivityPreviewCard(model: model, compactLayout: compactLayout)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ActivityPanel: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let compactLayout: Bool
|
|
let onOpenRequest: (ApprovalRequest) -> Void
|
|
|
|
@State private var selectedNotificationID: AppNotification.ID?
|
|
|
|
private var notificationIDs: [AppNotification.ID] {
|
|
model.notifications.map(\.id)
|
|
}
|
|
|
|
private var selectedNotification: AppNotification? {
|
|
if let selectedNotificationID,
|
|
let match = model.notifications.first(where: { $0.id == selectedNotificationID }) {
|
|
return match
|
|
}
|
|
|
|
return model.notifications.first
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) {
|
|
if compactLayout {
|
|
SectionCard(
|
|
title: "Recent Activity",
|
|
subtitle: "A dedicated home for approvals, pairing events, and system changes after they happen."
|
|
) {
|
|
VStack(spacing: 16) {
|
|
activityMetricRow
|
|
|
|
if model.notifications.isEmpty {
|
|
EmptyStateCopy(
|
|
title: "No activity yet",
|
|
systemImage: "clock.badge.xmark",
|
|
message: "Once requests and pairing events arrive, the timeline will fill in here."
|
|
)
|
|
} else {
|
|
ForEach(model.notifications) { notification in
|
|
NotificationCard(
|
|
notification: notification,
|
|
compactLayout: compactLayout,
|
|
onMarkRead: {
|
|
Task { await model.markNotificationRead(notification) }
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
SectionCard(
|
|
title: "Activity Timeline",
|
|
subtitle: "Review what already happened across approvals, pairing, and system state without mixing it into the notification surface."
|
|
) {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
activityMetricRow
|
|
|
|
if model.notifications.isEmpty {
|
|
EmptyStateCopy(
|
|
title: "No activity yet",
|
|
systemImage: "clock.badge.xmark",
|
|
message: "Once requests and pairing events arrive, the timeline will fill in here."
|
|
)
|
|
} else {
|
|
HStack(alignment: .top, spacing: 18) {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Timeline")
|
|
.font(.headline)
|
|
|
|
Text("The latest product and security events stay readable here, while the bell above stays focused on device notifications.")
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(spacing: 12) {
|
|
ForEach(model.notifications) { notification in
|
|
NotificationFeedRow(
|
|
notification: notification,
|
|
isSelected: notification.id == selectedNotification?.id
|
|
) {
|
|
selectedNotificationID = notification.id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: 340, alignment: .leading)
|
|
.padding(18)
|
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
|
|
|
if let notification = selectedNotification {
|
|
NotificationWorkbenchDetail(
|
|
notification: notification,
|
|
permissionState: model.notificationPermission,
|
|
onMarkRead: {
|
|
Task { await model.markNotificationRead(notification) }
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !model.handledRequests.isEmpty {
|
|
SectionCard(
|
|
title: "Handled Requests",
|
|
subtitle: "A compact audit trail for the approvals and rejections that already moved through the queue."
|
|
) {
|
|
LazyVStack(spacing: 14) {
|
|
ForEach(model.handledRequests.prefix(compactLayout ? 4 : 6)) { request in
|
|
RequestCard(
|
|
request: request,
|
|
compactLayout: compactLayout,
|
|
isBusy: false,
|
|
onApprove: nil,
|
|
onReject: nil,
|
|
onOpenRequest: {
|
|
onOpenRequest(request)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: notificationIDs, initial: true) { _, _ in
|
|
syncSelectedNotification()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var activityMetricRow: some View {
|
|
if compactLayout {
|
|
VStack(spacing: 10) {
|
|
SmallMetricPill(title: "Events", value: "\(model.notifications.count)")
|
|
SmallMetricPill(title: "Unread", value: "\(model.unreadNotificationCount)")
|
|
SmallMetricPill(title: "Handled", value: "\(model.handledRequests.count)")
|
|
}
|
|
} else {
|
|
HStack(spacing: 14) {
|
|
NotificationMetricCard(
|
|
title: "Events",
|
|
value: "\(model.notifications.count)",
|
|
subtitle: model.notifications.isEmpty ? "Quiet so far" : "Timeline active",
|
|
accent: dashboardAccent
|
|
)
|
|
NotificationMetricCard(
|
|
title: "Unread",
|
|
value: "\(model.unreadNotificationCount)",
|
|
subtitle: model.unreadNotificationCount == 0 ? "Everything acknowledged" : "Still highlighted",
|
|
accent: .orange
|
|
)
|
|
NotificationMetricCard(
|
|
title: "Handled",
|
|
value: "\(model.handledRequests.count)",
|
|
subtitle: model.handledRequests.isEmpty ? "No completed approvals yet" : "Recent decisions ready to review",
|
|
accent: dashboardGold
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func syncSelectedNotification() {
|
|
if let selectedNotificationID,
|
|
notificationIDs.contains(selectedNotificationID) {
|
|
return
|
|
}
|
|
|
|
selectedNotificationID = model.notifications.first?.id
|
|
}
|
|
}
|
|
|
|
private struct RequestsPanel: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let compactLayout: Bool
|
|
let onOpenRequest: (ApprovalRequest) -> Void
|
|
|
|
@State private var selectedRequestID: ApprovalRequest.ID?
|
|
|
|
private var requestIDs: [ApprovalRequest.ID] {
|
|
model.requests.map(\.id)
|
|
}
|
|
|
|
private var selectedRequest: ApprovalRequest? {
|
|
if let selectedRequestID,
|
|
let match = model.requests.first(where: { $0.id == selectedRequestID }) {
|
|
return match
|
|
}
|
|
|
|
return model.pendingRequests.first ?? model.handledRequests.first
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) {
|
|
if compactLayout {
|
|
SectionCard(
|
|
title: "Approval Desk",
|
|
subtitle: "Treat every request like a border checkpoint: verify the origin, timing, and scope before letting it through.",
|
|
compactLayout: compactLayout
|
|
) {
|
|
VStack(spacing: 16) {
|
|
RequestQueueSummary(
|
|
pendingCount: model.pendingRequests.count,
|
|
elevatedCount: model.elevatedPendingCount,
|
|
compactLayout: compactLayout
|
|
)
|
|
|
|
if model.pendingRequests.isEmpty {
|
|
EmptyStateCopy(
|
|
title: "Queue is clear",
|
|
systemImage: "checkmark.circle",
|
|
message: "Use the toolbar to simulate another request if you want to keep testing."
|
|
)
|
|
} else {
|
|
ForEach(model.pendingRequests) { request in
|
|
RequestCard(
|
|
request: request,
|
|
compactLayout: compactLayout,
|
|
isBusy: model.activeRequestID == request.id,
|
|
onApprove: {
|
|
Task { await model.approve(request) }
|
|
},
|
|
onReject: {
|
|
Task { await model.reject(request) }
|
|
},
|
|
onOpenRequest: {
|
|
onOpenRequest(request)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SectionCard(
|
|
title: "Decision Guide",
|
|
subtitle: "What to check before approving high-sensitivity actions from your phone.",
|
|
compactLayout: compactLayout
|
|
) {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
GuidanceRow(
|
|
icon: "network.badge.shield.half.filled",
|
|
title: "Confirm the origin",
|
|
message: "The service hostname should match the product or automation you intentionally triggered."
|
|
)
|
|
GuidanceRow(
|
|
icon: "timer",
|
|
title: "Look for short lifetimes",
|
|
message: "Privileged grants should usually be limited in time instead of creating long-lived access."
|
|
)
|
|
GuidanceRow(
|
|
icon: "lock.shield",
|
|
title: "Escalate mentally for elevated scopes",
|
|
message: "Signing, publishing, and write scopes deserve a slower second look before approval."
|
|
)
|
|
}
|
|
}
|
|
|
|
if !model.handledRequests.isEmpty {
|
|
SectionCard(
|
|
title: "Recently Handled",
|
|
subtitle: "A compact audit trail of the latest approvals and rejections.",
|
|
compactLayout: compactLayout
|
|
) {
|
|
LazyVStack(spacing: 14) {
|
|
ForEach(model.handledRequests.prefix(4)) { request in
|
|
RequestCard(
|
|
request: request,
|
|
compactLayout: compactLayout,
|
|
isBusy: false,
|
|
onApprove: nil,
|
|
onReject: nil,
|
|
onOpenRequest: {
|
|
onOpenRequest(request)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
SectionCard(
|
|
title: "Approval Workbench",
|
|
subtitle: "Use the queue on the left and a richer inline review on the right so each decision feels deliberate instead of mechanical."
|
|
) {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
RequestQueueSummary(
|
|
pendingCount: model.pendingRequests.count,
|
|
elevatedCount: model.elevatedPendingCount,
|
|
compactLayout: compactLayout
|
|
)
|
|
|
|
if model.requests.isEmpty {
|
|
EmptyStateCopy(
|
|
title: "Queue is clear",
|
|
systemImage: "checkmark.circle",
|
|
message: "Use the toolbar to simulate another request if you want to keep testing."
|
|
)
|
|
} else {
|
|
HStack(alignment: .top, spacing: 18) {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Queue")
|
|
.font(.headline)
|
|
|
|
Text("Pending and recently handled items stay visible here so you can sanity-check decisions without leaving the flow.")
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(spacing: 12) {
|
|
ForEach(model.requests) { request in
|
|
RequestQueueRow(
|
|
request: request,
|
|
isSelected: request.id == selectedRequest?.id
|
|
) {
|
|
selectedRequestID = request.id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: 340, alignment: .leading)
|
|
.padding(18)
|
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
|
|
|
if let request = selectedRequest {
|
|
RequestWorkbenchDetail(
|
|
request: request,
|
|
isBusy: model.activeRequestID == request.id,
|
|
onApprove: request.status == .pending ? {
|
|
Task { await model.approve(request) }
|
|
} : nil,
|
|
onReject: request.status == .pending ? {
|
|
Task { await model.reject(request) }
|
|
} : nil,
|
|
onOpenRequest: {
|
|
onOpenRequest(request)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SectionCard(
|
|
title: "Operator Checklist",
|
|
subtitle: "A calm review pattern for larger screens, especially when elevated scopes show up."
|
|
) {
|
|
LazyVGrid(
|
|
columns: [
|
|
GridItem(.flexible(), spacing: 14),
|
|
GridItem(.flexible(), spacing: 14)
|
|
],
|
|
alignment: .leading,
|
|
spacing: 14
|
|
) {
|
|
GuidanceCard(
|
|
icon: "network.badge.shield.half.filled",
|
|
title: "Confirm the origin",
|
|
message: "The hostname should map to the workflow or portal you intentionally triggered."
|
|
)
|
|
GuidanceCard(
|
|
icon: "timer",
|
|
title: "Look for short lifetimes",
|
|
message: "Elevated grants are safer when they expire quickly instead of becoming ambient access."
|
|
)
|
|
GuidanceCard(
|
|
icon: "lock.shield",
|
|
title: "Escalate for signing and publish scopes",
|
|
message: "If the action can sign, publish, or write, slow down and verify the target system twice."
|
|
)
|
|
GuidanceCard(
|
|
icon: "person.badge.shield.checkmark",
|
|
title: "Match the device",
|
|
message: "The request story should line up with the paired browser, CLI, or automation session you expect."
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: requestIDs, initial: true) { _, _ in
|
|
syncSelectedRequest()
|
|
}
|
|
}
|
|
|
|
private func syncSelectedRequest() {
|
|
if let selectedRequestID,
|
|
requestIDs.contains(selectedRequestID) {
|
|
return
|
|
}
|
|
|
|
selectedRequestID = model.pendingRequests.first?.id ?? model.handledRequests.first?.id
|
|
}
|
|
}
|
|
|
|
private struct NotificationsPanel: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let compactLayout: Bool
|
|
|
|
@State private var selectedNotificationID: AppNotification.ID?
|
|
|
|
private var notificationIDs: [AppNotification.ID] {
|
|
model.notifications.map(\.id)
|
|
}
|
|
|
|
private var selectedNotification: AppNotification? {
|
|
if let selectedNotificationID,
|
|
let match = model.notifications.first(where: { $0.id == selectedNotificationID }) {
|
|
return match
|
|
}
|
|
|
|
return model.notifications.first
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) {
|
|
if compactLayout {
|
|
SectionCard(
|
|
title: "Notification Delivery",
|
|
subtitle: "Control lock-screen delivery now, then evolve this into remote push once the backend is live.",
|
|
compactLayout: compactLayout
|
|
) {
|
|
NotificationPermissionCard(model: model, compactLayout: compactLayout)
|
|
}
|
|
|
|
SectionCard(
|
|
title: "Alert Inbox",
|
|
subtitle: "Unread alerts stay emphasized here until you explicitly clear them.",
|
|
compactLayout: compactLayout
|
|
) {
|
|
if model.notifications.isEmpty {
|
|
EmptyStateCopy(
|
|
title: "No alerts yet",
|
|
systemImage: "bell.slash",
|
|
message: "New pairing and approval alerts will accumulate here."
|
|
)
|
|
} else {
|
|
LazyVStack(spacing: 14) {
|
|
ForEach(model.notifications) { notification in
|
|
NotificationCard(
|
|
notification: notification,
|
|
compactLayout: compactLayout,
|
|
onMarkRead: {
|
|
Task { await model.markNotificationRead(notification) }
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
SectionCard(
|
|
title: "Delivery Posture",
|
|
subtitle: "Keep delivery health, unread pressure, and the latest alert in one glance from the notification center."
|
|
) {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
HStack(spacing: 14) {
|
|
NotificationMetricCard(
|
|
title: "Unread",
|
|
value: "\(model.unreadNotificationCount)",
|
|
subtitle: model.unreadNotificationCount == 0 ? "Inbox clear" : "Needs triage",
|
|
accent: .orange
|
|
)
|
|
NotificationMetricCard(
|
|
title: "Permission",
|
|
value: model.notificationPermission.title,
|
|
subtitle: model.notificationPermission == .allowed ? "Lock screen ready" : "Review device status",
|
|
accent: dashboardAccent
|
|
)
|
|
NotificationMetricCard(
|
|
title: "Latest",
|
|
value: model.latestNotification?.kind.title ?? "Quiet",
|
|
subtitle: model.latestNotification?.sentAt.formatted(date: .omitted, time: .shortened) ?? "No recent events",
|
|
accent: dashboardGold
|
|
)
|
|
}
|
|
|
|
NotificationPermissionCard(model: model, compactLayout: compactLayout)
|
|
}
|
|
}
|
|
|
|
SectionCard(
|
|
title: "Alert Inbox",
|
|
subtitle: "Select an alert to inspect the message body, delivery state, and the right follow-up action."
|
|
) {
|
|
if model.notifications.isEmpty {
|
|
EmptyStateCopy(
|
|
title: "No alerts yet",
|
|
systemImage: "bell.slash",
|
|
message: "New pairing and approval alerts will accumulate here."
|
|
)
|
|
} else {
|
|
HStack(alignment: .top, spacing: 18) {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Feed")
|
|
.font(.headline)
|
|
|
|
Text("Unread items stay visually lifted until you clear them, which makes it easier to scan the important changes first.")
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(spacing: 12) {
|
|
ForEach(model.notifications) { notification in
|
|
NotificationFeedRow(
|
|
notification: notification,
|
|
isSelected: notification.id == selectedNotification?.id
|
|
) {
|
|
selectedNotificationID = notification.id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: 340, alignment: .leading)
|
|
.padding(18)
|
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
|
|
|
if let notification = selectedNotification {
|
|
NotificationWorkbenchDetail(
|
|
notification: notification,
|
|
permissionState: model.notificationPermission,
|
|
onMarkRead: {
|
|
Task { await model.markNotificationRead(notification) }
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: notificationIDs, initial: true) { _, _ in
|
|
syncSelectedNotification()
|
|
}
|
|
}
|
|
|
|
private func syncSelectedNotification() {
|
|
if let selectedNotificationID,
|
|
notificationIDs.contains(selectedNotificationID) {
|
|
return
|
|
}
|
|
|
|
selectedNotificationID = model.notifications.first?.id
|
|
}
|
|
}
|
|
|
|
private struct AccountPanel: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let compactLayout: Bool
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) {
|
|
if let profile = model.profile, let session = model.session {
|
|
AccountHero(profile: profile, session: session, compactLayout: compactLayout)
|
|
|
|
SectionCard(
|
|
title: "Session Security",
|
|
subtitle: "The core trust facts for the currently paired session.",
|
|
compactLayout: compactLayout
|
|
) {
|
|
AccountFactGrid(profile: profile, session: session, compactLayout: compactLayout)
|
|
}
|
|
}
|
|
|
|
SectionCard(
|
|
title: "Mock Pairing Payload",
|
|
subtitle: "Useful for testing QR flow while the real portal integration is still pending.",
|
|
compactLayout: compactLayout
|
|
) {
|
|
Text(model.suggestedQRCodePayload)
|
|
.font(.body.monospaced())
|
|
.textSelection(.enabled)
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
|
}
|
|
|
|
SectionCard(
|
|
title: "Session Controls",
|
|
subtitle: "Use this once you want to reset back to the login and pairing flow.",
|
|
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
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topLeading) {
|
|
RoundedRectangle(cornerRadius: 34, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
Color(red: 0.07, green: 0.18, blue: 0.15),
|
|
Color(red: 0.11, green: 0.28, blue: 0.24),
|
|
Color(red: 0.29, green: 0.24, blue: 0.12)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 34, style: .continuous)
|
|
.strokeBorder(dashboardGold.opacity(0.55), lineWidth: 1.2)
|
|
)
|
|
|
|
Circle()
|
|
.fill(.white.opacity(0.08))
|
|
.frame(width: compactLayout ? 180 : 260, height: compactLayout ? 180 : 260)
|
|
.offset(x: compactLayout ? 210 : 420, y: compactLayout ? -30 : -50)
|
|
|
|
Image(systemName: "globe.europe.africa.fill")
|
|
.font(.system(size: compactLayout ? 92 : 122))
|
|
.foregroundStyle(.white.opacity(0.07))
|
|
.offset(x: compactLayout ? 220 : 455, y: compactLayout ? 4 : 8)
|
|
|
|
VStack(alignment: .leading, spacing: compactLayout ? 16 : 20) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("IDP.GLOBAL DIGITAL PASSPORT")
|
|
.font(.caption.weight(.bold))
|
|
.tracking(1.8)
|
|
.foregroundStyle(.white.opacity(0.78))
|
|
|
|
Text(profile.name)
|
|
.font(.system(size: compactLayout ? 30 : 36, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
Text("Bound to \(session.deviceName) for requests coming from \(session.originHost).")
|
|
.font(compactLayout ? .subheadline : .title3)
|
|
.foregroundStyle(.white.opacity(0.88))
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
VStack(alignment: .trailing, spacing: 8) {
|
|
StatusBadge(title: "Bound", tone: .white)
|
|
|
|
Text(session.pairingCode)
|
|
.font(.caption.monospaced().weight(.semibold))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 7)
|
|
.background(.white.opacity(0.12), in: Capsule())
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
|
|
Group {
|
|
if compactLayout {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
passportPortrait
|
|
|
|
VStack(spacing: 12) {
|
|
passportPrimaryFields
|
|
passportSecondaryFields
|
|
}
|
|
}
|
|
} else {
|
|
HStack(alignment: .top, spacing: 18) {
|
|
passportPortrait
|
|
|
|
HStack(alignment: .top, spacing: 14) {
|
|
passportPrimaryFields
|
|
passportSecondaryFields
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(compactLayout ? 18 : 20)
|
|
.background(.white.opacity(0.11), in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
|
|
|
PassportMachineStrip(code: machineReadableCode)
|
|
|
|
if compactLayout {
|
|
VStack(spacing: 10) {
|
|
PassportMetricBadge(
|
|
title: "Pending",
|
|
value: "\(pendingCount)",
|
|
subtitle: pendingCount == 0 ? "No approvals waiting" : "Requests still at the border"
|
|
)
|
|
PassportMetricBadge(
|
|
title: "Alerts",
|
|
value: "\(unreadCount)",
|
|
subtitle: unreadCount == 0 ? "Notification bell is clear" : "Unread device alerts"
|
|
)
|
|
PassportMetricBadge(
|
|
title: "Devices",
|
|
value: "\(profile.deviceCount)",
|
|
subtitle: "\(profile.organization) membership"
|
|
)
|
|
}
|
|
} else {
|
|
HStack(spacing: 12) {
|
|
PassportMetricBadge(
|
|
title: "Pending",
|
|
value: "\(pendingCount)",
|
|
subtitle: pendingCount == 0 ? "No approvals waiting" : "Requests still at the border"
|
|
)
|
|
PassportMetricBadge(
|
|
title: "Alerts",
|
|
value: "\(unreadCount)",
|
|
subtitle: unreadCount == 0 ? "Notification bell is clear" : "Unread device alerts"
|
|
)
|
|
PassportMetricBadge(
|
|
title: "Devices",
|
|
value: "\(profile.deviceCount)",
|
|
subtitle: "\(profile.organization) membership"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.padding(compactLayout ? 22 : 28)
|
|
}
|
|
.frame(minHeight: compactLayout ? 470 : 390)
|
|
}
|
|
|
|
private var passportPortrait: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
|
.fill(.white.opacity(0.12))
|
|
.frame(width: compactLayout ? 118 : 132, height: compactLayout ? 148 : 166)
|
|
.overlay {
|
|
VStack(spacing: 10) {
|
|
Circle()
|
|
.fill(.white.opacity(0.18))
|
|
.frame(width: compactLayout ? 56 : 64, height: compactLayout ? 56 : 64)
|
|
.overlay {
|
|
Text(holderInitials)
|
|
.font(.system(size: compactLayout ? 24 : 28, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
}
|
|
|
|
Text("TRUSTED HOLDER")
|
|
.font(.caption2.weight(.bold))
|
|
.tracking(1.2)
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
|
|
Text(profile.handle)
|
|
.font(.footnote.monospaced())
|
|
.foregroundStyle(.white.opacity(0.9))
|
|
}
|
|
.padding(12)
|
|
}
|
|
|
|
Text("Issued \(session.pairedAt.formatted(date: .abbreviated, time: .shortened))")
|
|
.font(.caption)
|
|
.foregroundStyle(.white.opacity(0.74))
|
|
}
|
|
}
|
|
|
|
private var passportPrimaryFields: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
PassportField(label: "Holder", value: profile.name, emphasized: true)
|
|
PassportField(label: "Handle", value: profile.handle, monospaced: true)
|
|
PassportField(label: "Organization", value: profile.organization)
|
|
}
|
|
}
|
|
|
|
private var passportSecondaryFields: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
PassportField(label: "Bound Device", value: session.deviceName)
|
|
PassportField(label: "Origin", value: session.originHost, monospaced: true)
|
|
PassportField(label: "Token Preview", value: "...\(session.tokenPreview)", monospaced: true)
|
|
}
|
|
}
|
|
|
|
private var holderInitials: String {
|
|
let parts = profile.name
|
|
.split(separator: " ")
|
|
.prefix(2)
|
|
.compactMap { $0.first }
|
|
|
|
let initials = String(parts)
|
|
return initials.isEmpty ? "ID" : initials.uppercased()
|
|
}
|
|
|
|
private var machineReadableCode: String {
|
|
let normalizedName = sanitize(profile.name)
|
|
let normalizedHandle = sanitize(profile.handle)
|
|
let normalizedToken = sanitize(session.tokenPreview)
|
|
return "P<IDP\(session.pairingCode.uppercased())<\(normalizedName)<<\(normalizedHandle)<<\(normalizedToken)"
|
|
}
|
|
|
|
private func sanitize(_ value: String) -> String {
|
|
value
|
|
.uppercased()
|
|
.map { character in
|
|
character.isLetter || character.isNumber ? String(character) : "<"
|
|
}
|
|
.joined()
|
|
}
|
|
}
|
|
|
|
private struct PassportField: View {
|
|
let label: String
|
|
let value: String
|
|
var monospaced: Bool = false
|
|
var emphasized: Bool = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(label.uppercased())
|
|
.font(.caption2.weight(.bold))
|
|
.tracking(1.0)
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
|
|
Text(value)
|
|
.font(valueFont)
|
|
.foregroundStyle(.white)
|
|
.lineLimit(2)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private var valueFont: Font {
|
|
if monospaced {
|
|
return .body.monospaced()
|
|
}
|
|
|
|
return emphasized ? .headline : .body
|
|
}
|
|
}
|
|
|
|
private struct PassportMetricBadge: View {
|
|
let title: String
|
|
let value: String
|
|
let subtitle: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(title.uppercased())
|
|
.font(.caption.weight(.bold))
|
|
.tracking(1.0)
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
|
|
Text(value)
|
|
.font(.title2.weight(.bold))
|
|
.foregroundStyle(.white)
|
|
|
|
Text(subtitle)
|
|
.font(.footnote)
|
|
.foregroundStyle(.white.opacity(0.82))
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.white.opacity(0.10), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct PassportMachineStrip: View {
|
|
let code: String
|
|
|
|
var body: some View {
|
|
Text(code)
|
|
.font(.caption.monospaced().weight(.semibold))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.5)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color.black.opacity(0.22), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
.foregroundStyle(.white.opacity(0.94))
|
|
}
|
|
}
|
|
|
|
private struct QuickActionsDeck: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let compactLayout: Bool
|
|
|
|
var body: some View {
|
|
Group {
|
|
if compactLayout {
|
|
VStack(spacing: 12) {
|
|
actionButtons
|
|
}
|
|
} else {
|
|
HStack(alignment: .top, spacing: 14) {
|
|
actionButtons
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var actionButtons: some View {
|
|
ActionTile(
|
|
title: "Refresh State",
|
|
subtitle: "Pull the latest requests and notifications from the mock service.",
|
|
systemImage: "arrow.clockwise"
|
|
) {
|
|
Task {
|
|
await model.refreshDashboard()
|
|
}
|
|
}
|
|
|
|
ActionTile(
|
|
title: "Seed Request",
|
|
subtitle: "Inject a new elevated approval flow to test the queue.",
|
|
systemImage: "sparkles.rectangle.stack.fill"
|
|
) {
|
|
Task {
|
|
await model.simulateIncomingRequest()
|
|
}
|
|
}
|
|
|
|
ActionTile(
|
|
title: "Test Alert",
|
|
subtitle: "Schedule a local notification so the phone behavior is easy to verify.",
|
|
systemImage: "bell.badge.fill"
|
|
) {
|
|
Task {
|
|
await model.sendTestNotification()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ActionTile: View {
|
|
let title: String
|
|
let subtitle: String
|
|
let systemImage: String
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Image(systemName: systemImage)
|
|
.font(.title2)
|
|
.foregroundStyle(dashboardAccent)
|
|
Text(title)
|
|
.font(.headline)
|
|
.foregroundStyle(.primary)
|
|
Text(subtitle)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(18)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private struct FeaturedRequestCard: View {
|
|
let request: ApprovalRequest
|
|
let compactLayout: Bool
|
|
let onOpenRequest: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Image(systemName: request.risk == .elevated ? "shield.lefthalf.filled.badge.checkmark" : request.kind.systemImage)
|
|
.font(.title2)
|
|
.foregroundStyle(request.risk == .elevated ? .orange : dashboardAccent)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(request.trustHeadline)
|
|
.font(.headline)
|
|
Text(request.title)
|
|
.font(.title3.weight(.semibold))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
StatusBadge(
|
|
title: request.risk.title,
|
|
tone: request.risk == .routine ? .mint : .orange
|
|
)
|
|
}
|
|
|
|
Text(request.trustDetail)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack(spacing: 8) {
|
|
StatusBadge(title: request.kind.title, tone: .blue)
|
|
StatusBadge(title: request.source, tone: .gray)
|
|
StatusBadge(title: request.scopeSummary, tone: .green)
|
|
}
|
|
|
|
if compactLayout {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Button("Review Full Context", action: onOpenRequest)
|
|
.buttonStyle(.borderedProminent)
|
|
Text(request.risk.guidance)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else {
|
|
HStack {
|
|
Button("Review Full Context", action: onOpenRequest)
|
|
.buttonStyle(.borderedProminent)
|
|
Spacer()
|
|
Text(request.risk.guidance)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
}
|
|
}
|
|
.padding(compactLayout ? 18 : 22)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [
|
|
request.risk == .routine ? dashboardAccent.opacity(0.12) : Color.orange.opacity(0.16),
|
|
Color.white.opacity(0.7)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
),
|
|
in: RoundedRectangle(cornerRadius: 28, style: .continuous)
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct RequestQueueSummary: View {
|
|
let pendingCount: Int
|
|
let elevatedCount: Int
|
|
let compactLayout: Bool
|
|
|
|
var body: some View {
|
|
Group {
|
|
if compactLayout {
|
|
VStack(spacing: 12) {
|
|
summaryCards
|
|
}
|
|
} else {
|
|
HStack(spacing: 12) {
|
|
summaryCards
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var summaryCards: some View {
|
|
RequestSummaryMetricCard(
|
|
title: "Pending",
|
|
value: "\(pendingCount)",
|
|
subtitle: pendingCount == 0 ? "Queue is clear" : "Still waiting on your call",
|
|
accent: dashboardAccent
|
|
)
|
|
|
|
RequestSummaryMetricCard(
|
|
title: "Elevated",
|
|
value: "\(elevatedCount)",
|
|
subtitle: elevatedCount == 0 ? "No privileged scopes" : "Needs slower review",
|
|
accent: .orange
|
|
)
|
|
|
|
RequestSummaryMetricCard(
|
|
title: "Posture",
|
|
value: trustMode,
|
|
subtitle: postureSummary,
|
|
accent: dashboardGold
|
|
)
|
|
}
|
|
|
|
private var trustMode: String {
|
|
if pendingCount == 0 {
|
|
return "Clear"
|
|
}
|
|
|
|
if elevatedCount == 0 {
|
|
return "Active"
|
|
}
|
|
|
|
return elevatedCount > 1 ? "Escalate" : "Guarded"
|
|
}
|
|
|
|
private var postureSummary: String {
|
|
if pendingCount == 0 {
|
|
return "Nothing at the border"
|
|
}
|
|
|
|
if elevatedCount == 0 {
|
|
return "Routine traffic only"
|
|
}
|
|
|
|
return "Privileged access in queue"
|
|
}
|
|
}
|
|
|
|
private struct RequestSummaryMetricCard: View {
|
|
let title: String
|
|
let value: String
|
|
let subtitle: String
|
|
let accent: Color
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(title.uppercased())
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(value)
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundStyle(.primary)
|
|
|
|
Text(subtitle)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct NotificationPermissionCard: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let compactLayout: Bool
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
HStack(alignment: .top, spacing: 14) {
|
|
Image(systemName: model.notificationPermission.systemImage)
|
|
.font(.title2)
|
|
.frame(width: 38, height: 38)
|
|
.background(.thinMaterial, in: Circle())
|
|
.foregroundStyle(dashboardAccent)
|
|
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
Text(model.notificationPermission.title)
|
|
.font(.headline)
|
|
Text(model.notificationPermission.summary)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Group {
|
|
if compactLayout {
|
|
VStack(spacing: 12) {
|
|
permissionButtons
|
|
}
|
|
} else {
|
|
HStack(spacing: 12) {
|
|
permissionButtons
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(18)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 26, style: .continuous))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var permissionButtons: some View {
|
|
Button {
|
|
Task {
|
|
await model.requestNotificationAccess()
|
|
}
|
|
} label: {
|
|
Label("Enable Notifications", systemImage: "bell.and.waves.left.and.right.fill")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
Button {
|
|
Task {
|
|
await model.sendTestNotification()
|
|
}
|
|
} label: {
|
|
Label("Send Test Alert", systemImage: "paperplane.fill")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
|
|
private struct ActivityPreviewCard: View {
|
|
@ObservedObject var model: AppViewModel
|
|
let compactLayout: Bool
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
if let latest = model.latestNotification {
|
|
NotificationCard(
|
|
notification: latest,
|
|
compactLayout: compactLayout,
|
|
onMarkRead: {
|
|
Task { await model.markNotificationRead(latest) }
|
|
}
|
|
)
|
|
} else {
|
|
EmptyStateCopy(
|
|
title: "No activity yet",
|
|
systemImage: "clock.badge.xmark",
|
|
message: "Once requests and pairing events arrive, the activity timeline will fill in here."
|
|
)
|
|
}
|
|
|
|
if compactLayout {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Button {
|
|
model.selectedSection = .activity
|
|
} label: {
|
|
Label("Open Activity", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
Button {
|
|
model.isNotificationCenterPresented = true
|
|
} label: {
|
|
Label("Open Notification Bell", systemImage: "bell")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
} else {
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
model.selectedSection = .activity
|
|
} label: {
|
|
Label("Open Activity", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
Button {
|
|
model.isNotificationCenterPresented = true
|
|
} label: {
|
|
Label("Open Notifications", systemImage: "bell")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Spacer()
|
|
|
|
Text("Unread device alerts now live in the bell above instead of taking a full navigation slot.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct NotificationBellButton: View {
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
var body: some View {
|
|
Button {
|
|
model.isNotificationCenterPresented = true
|
|
} label: {
|
|
ZStack(alignment: .topTrailing) {
|
|
Image(systemName: model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill")
|
|
.font(.headline)
|
|
.foregroundStyle(model.unreadNotificationCount == 0 ? .primary : dashboardAccent)
|
|
|
|
if model.unreadNotificationCount > 0 {
|
|
Text("\(min(model.unreadNotificationCount, 9))")
|
|
.font(.caption2.weight(.bold))
|
|
.padding(.horizontal, 5)
|
|
.padding(.vertical, 2)
|
|
.background(Color.orange, in: Capsule())
|
|
.foregroundStyle(.white)
|
|
.offset(x: 10, y: -10)
|
|
}
|
|
}
|
|
.frame(width: 28, height: 28)
|
|
}
|
|
.accessibilityLabel("Notifications")
|
|
}
|
|
}
|
|
|
|
private struct NotificationCenterSheet: View {
|
|
@ObservedObject var model: AppViewModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
NotificationsPanel(model: model, compactLayout: compactLayout)
|
|
.padding(compactLayout ? 18 : 24)
|
|
.frame(maxWidth: compactLayout ? 720 : 1120, alignment: .leading)
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
.navigationTitle("Notifications")
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#if os(iOS)
|
|
.presentationDetents(compactLayout ? [.large] : [.medium, .large])
|
|
#endif
|
|
}
|
|
|
|
private var compactLayout: Bool {
|
|
#if os(iOS)
|
|
horizontalSizeClass == .compact
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private struct AccountHero: View {
|
|
let profile: MemberProfile
|
|
let session: AuthSession
|
|
let compactLayout: Bool
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottomLeading) {
|
|
RoundedRectangle(cornerRadius: 32, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
dashboardAccent.opacity(0.95),
|
|
Color(red: 0.19, green: 0.49, blue: 0.40),
|
|
dashboardGold.opacity(0.92)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text(profile.name)
|
|
.font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
Text(profile.handle)
|
|
.font(.headline)
|
|
.foregroundStyle(.white.opacity(0.84))
|
|
Text("Current trusted device: \(session.deviceName)")
|
|
.foregroundStyle(.white.opacity(0.86))
|
|
}
|
|
.padding(compactLayout ? 22 : 28)
|
|
}
|
|
.frame(minHeight: compactLayout ? 190 : 220)
|
|
}
|
|
}
|
|
|
|
private struct AccountFactGrid: View {
|
|
let profile: MemberProfile
|
|
let session: AuthSession
|
|
let compactLayout: Bool
|
|
|
|
private var columns: [GridItem] {
|
|
Array(repeating: GridItem(.flexible(), spacing: 12), count: compactLayout ? 1 : 2)
|
|
}
|
|
|
|
var body: some View {
|
|
LazyVGrid(columns: columns, spacing: 12) {
|
|
FactCard(label: "Organization", value: profile.organization)
|
|
FactCard(label: "Origin", value: session.originHost)
|
|
FactCard(label: "Paired At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
|
FactCard(label: "Token Preview", value: "…\(session.tokenPreview)")
|
|
FactCard(label: "Trusted Devices", value: "\(profile.deviceCount)")
|
|
FactCard(label: "Recovery", value: profile.recoverySummary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct RequestCard: View {
|
|
let request: ApprovalRequest
|
|
let compactLayout: Bool
|
|
let isBusy: Bool
|
|
let onApprove: (() -> Void)?
|
|
let onReject: (() -> Void)?
|
|
let onOpenRequest: (() -> Void)?
|
|
|
|
private var infoColumns: [GridItem] {
|
|
Array(repeating: GridItem(.flexible(), spacing: 10), count: compactLayout ? 2 : 3)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack(alignment: .top, spacing: 14) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(requestAccent.opacity(0.14))
|
|
|
|
Image(systemName: request.kind.systemImage)
|
|
.font(.title2)
|
|
.foregroundStyle(requestAccent)
|
|
}
|
|
.frame(width: 46, height: 46)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(request.trustHeadline)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(requestAccent)
|
|
|
|
Text(request.title)
|
|
.font(.headline)
|
|
.foregroundStyle(.primary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
StatusBadge(
|
|
title: request.status.title,
|
|
tone: statusTone
|
|
)
|
|
}
|
|
|
|
Text(request.subtitle)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack(spacing: 8) {
|
|
StatusBadge(title: request.kind.title, tone: .blue)
|
|
StatusBadge(title: request.risk.title, tone: request.risk == .routine ? .mint : .orange)
|
|
Text(request.createdAt, style: .relative)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
LazyVGrid(columns: infoColumns, alignment: .leading, spacing: 10) {
|
|
RequestFactPill(label: "Source", value: request.source, accent: dashboardAccent)
|
|
RequestFactPill(
|
|
label: "Requested",
|
|
value: request.createdAt.formatted(date: .abbreviated, time: .shortened),
|
|
accent: dashboardGold
|
|
)
|
|
RequestFactPill(label: "Access", value: request.scopeSummary, accent: requestAccent)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Label(request.status == .pending ? "Decision posture" : "Decision record", systemImage: request.status.systemImage)
|
|
.font(.headline)
|
|
.foregroundStyle(.primary)
|
|
|
|
Text(request.trustDetail)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(reviewSummary)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(requestAccent)
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(requestAccent.opacity(0.10), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
|
|
if !request.scopes.isEmpty {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Requested scopes")
|
|
.font(.subheadline.weight(.semibold))
|
|
|
|
FlowScopes(scopes: request.scopes)
|
|
}
|
|
}
|
|
|
|
VStack(spacing: 12) {
|
|
if let onOpenRequest {
|
|
Button {
|
|
onOpenRequest()
|
|
} label: {
|
|
Label("Review Details", systemImage: "arrow.up.forward.app")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
if let onApprove, let onReject, request.status == .pending {
|
|
if compactLayout {
|
|
VStack(spacing: 10) {
|
|
Button {
|
|
onApprove()
|
|
} label: {
|
|
if isBusy {
|
|
ProgressView()
|
|
} else {
|
|
Label("Approve Request", systemImage: "checkmark.circle.fill")
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(isBusy)
|
|
|
|
Button(role: .destructive) {
|
|
onReject()
|
|
} label: {
|
|
Label("Reject Request", systemImage: "xmark.circle.fill")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(isBusy)
|
|
}
|
|
} else {
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
onApprove()
|
|
} label: {
|
|
if isBusy {
|
|
ProgressView()
|
|
} else {
|
|
Label("Approve", systemImage: "checkmark.circle.fill")
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(isBusy)
|
|
|
|
Button(role: .destructive) {
|
|
onReject()
|
|
} label: {
|
|
Label("Reject", systemImage: "xmark.circle.fill")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(isBusy)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(compactLayout ? 18 : 20)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [
|
|
requestAccent.opacity(0.10),
|
|
Color.white.opacity(0.74)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
),
|
|
in: RoundedRectangle(cornerRadius: 28, style: .continuous)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
|
.stroke(requestAccent.opacity(0.16), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private var statusTone: Color {
|
|
switch request.status {
|
|
case .pending:
|
|
return .orange
|
|
case .approved:
|
|
return .green
|
|
case .rejected:
|
|
return .red
|
|
}
|
|
}
|
|
|
|
private var requestAccent: Color {
|
|
switch request.status {
|
|
case .approved:
|
|
return .green
|
|
case .rejected:
|
|
return .red
|
|
case .pending:
|
|
return request.risk == .routine ? dashboardAccent : .orange
|
|
}
|
|
}
|
|
|
|
private var reviewSummary: String {
|
|
switch request.status {
|
|
case .pending:
|
|
if request.risk == .elevated {
|
|
return "This is privileged access. Let it through only if the origin and the moment both match what you just initiated."
|
|
}
|
|
return "This looks routine, but it still needs to match the browser, CLI, or device session you expect."
|
|
case .approved:
|
|
return "This request was already approved in the mock queue and is now part of the recent audit trail."
|
|
case .rejected:
|
|
return "This request was rejected and should remain a closed lane unless a new request is issued."
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct RequestQueueRow: View {
|
|
let request: ApprovalRequest
|
|
let isSelected: Bool
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.fill(rowAccent.opacity(0.14))
|
|
|
|
Image(systemName: request.kind.systemImage)
|
|
.font(.headline)
|
|
.foregroundStyle(rowAccent)
|
|
}
|
|
.frame(width: 38, height: 38)
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(request.title)
|
|
.font(.headline)
|
|
.foregroundStyle(.primary)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
Text(request.trustHeadline)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(rowAccent)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
StatusBadge(
|
|
title: request.status.title,
|
|
tone: statusTone
|
|
)
|
|
}
|
|
|
|
Text(request.source)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(request.subtitle)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
HStack(spacing: 8) {
|
|
StatusBadge(title: request.risk.title, tone: request.risk == .routine ? .mint : .orange)
|
|
StatusBadge(title: request.scopeSummary, tone: .blue)
|
|
Spacer()
|
|
Text(request.createdAt, style: .relative)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Image(systemName: isSelected ? "chevron.right.circle.fill" : "chevron.right")
|
|
.font(.headline)
|
|
.foregroundStyle(isSelected ? rowAccent : .secondary.opacity(0.7))
|
|
.padding(.top, 2)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(backgroundStyle, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
|
.stroke(isSelected ? dashboardAccent.opacity(0.36) : Color.clear, lineWidth: 1.5)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var statusTone: Color {
|
|
switch request.status {
|
|
case .pending:
|
|
.orange
|
|
case .approved:
|
|
.green
|
|
case .rejected:
|
|
.red
|
|
}
|
|
}
|
|
|
|
private var backgroundStyle: Color {
|
|
isSelected ? rowAccent.opacity(0.12) : Color.white.opacity(0.58)
|
|
}
|
|
|
|
private var rowAccent: Color {
|
|
switch request.status {
|
|
case .approved:
|
|
.green
|
|
case .rejected:
|
|
.red
|
|
case .pending:
|
|
request.risk == .routine ? dashboardAccent : .orange
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct RequestWorkbenchDetail: View {
|
|
let request: ApprovalRequest
|
|
let isBusy: Bool
|
|
let onApprove: (() -> Void)?
|
|
let onReject: (() -> Void)?
|
|
let onOpenRequest: () -> Void
|
|
|
|
private let columns = [
|
|
GridItem(.flexible(), spacing: 12),
|
|
GridItem(.flexible(), spacing: 12)
|
|
]
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
ZStack(alignment: .bottomLeading) {
|
|
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
request.risk == .routine ? dashboardAccent.opacity(0.95) : Color.orange.opacity(0.92),
|
|
dashboardGold.opacity(0.88)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
|
.strokeBorder(requestAccent.opacity(0.20), lineWidth: 1)
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 8) {
|
|
StatusBadge(title: request.kind.title, tone: .white)
|
|
StatusBadge(title: request.risk.title, tone: .white)
|
|
StatusBadge(title: request.status.title, tone: .white)
|
|
}
|
|
|
|
Text(request.title)
|
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
Text(request.trustHeadline)
|
|
.font(.headline)
|
|
.foregroundStyle(.white.opacity(0.84))
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
VStack(alignment: .trailing, spacing: 6) {
|
|
Text("REQUESTED")
|
|
.font(.caption.weight(.bold))
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
|
|
Text(request.createdAt.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
|
|
Text(request.subtitle)
|
|
.foregroundStyle(.white.opacity(0.88))
|
|
|
|
HStack(spacing: 14) {
|
|
Label(request.source, systemImage: "network")
|
|
Label(request.scopeSummary, systemImage: "lock.shield")
|
|
}
|
|
.font(.subheadline)
|
|
.foregroundStyle(.white.opacity(0.88))
|
|
}
|
|
.padding(24)
|
|
}
|
|
.frame(minHeight: 250)
|
|
|
|
LazyVGrid(columns: columns, spacing: 12) {
|
|
FactCard(label: "Source", value: request.source)
|
|
FactCard(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened))
|
|
FactCard(label: "Type", value: request.kind.title)
|
|
FactCard(label: "Status", value: request.status.title)
|
|
FactCard(label: "Risk", value: request.risk.summary)
|
|
FactCard(label: "Access", value: request.scopeSummary)
|
|
}
|
|
|
|
HStack(alignment: .top, spacing: 12) {
|
|
RequestSignalCard(
|
|
title: "Trust Signals",
|
|
subtitle: "The approval story should match the device, the product, and the moment you just triggered.",
|
|
accent: requestAccent
|
|
) {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
GuidanceRow(
|
|
icon: "network.badge.shield.half.filled",
|
|
title: "Source must look familiar",
|
|
message: "This request comes from \(request.source). Only approve if that host or product lines up with what you intended."
|
|
)
|
|
GuidanceRow(
|
|
icon: "person.badge.shield.checkmark",
|
|
title: "Action should fit the session",
|
|
message: request.trustDetail
|
|
)
|
|
GuidanceRow(
|
|
icon: request.risk == .routine ? "checkmark.shield" : "exclamationmark.shield",
|
|
title: request.risk == .routine ? "Routine review is still a review" : "Elevated access deserves a pause",
|
|
message: request.risk.guidance
|
|
)
|
|
}
|
|
}
|
|
|
|
RequestSignalCard(
|
|
title: "Access Envelope",
|
|
subtitle: "These are the capabilities this request wants before it can proceed.",
|
|
accent: dashboardGold
|
|
) {
|
|
if request.scopes.isEmpty {
|
|
Text("The mock backend did not provide explicit scopes for this request.")
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
FlowScopes(scopes: request.scopes)
|
|
}
|
|
}
|
|
}
|
|
|
|
RequestSignalCard(
|
|
title: request.status == .pending ? "Decision Rail" : "Decision Record",
|
|
subtitle: request.status == .pending
|
|
? "Use the actions below only once the request story matches the device in your hand."
|
|
: "This request already moved through the queue, so this rail becomes a compact audit note.",
|
|
accent: statusTone
|
|
) {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text(request.trustDetail)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(decisionSummary)
|
|
.font(.headline)
|
|
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
onOpenRequest()
|
|
} label: {
|
|
Label("Open Full Review", systemImage: "arrow.up.forward.app")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Spacer()
|
|
|
|
if let onApprove, let onReject, request.status == .pending {
|
|
Button {
|
|
onApprove()
|
|
} label: {
|
|
if isBusy {
|
|
ProgressView()
|
|
} else {
|
|
Label("Approve", systemImage: "checkmark.circle.fill")
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(isBusy)
|
|
|
|
Button(role: .destructive) {
|
|
onReject()
|
|
} label: {
|
|
Label("Reject", systemImage: "xmark.circle.fill")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(isBusy)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private var statusTone: Color {
|
|
switch request.status {
|
|
case .pending:
|
|
return .orange
|
|
case .approved:
|
|
return .green
|
|
case .rejected:
|
|
return .red
|
|
}
|
|
}
|
|
|
|
private var requestAccent: Color {
|
|
request.risk == .routine ? dashboardAccent : .orange
|
|
}
|
|
|
|
private var decisionSummary: String {
|
|
switch request.status {
|
|
case .pending:
|
|
return request.risk == .routine
|
|
? "Approve only if the origin and timing feel boringly expected."
|
|
: "Privileged requests should feel unmistakably intentional before you approve them."
|
|
case .approved:
|
|
return "This request has already been approved and should now be treated as part of your recent decision history."
|
|
case .rejected:
|
|
return "This request was rejected and is now a record of a blocked access attempt."
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct RequestFactPill: View {
|
|
let label: String
|
|
let value: String
|
|
let accent: Color
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(label.uppercased())
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(value)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(2)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct RequestSignalCard<Content: View>: View {
|
|
let title: String
|
|
let subtitle: String
|
|
let accent: Color
|
|
let content: () -> Content
|
|
|
|
init(
|
|
title: String,
|
|
subtitle: String,
|
|
accent: Color,
|
|
@ViewBuilder content: @escaping () -> Content
|
|
) {
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.accent = accent
|
|
self.content = content
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Circle()
|
|
.fill(accent.opacity(0.16))
|
|
.frame(width: 34, height: 34)
|
|
.overlay {
|
|
Circle()
|
|
.stroke(accent.opacity(0.30), lineWidth: 1)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.headline)
|
|
Text(subtitle)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
content()
|
|
}
|
|
.padding(18)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 26, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct RequestDetailSheet: View {
|
|
let request: ApprovalRequest
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
RequestDetailHero(request: request)
|
|
|
|
SectionCard(
|
|
title: "Requested Access",
|
|
subtitle: "The exact scopes or capabilities this action wants to receive."
|
|
) {
|
|
if request.scopes.isEmpty {
|
|
Text("No explicit scopes were provided by the mock backend.")
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
FlowScopes(scopes: request.scopes)
|
|
}
|
|
}
|
|
|
|
SectionCard(
|
|
title: "Trust Signals",
|
|
subtitle: "The details to validate before you approve anything sensitive."
|
|
) {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
FactCard(label: "Source", value: request.source)
|
|
FactCard(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened))
|
|
FactCard(label: "Type", value: request.kind.title)
|
|
FactCard(label: "Risk", value: request.risk.summary)
|
|
}
|
|
}
|
|
|
|
SectionCard(
|
|
title: "Decision Guidance",
|
|
subtitle: "A short operator-minded reminder before you accept or reject this request."
|
|
) {
|
|
Text(request.trustDetail)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(request.risk.guidance)
|
|
.font(.headline)
|
|
}
|
|
|
|
if request.status == .pending {
|
|
VStack(spacing: 12) {
|
|
Button {
|
|
Task {
|
|
await model.approve(request)
|
|
dismiss()
|
|
}
|
|
} label: {
|
|
if model.activeRequestID == request.id {
|
|
ProgressView()
|
|
} else {
|
|
Label("Approve Request", systemImage: "checkmark.circle.fill")
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(model.activeRequestID == request.id)
|
|
|
|
Button(role: .destructive) {
|
|
Task {
|
|
await model.reject(request)
|
|
dismiss()
|
|
}
|
|
} label: {
|
|
Label("Reject Request", systemImage: "xmark.circle.fill")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(model.activeRequestID == request.id)
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
.navigationTitle("Review Request")
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Close") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct RequestDetailHero: View {
|
|
let request: ApprovalRequest
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottomLeading) {
|
|
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
request.risk == .routine ? dashboardAccent.opacity(0.92) : Color.orange.opacity(0.92),
|
|
dashboardGold.opacity(0.88)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(request.trustHeadline)
|
|
.font(.headline)
|
|
.foregroundStyle(.white.opacity(0.86))
|
|
Text(request.title)
|
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
Text(request.subtitle)
|
|
.foregroundStyle(.white.opacity(0.86))
|
|
}
|
|
.padding(24)
|
|
}
|
|
.frame(minHeight: 210)
|
|
}
|
|
}
|
|
|
|
private struct NotificationCard: View {
|
|
let notification: AppNotification
|
|
let compactLayout: Bool
|
|
let onMarkRead: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack(alignment: .top, spacing: 14) {
|
|
Image(systemName: notification.kind.systemImage)
|
|
.font(.title3)
|
|
.frame(width: 38, height: 38)
|
|
.background(.thinMaterial, in: Circle())
|
|
.foregroundStyle(accentColor)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text(notification.title)
|
|
.font(.headline)
|
|
Spacer()
|
|
if notification.isUnread {
|
|
StatusBadge(title: "Unread", tone: .orange)
|
|
}
|
|
}
|
|
|
|
Text(notification.kind.summary)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Text(notification.message)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Group {
|
|
if compactLayout {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
timestampLabel
|
|
if notification.isUnread {
|
|
markReadButton
|
|
}
|
|
}
|
|
} else {
|
|
HStack {
|
|
timestampLabel
|
|
Spacer()
|
|
if notification.isUnread {
|
|
markReadButton
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(compactLayout ? 16 : 18)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 26, style: .continuous))
|
|
}
|
|
|
|
private var timestampLabel: 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
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct NotificationMetricCard: View {
|
|
let title: String
|
|
let value: String
|
|
let subtitle: String
|
|
let accent: Color
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(title.uppercased())
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(value)
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundStyle(.primary)
|
|
|
|
Text(subtitle)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(18)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct NotificationFeedRow: View {
|
|
let notification: AppNotification
|
|
let isSelected: Bool
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: notification.kind.systemImage)
|
|
.font(.headline)
|
|
.foregroundStyle(accentColor)
|
|
.frame(width: 34, height: 34)
|
|
.background(.thinMaterial, in: Circle())
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(notification.title)
|
|
.font(.headline)
|
|
.foregroundStyle(.primary)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
Text(notification.kind.summary)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
if notification.isUnread {
|
|
Circle()
|
|
.fill(Color.orange)
|
|
.frame(width: 10, height: 10)
|
|
}
|
|
}
|
|
|
|
Text(notification.message)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
HStack {
|
|
StatusBadge(title: notification.kind.title, tone: accentColor)
|
|
Spacer()
|
|
Text(notification.sentAt.formatted(date: .omitted, time: .shortened))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(backgroundStyle, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
|
.stroke(isSelected ? accentColor.opacity(0.35) : Color.clear, lineWidth: 1.5)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var accentColor: Color {
|
|
switch notification.kind {
|
|
case .approval:
|
|
.green
|
|
case .security:
|
|
.orange
|
|
case .system:
|
|
.blue
|
|
}
|
|
}
|
|
|
|
private var backgroundStyle: Color {
|
|
isSelected ? accentColor.opacity(0.10) : Color.white.opacity(0.58)
|
|
}
|
|
}
|
|
|
|
private struct NotificationWorkbenchDetail: View {
|
|
let notification: AppNotification
|
|
let permissionState: NotificationPermissionState
|
|
let onMarkRead: () -> Void
|
|
|
|
private let columns = [
|
|
GridItem(.flexible(), spacing: 12),
|
|
GridItem(.flexible(), spacing: 12)
|
|
]
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
ZStack(alignment: .bottomLeading) {
|
|
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
accentColor.opacity(0.95),
|
|
accentColor.opacity(0.70),
|
|
dashboardGold.opacity(0.82)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 8) {
|
|
StatusBadge(title: notification.kind.title, tone: .white)
|
|
StatusBadge(title: notification.isUnread ? "Unread" : "Read", tone: .white)
|
|
}
|
|
|
|
Text(notification.title)
|
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
Text(notification.message)
|
|
.foregroundStyle(.white.opacity(0.9))
|
|
}
|
|
.padding(24)
|
|
}
|
|
.frame(minHeight: 210)
|
|
|
|
LazyVGrid(columns: columns, spacing: 12) {
|
|
FactCard(label: "Category", value: notification.kind.summary)
|
|
FactCard(label: "Sent", value: notification.sentAt.formatted(date: .abbreviated, time: .shortened))
|
|
FactCard(label: "Inbox State", value: notification.isUnread ? "Still highlighted" : "Already cleared")
|
|
FactCard(label: "Delivery", value: permissionState.title)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Delivery Context")
|
|
.font(.headline)
|
|
|
|
Text(permissionState.summary)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(notification.isUnread ? "This alert is still asking for attention in the in-app feed." : "This alert has already been acknowledged in the mock inbox.")
|
|
.font(.headline)
|
|
}
|
|
.padding(18)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 26, style: .continuous))
|
|
|
|
if notification.isUnread {
|
|
Button {
|
|
onMarkRead()
|
|
} label: {
|
|
Label("Mark Read", systemImage: "checkmark")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private var accentColor: Color {
|
|
switch notification.kind {
|
|
case .approval:
|
|
.green
|
|
case .security:
|
|
.orange
|
|
case .system:
|
|
.blue
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SectionCard<Content: View>: View {
|
|
let title: String
|
|
let subtitle: String
|
|
let compactLayout: Bool
|
|
let content: () -> Content
|
|
|
|
init(
|
|
title: String,
|
|
subtitle: String,
|
|
compactLayout: Bool = false,
|
|
@ViewBuilder content: @escaping () -> Content
|
|
) {
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.compactLayout = compactLayout
|
|
self.content = content
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title)
|
|
.font(.title2.weight(.semibold))
|
|
Text(subtitle)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
content()
|
|
}
|
|
.padding(compactLayout ? 18 : 24)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color.white.opacity(compactLayout ? 0.78 : 0.68), in: RoundedRectangle(cornerRadius: 32, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct BannerCard: View {
|
|
let message: String
|
|
let compactLayout: Bool
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "sparkles")
|
|
.font(.title3)
|
|
.foregroundStyle(dashboardAccent)
|
|
Text(message)
|
|
.font(compactLayout ? .subheadline.weight(.semibold) : .headline)
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
.background(.ultraThinMaterial, in: Capsule())
|
|
}
|
|
}
|
|
|
|
private struct SmallMetricPill: View {
|
|
let title: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title.uppercased())
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
Text(value)
|
|
.font(.headline)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct HeroMetric: View {
|
|
let title: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title.uppercased())
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
Text(value)
|
|
.font(.title2.weight(.bold))
|
|
.foregroundStyle(.white)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.white.opacity(0.12), in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct GuidanceRow: View {
|
|
let icon: String
|
|
let title: String
|
|
let message: String
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: icon)
|
|
.font(.title3)
|
|
.frame(width: 32)
|
|
.foregroundStyle(dashboardAccent)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.headline)
|
|
Text(message)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct GuidanceCard: View {
|
|
let icon: String
|
|
let title: String
|
|
let message: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Image(systemName: icon)
|
|
.font(.title3)
|
|
.foregroundStyle(dashboardAccent)
|
|
|
|
Text(title)
|
|
.font(.headline)
|
|
|
|
Text(message)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(18)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct FlowScopes: View {
|
|
let scopes: [String]
|
|
|
|
var body: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(scopes, id: \.self) { scope in
|
|
Text(scope)
|
|
.font(.caption.monospaced())
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.background(.thinMaterial, in: Capsule())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct EmptyStateCopy: View {
|
|
let title: String
|
|
let systemImage: String
|
|
let message: String
|
|
|
|
var body: some View {
|
|
ContentUnavailableView(
|
|
title,
|
|
systemImage: systemImage,
|
|
description: Text(message)
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 10)
|
|
}
|
|
}
|
|
|
|
private struct FactCard: View {
|
|
let label: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(label.uppercased())
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
Text(value)
|
|
.font(.body)
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private struct StatusBadge: View {
|
|
let title: String
|
|
let tone: Color
|
|
|
|
var body: some View {
|
|
Text(title)
|
|
.font(.caption.weight(.semibold))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(tone.opacity(0.14), in: Capsule())
|
|
.foregroundStyle(tone)
|
|
}
|
|
}
|