Files
swiftapp/Sources/Features/Home/HomeRootView.swift

1153 lines
38 KiB
Swift

import CryptoKit
import Foundation
import SwiftUI
private let dashboardAccent = AppTheme.accent
private let dashboardGold = AppTheme.warmAccent
private extension View {
@ViewBuilder
func inlineNavigationTitleOnIOS() -> some View {
#if os(iOS)
navigationBarTitleDisplayMode(.inline)
#else
self
#endif
}
@ViewBuilder
func cleanTabBarOnIOS() -> some View {
#if os(iOS)
toolbarBackground(.visible, for: .tabBar)
.toolbarBackground(Color.white.opacity(0.98), for: .tabBar)
#else
self
#endif
}
}
struct HomeRootView: View {
@ObservedObject var model: AppViewModel
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)
true
#else
false
#endif
}
}
private struct CompactHomeContainer: View {
@ObservedObject var model: AppViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
TabView(selection: $model.selectedSection) {
ForEach(AppSection.allCases) { section in
NavigationStack {
HomeSectionScreen(model: model, section: section, compactLayout: compactLayout)
.navigationTitle(section.title)
.inlineNavigationTitleOnIOS()
.toolbar {
DashboardToolbar(model: model)
}
}
.tag(section)
.tabItem {
Label(section.title, systemImage: section.systemImage)
}
}
}
.cleanTabBarOnIOS()
}
private var compactLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
}
}
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)
}
}
.navigationSplitViewStyle(.balanced)
}
}
private struct DashboardToolbar: ToolbarContent {
@ObservedObject var model: AppViewModel
var body: some ToolbarContent {
ToolbarItemGroup(placement: .primaryAction) {
NotificationBellButton(model: model)
}
}
}
private struct HomeSectionScreen: View {
@ObservedObject var model: AppViewModel
let section: AppSection
let compactLayout: Bool
@State private var focusedRequest: ApprovalRequest?
@State private var isOTPPresented = false
@StateObject private var identifyReader = NFCIdentifyReader()
var body: some View {
AppScrollScreen(
compactLayout: compactLayout,
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
) {
HomeTopActions(
model: model,
identifyReader: identifyReader,
onScanQR: { model.isScannerPresented = true },
onShowOTP: { isOTPPresented = true }
)
switch section {
case .overview:
OverviewPanel(model: model, compactLayout: compactLayout)
case .requests:
RequestsPanel(model: model, compactLayout: compactLayout, onOpenRequest: { focusedRequest = $0 })
case .activity:
ActivityPanel(model: model, compactLayout: compactLayout)
case .account:
AccountPanel(model: model, compactLayout: compactLayout)
}
}
.task {
identifyReader.onAuthenticationRequestDetected = { request in
Task {
await model.identifyWithNFC(request)
}
}
identifyReader.onError = { message in
model.errorMessage = message
}
}
.sheet(item: $focusedRequest) { request in
RequestDetailSheet(request: request, model: model)
}
.sheet(isPresented: $model.isScannerPresented) {
QRScannerSheet(
seededPayload: model.session?.pairingCode ?? model.suggestedPairingPayload,
title: "Scan proof QR",
description: "Use the camera to scan an idp.global QR challenge from the site or device asking you to prove that it is really you.",
navigationTitle: "Scan Proof QR",
onCodeScanned: { payload in
Task {
await model.identifyWithPayload(payload, transport: .qr)
}
}
)
}
.sheet(isPresented: $isOTPPresented) {
if let session = model.session {
OneTimePasscodeSheet(session: session)
}
}
}
}
private struct HomeTopActions: View {
@ObservedObject var model: AppViewModel
@ObservedObject var identifyReader: NFCIdentifyReader
let onScanQR: () -> Void
let onShowOTP: () -> Void
var body: some View {
LazyVGrid(columns: columns, spacing: 12) {
identifyButton
qrButton
otpButton
}
}
private var columns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 12), count: 3)
}
private var identifyButton: some View {
Button {
identifyReader.beginScanning()
} label: {
AppActionTile(
title: identifyReader.isScanning ? "Scanning NFC" : "Tap NFC",
systemImage: "dot.radiowaves.left.and.right",
tone: dashboardAccent,
isBusy: identifyReader.isScanning || model.isIdentifying
)
}
.buttonStyle(.plain)
.disabled(identifyReader.isScanning || !identifyReader.isSupported || model.isIdentifying)
}
private var qrButton: some View {
Button {
onScanQR()
} label: {
AppActionTile(
title: "Scan QR",
systemImage: "qrcode.viewfinder",
tone: dashboardAccent
)
}
.buttonStyle(.plain)
}
private var otpButton: some View {
Button {
onShowOTP()
} label: {
AppActionTile(
title: "OTP",
systemImage: "number.square.fill",
tone: dashboardGold
)
}
.buttonStyle(.plain)
}
}
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
Button {
model.selectedSection = section
} label: {
HStack {
Label(section.title, systemImage: section.systemImage)
Spacer()
if badgeCount(for: section) > 0 {
AppStatusTag(title: "\(badgeCount(for: section))", tone: dashboardAccent)
}
}
}
.buttonStyle(.plain)
.listRowBackground(
model.selectedSection == section
? dashboardAccent.opacity(0.10)
: Color.clear
)
}
}
}
.navigationTitle("idp.global")
}
private func badgeCount(for section: AppSection) -> Int {
switch section {
case .overview:
0
case .requests:
model.pendingRequests.count
case .activity:
model.unreadNotificationCount
case .account:
0
}
}
}
private struct SidebarStatusCard: View {
let profile: MemberProfile?
let pendingCount: Int
let unreadCount: Int
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Digital Passport")
.font(.headline)
Text(profile?.handle ?? "No passport active")
.foregroundStyle(.secondary)
HStack(spacing: 8) {
AppStatusTag(title: "\(pendingCount) pending", tone: dashboardAccent)
AppStatusTag(title: "\(unreadCount) unread", tone: dashboardGold)
}
}
.padding(.vertical, 6)
}
}
private struct OverviewPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if let profile = model.profile, let session = model.session {
OverviewHero(
profile: profile,
session: session,
pendingCount: model.pendingRequests.count,
unreadCount: model.unreadNotificationCount,
compactLayout: compactLayout
)
}
}
}
}
private struct RequestsPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
let onOpenRequest: (ApprovalRequest) -> Void
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if model.requests.isEmpty {
AppPanel(compactLayout: compactLayout) {
EmptyStateCopy(
title: "No checks waiting",
systemImage: "checkmark.circle",
message: "Identity proof requests from sites and devices appear here."
)
}
} else {
RequestList(
requests: model.requests,
compactLayout: compactLayout,
activeRequestID: model.activeRequestID,
onApprove: { request in
Task { await model.approve(request) }
},
onReject: { request in
Task { await model.reject(request) }
},
onOpenRequest: onOpenRequest
)
}
}
}
}
private struct ActivityPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if model.notifications.isEmpty {
AppPanel(compactLayout: compactLayout) {
EmptyStateCopy(
title: "No proof activity yet",
systemImage: "clock.badge.xmark",
message: "Identity proofs and security events will appear here."
)
}
} else {
NotificationList(
notifications: model.notifications,
compactLayout: compactLayout,
onMarkRead: { notification in
Task { await model.markNotificationRead(notification) }
}
)
}
}
}
}
private struct NotificationsPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
AppSectionCard(title: "Delivery", compactLayout: compactLayout) {
NotificationPermissionSummary(model: model, compactLayout: compactLayout)
}
AppSectionCard(title: "Alerts", compactLayout: compactLayout) {
if model.notifications.isEmpty {
EmptyStateCopy(
title: "No alerts yet",
systemImage: "bell.slash",
message: "New passport and identity-proof alerts will accumulate here."
)
} else {
NotificationList(
notifications: model.notifications,
compactLayout: compactLayout,
onMarkRead: { notification in
Task { await model.markNotificationRead(notification) }
}
)
}
}
}
}
}
private struct AccountPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if let profile = model.profile, let session = model.session {
AccountHero(profile: profile, session: session, compactLayout: compactLayout)
AppSectionCard(title: "Session", compactLayout: compactLayout) {
AccountFactsGrid(profile: profile, session: session, compactLayout: compactLayout)
}
}
AppSectionCard(title: "Pairing payload", compactLayout: compactLayout) {
AppTextSurface(text: model.suggestedPairingPayload, monospaced: true)
}
AppSectionCard(title: "Actions", 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
private var detailColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
}
private var metricColumns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 16), count: 3)
}
var body: some View {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "Digital passport", tone: dashboardAccent)
VStack(alignment: .leading, spacing: 6) {
Text(profile.name)
.font(.system(size: compactLayout ? 30 : 38, weight: .bold, design: .rounded))
.lineLimit(2)
Text("\(profile.handle)\(profile.organization)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
AppStatusTag(title: "Passport active", tone: dashboardAccent)
AppStatusTag(title: session.pairingTransport.title, tone: dashboardGold)
}
Divider()
LazyVGrid(columns: detailColumns, alignment: .leading, spacing: 16) {
AppKeyValue(label: "Device", value: session.deviceName)
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
}
Divider()
LazyVGrid(columns: metricColumns, alignment: .leading, spacing: 16) {
AppMetric(title: "Pending", value: "\(pendingCount)")
AppMetric(title: "Alerts", value: "\(unreadCount)")
AppMetric(title: "Devices", value: "\(profile.deviceCount)")
}
}
}
}
private struct NotificationPermissionSummary: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: model.notificationPermission.systemImage)
.font(.headline)
.foregroundStyle(dashboardAccent)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) {
Text(model.notificationPermission.title)
.font(.headline)
Text(model.notificationPermission.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if compactLayout {
VStack(alignment: .leading, spacing: 12) {
permissionButtons
}
} else {
HStack(spacing: 12) {
permissionButtons
}
}
}
}
@ViewBuilder
private var permissionButtons: some View {
Button {
Task { await model.requestNotificationAccess() }
} label: {
Label("Enable notifications", systemImage: "bell.and.waves.left.and.right.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
Task { await model.sendTestNotification() }
} label: {
Label("Send test alert", systemImage: "paperplane.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
private struct AccountHero: View {
let profile: MemberProfile
let session: AuthSession
let compactLayout: Bool
var body: some View {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "Account", tone: dashboardAccent)
Text(profile.name)
.font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded))
.lineLimit(2)
Text(profile.handle)
.font(.headline)
.foregroundStyle(.secondary)
Text("Active client: \(session.deviceName)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
private struct AccountFactsGrid: View {
let profile: MemberProfile
let session: AuthSession
let compactLayout: Bool
private var columns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
}
var body: some View {
LazyVGrid(columns: columns, alignment: .leading, spacing: 16) {
AppKeyValue(label: "Organization", value: profile.organization)
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
AppKeyValue(label: "Linked At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
AppKeyValue(label: "Method", value: session.pairingTransport.title)
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
AppKeyValue(label: "Recovery", value: profile.recoverySummary)
if let signedGPSPosition = session.signedGPSPosition {
AppKeyValue(
label: "Signed GPS",
value: "\(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)",
monospaced: true
)
}
AppKeyValue(label: "Trusted Devices", value: "\(profile.deviceCount)")
}
}
}
private 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
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) }
)
}
}
}
}
private struct RequestCard: View {
let request: ApprovalRequest
let compactLayout: Bool
let isBusy: Bool
let onApprove: (() -> Void)?
let onReject: (() -> Void)?
let onOpenRequest: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: request.kind.systemImage)
.font(.headline)
.foregroundStyle(requestAccent)
.frame(width: 28, height: 28)
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)
.foregroundStyle(.secondary)
.lineLimit(2)
}
controls
}
.padding(compactLayout ? 18 : 20)
.appSurface(radius: 24)
}
@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
}
}
}
private struct NotificationList: View {
let notifications: [AppNotification]
let compactLayout: Bool
let onMarkRead: (AppNotification) -> Void
var body: some View {
VStack(spacing: 14) {
ForEach(notifications) { notification in
NotificationCard(
notification: notification,
compactLayout: compactLayout,
onMarkRead: { onMarkRead(notification) }
)
}
}
}
}
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)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if compactLayout {
VStack(alignment: .leading, spacing: 10) {
timestamp
if notification.isUnread {
markReadButton
}
}
} else {
HStack {
timestamp
Spacer(minLength: 0)
if notification.isUnread {
markReadButton
}
}
}
}
.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
}
}
}
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 {
AppScrollScreen(
compactLayout: compactLayout,
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
) {
NotificationsPanel(model: model, compactLayout: compactLayout)
}
.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 RequestDetailSheet: View {
let request: ApprovalRequest
@ObservedObject var model: AppViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
AppScrollScreen(
compactLayout: true,
bottomPadding: AppLayout.compactBottomDockPadding
) {
RequestDetailHero(request: request)
AppSectionCard(title: "Summary", compactLayout: true) {
AppKeyValue(label: "Source", value: request.source)
AppKeyValue(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened))
AppKeyValue(label: "Risk", value: request.risk.summary)
AppKeyValue(label: "Type", value: request.kind.title)
}
AppSectionCard(title: "Proof details", compactLayout: true) {
if request.scopes.isEmpty {
Text("No explicit proof details were provided by the mock backend.")
.foregroundStyle(.secondary)
} else {
Text(request.scopes.joined(separator: "\n"))
.font(.body.monospaced())
.foregroundStyle(.secondary)
}
}
AppSectionCard(title: "Guidance", compactLayout: true) {
Text(request.trustDetail)
.foregroundStyle(.secondary)
Text(request.risk.guidance)
.font(.headline)
}
if request.status == .pending {
AppSectionCard(title: "Actions", compactLayout: true) {
VStack(spacing: 12) {
Button {
Task {
await model.approve(request)
dismiss()
}
} label: {
if model.activeRequestID == request.id {
ProgressView()
} else {
Label("Verify identity", systemImage: "checkmark.circle.fill")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.disabled(model.activeRequestID == request.id)
Button(role: .destructive) {
Task {
await model.reject(request)
dismiss()
}
} label: {
Label("Decline", systemImage: "xmark.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(model.activeRequestID == request.id)
}
}
}
}
.navigationTitle("Review Proof")
.inlineNavigationTitleOnIOS()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
}
}
}
private struct RequestDetailHero: View {
let request: ApprovalRequest
private var accent: Color {
switch request.status {
case .approved:
.green
case .rejected:
.red
case .pending:
request.risk == .routine ? dashboardAccent : .orange
}
}
var body: some View {
AppPanel(compactLayout: true, radius: AppLayout.largeCardRadius) {
AppBadge(title: request.kind.title, tone: accent)
Text(request.title)
.font(.system(size: 30, weight: .bold, design: .rounded))
.lineLimit(3)
Text(request.subtitle)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
AppStatusTag(title: request.status.title, tone: accent)
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
}
}
}
}
private struct OneTimePasscodeSheet: View {
let session: AuthSession
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
NavigationStack {
TimelineView(.periodic(from: .now, by: 1)) { context in
let code = passcode(at: context.date)
let secondsRemaining = renewalCountdown(at: context.date)
AppScrollScreen(compactLayout: compactLayout) {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "One-time passcode", tone: dashboardGold)
Text("OTP")
.font(.system(size: compactLayout ? 32 : 40, weight: .bold, design: .rounded))
Text("Share this code only with the site or device asking you to prove that it is really you.")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(code)
.font(.system(size: compactLayout ? 42 : 54, weight: .bold, design: .rounded).monospacedDigit())
.tracking(compactLayout ? 4 : 6)
.frame(maxWidth: .infinity)
.padding(.vertical, compactLayout ? 16 : 20)
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
HStack(spacing: 8) {
AppStatusTag(title: "Renews in \(secondsRemaining)s", tone: dashboardGold)
AppStatusTag(title: session.originHost, tone: dashboardAccent)
}
Divider()
AppKeyValue(label: "Client", value: session.deviceName)
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
}
}
}
.navigationTitle("OTP")
.inlineNavigationTitleOnIOS()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
}
}
private var compactLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
}
private func passcode(at date: Date) -> String {
let timeSlot = Int(date.timeIntervalSince1970 / 30)
let digest = SHA256.hash(data: Data("\(session.pairingCode)|\(timeSlot)".utf8))
let value = digest.prefix(4).reduce(UInt32(0)) { partialResult, byte in
(partialResult << 8) | UInt32(byte)
}
return String(format: "%06d", locale: Locale(identifier: "en_US_POSIX"), Int(value % 1_000_000))
}
private func renewalCountdown(at date: Date) -> Int {
let elapsed = Int(date.timeIntervalSince1970) % 30
return elapsed == 0 ? 30 : 30 - elapsed
}
}
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)
}
}