1153 lines
38 KiB
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)
|
|
}
|
|
}
|