318 lines
11 KiB
Swift
318 lines
11 KiB
Swift
|
|
import SwiftUI
|
||
|
|
|
||
|
|
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
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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) }
|
||
|
|
}
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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) }
|
||
|
|
}
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 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)
|
||
|
|
}
|
||
|
|
}
|