Refine app structure for persisted sessions and test coverage
CI / test (push) Has been cancelled

This commit is contained in:
2026-04-18 12:29:32 +02:00
parent 243029c798
commit d534964601
18 changed files with 1850 additions and 1162 deletions
-257
View File
@@ -1,261 +1,4 @@
import SwiftUI
#if os(macOS)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
private extension Color {
static func adaptive(
light: (red: Double, green: Double, blue: Double, opacity: Double),
dark: (red: Double, green: Double, blue: Double, opacity: Double)
) -> Color {
#if os(macOS)
Color(
nsColor: NSColor(name: nil) { appearance in
let matchedAppearance = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight])
let components = matchedAppearance == .darkAqua || matchedAppearance == .vibrantDark ? dark : light
return NSColor(
red: components.red,
green: components.green,
blue: components.blue,
alpha: components.opacity
)
}
)
#elseif canImport(UIKit) && !os(watchOS)
Color(
uiColor: UIColor { traits in
let components = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(
red: components.red,
green: components.green,
blue: components.blue,
alpha: components.opacity
)
}
)
#elseif os(watchOS)
Color(
red: dark.red,
green: dark.green,
blue: dark.blue,
opacity: dark.opacity
)
#else
Color(
red: light.red,
green: light.green,
blue: light.blue,
opacity: light.opacity
)
#endif
}
}
enum AppTheme {
static let accent = Color(red: 0.12, green: 0.40, blue: 0.31)
static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48)
static let border = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.08),
dark: (1.00, 1.00, 1.00, 0.12)
)
static let shadow = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.05),
dark: (0.00, 0.00, 0.00, 0.32)
)
static let cardFill = Color.adaptive(
light: (1.00, 1.00, 1.00, 0.96),
dark: (0.11, 0.12, 0.14, 0.96)
)
static let mutedFill = Color.adaptive(
light: (0.972, 0.976, 0.970, 1.00),
dark: (0.16, 0.17, 0.19, 1.00)
)
static let backgroundTop = Color.adaptive(
light: (0.975, 0.978, 0.972, 1.00),
dark: (0.08, 0.09, 0.10, 1.00)
)
static let backgroundBottom = Color.adaptive(
light: (1.00, 1.00, 1.00, 1.00),
dark: (0.05, 0.06, 0.07, 1.00)
)
static let backgroundGlow = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.02),
dark: (1.00, 1.00, 1.00, 0.06)
)
static let chromeFill = Color.adaptive(
light: (1.00, 1.00, 1.00, 0.98),
dark: (0.10, 0.11, 0.13, 0.98)
)
}
enum AppLayout {
static let compactHorizontalPadding: CGFloat = 16
static let regularHorizontalPadding: CGFloat = 28
static let compactVerticalPadding: CGFloat = 18
static let regularVerticalPadding: CGFloat = 28
static let compactContentWidth: CGFloat = 720
static let regularContentWidth: CGFloat = 920
static let cardRadius: CGFloat = 24
static let largeCardRadius: CGFloat = 30
static let compactSectionPadding: CGFloat = 18
static let regularSectionPadding: CGFloat = 24
static let compactSectionSpacing: CGFloat = 18
static let regularSectionSpacing: CGFloat = 24
static let compactBottomDockPadding: CGFloat = 120
static let regularBottomPadding: CGFloat = 56
static func horizontalPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactHorizontalPadding : regularHorizontalPadding
}
static func verticalPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactVerticalPadding : regularVerticalPadding
}
static func contentWidth(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactContentWidth : regularContentWidth
}
static func sectionPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactSectionPadding : regularSectionPadding
}
static func sectionSpacing(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactSectionSpacing : regularSectionSpacing
}
}
extension View {
func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View {
background(
fill,
in: RoundedRectangle(cornerRadius: radius, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
.shadow(color: AppTheme.shadow, radius: 12, y: 3)
}
}
struct AppBackground: View {
var body: some View {
LinearGradient(
colors: [
AppTheme.backgroundTop,
AppTheme.backgroundBottom
],
startPoint: .top,
endPoint: .bottom
)
.overlay(alignment: .top) {
Rectangle()
.fill(AppTheme.backgroundGlow)
.frame(height: 160)
.blur(radius: 60)
.offset(y: -90)
}
.ignoresSafeArea()
}
}
struct AppScrollScreen<Content: View>: View {
let compactLayout: Bool
var bottomPadding: CGFloat? = nil
let content: () -> Content
init(
compactLayout: Bool,
bottomPadding: CGFloat? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.bottomPadding = bottomPadding
self.content = content
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
content()
}
.frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
.padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
.padding(.top, AppLayout.verticalPadding(for: compactLayout))
.padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
}
.scrollIndicators(.hidden)
}
}
struct AppPanel<Content: View>: View {
let compactLayout: Bool
let radius: CGFloat
let content: () -> Content
init(
compactLayout: Bool,
radius: CGFloat = AppLayout.cardRadius,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.radius = radius
self.content = content
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
content()
}
.padding(AppLayout.sectionPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: .leading)
.appSurface(radius: radius)
}
}
struct AppBadge: View {
let title: String
var tone: Color = AppTheme.accent
var body: some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(tone)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(tone.opacity(0.10), in: Capsule())
}
}
struct AppSectionCard<Content: View>: View {
let title: String
var subtitle: String? = nil
let compactLayout: Bool
let content: () -> Content
init(
title: String,
subtitle: String? = nil,
compactLayout: Bool,
@ViewBuilder content: @escaping () -> Content
) {
self.title = title
self.subtitle = subtitle
self.compactLayout = compactLayout
self.content = content
}
var body: some View {
AppPanel(compactLayout: compactLayout) {
AppSectionTitle(title: title, subtitle: subtitle)
content()
}
}
}
struct AppSectionTitle: View {
let title: String
+258
View File
@@ -0,0 +1,258 @@
import SwiftUI
#if os(macOS)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
private extension Color {
static func adaptive(
light: (red: Double, green: Double, blue: Double, opacity: Double),
dark: (red: Double, green: Double, blue: Double, opacity: Double)
) -> Color {
#if os(macOS)
Color(
nsColor: NSColor(name: nil) { appearance in
let matchedAppearance = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight])
let components = matchedAppearance == .darkAqua || matchedAppearance == .vibrantDark ? dark : light
return NSColor(
red: components.red,
green: components.green,
blue: components.blue,
alpha: components.opacity
)
}
)
#elseif canImport(UIKit) && !os(watchOS)
Color(
uiColor: UIColor { traits in
let components = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(
red: components.red,
green: components.green,
blue: components.blue,
alpha: components.opacity
)
}
)
#elseif os(watchOS)
Color(
red: dark.red,
green: dark.green,
blue: dark.blue,
opacity: dark.opacity
)
#else
Color(
red: light.red,
green: light.green,
blue: light.blue,
opacity: light.opacity
)
#endif
}
}
enum AppTheme {
static let accent = Color(red: 0.12, green: 0.40, blue: 0.31)
static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48)
static let border = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.08),
dark: (1.00, 1.00, 1.00, 0.12)
)
static let shadow = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.05),
dark: (0.00, 0.00, 0.00, 0.32)
)
static let cardFill = Color.adaptive(
light: (1.00, 1.00, 1.00, 0.96),
dark: (0.11, 0.12, 0.14, 0.96)
)
static let mutedFill = Color.adaptive(
light: (0.972, 0.976, 0.970, 1.00),
dark: (0.16, 0.17, 0.19, 1.00)
)
static let backgroundTop = Color.adaptive(
light: (0.975, 0.978, 0.972, 1.00),
dark: (0.08, 0.09, 0.10, 1.00)
)
static let backgroundBottom = Color.adaptive(
light: (1.00, 1.00, 1.00, 1.00),
dark: (0.05, 0.06, 0.07, 1.00)
)
static let backgroundGlow = Color.adaptive(
light: (0.00, 0.00, 0.00, 0.02),
dark: (1.00, 1.00, 1.00, 0.06)
)
static let chromeFill = Color.adaptive(
light: (1.00, 1.00, 1.00, 0.98),
dark: (0.10, 0.11, 0.13, 0.98)
)
}
enum AppLayout {
static let compactHorizontalPadding: CGFloat = 16
static let regularHorizontalPadding: CGFloat = 28
static let compactVerticalPadding: CGFloat = 18
static let regularVerticalPadding: CGFloat = 28
static let compactContentWidth: CGFloat = 720
static let regularContentWidth: CGFloat = 920
static let cardRadius: CGFloat = 24
static let largeCardRadius: CGFloat = 30
static let compactSectionPadding: CGFloat = 18
static let regularSectionPadding: CGFloat = 24
static let compactSectionSpacing: CGFloat = 18
static let regularSectionSpacing: CGFloat = 24
static let compactBottomDockPadding: CGFloat = 120
static let regularBottomPadding: CGFloat = 56
static func horizontalPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactHorizontalPadding : regularHorizontalPadding
}
static func verticalPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactVerticalPadding : regularVerticalPadding
}
static func contentWidth(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactContentWidth : regularContentWidth
}
static func sectionPadding(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactSectionPadding : regularSectionPadding
}
static func sectionSpacing(for compactLayout: Bool) -> CGFloat {
compactLayout ? compactSectionSpacing : regularSectionSpacing
}
}
extension View {
func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View {
background(
fill,
in: RoundedRectangle(cornerRadius: radius, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
.shadow(color: AppTheme.shadow, radius: 12, y: 3)
}
}
struct AppBackground: View {
var body: some View {
LinearGradient(
colors: [
AppTheme.backgroundTop,
AppTheme.backgroundBottom
],
startPoint: .top,
endPoint: .bottom
)
.overlay(alignment: .top) {
Rectangle()
.fill(AppTheme.backgroundGlow)
.frame(height: 160)
.blur(radius: 60)
.offset(y: -90)
}
.ignoresSafeArea()
}
}
struct AppScrollScreen<Content: View>: View {
let compactLayout: Bool
var bottomPadding: CGFloat? = nil
let content: () -> Content
init(
compactLayout: Bool,
bottomPadding: CGFloat? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.bottomPadding = bottomPadding
self.content = content
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
content()
}
.frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
.padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
.padding(.top, AppLayout.verticalPadding(for: compactLayout))
.padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
}
.scrollIndicators(.hidden)
}
}
struct AppPanel<Content: View>: View {
let compactLayout: Bool
let radius: CGFloat
let content: () -> Content
init(
compactLayout: Bool,
radius: CGFloat = AppLayout.cardRadius,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.radius = radius
self.content = content
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
content()
}
.padding(AppLayout.sectionPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: .leading)
.appSurface(radius: radius)
}
}
struct AppBadge: View {
let title: String
var tone: Color = AppTheme.accent
var body: some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(tone)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(tone.opacity(0.10), in: Capsule())
}
}
struct AppSectionCard<Content: View>: View {
let title: String
var subtitle: String? = nil
let compactLayout: Bool
let content: () -> Content
init(
title: String,
subtitle: String? = nil,
compactLayout: Bool,
@ViewBuilder content: @escaping () -> Content
) {
self.title = title
self.subtitle = subtitle
self.compactLayout = compactLayout
self.content = content
}
var body: some View {
AppPanel(compactLayout: compactLayout) {
AppSectionTitle(title: title, subtitle: subtitle)
content()
}
}
}
+50 -3
View File
@@ -23,6 +23,7 @@ final class AppViewModel: ObservableObject {
private var hasBootstrapped = false
private let service: IDPServicing
private let notificationCoordinator: NotificationCoordinating
private let appStateStore: AppStateStoring
private let launchArguments: [String]
private var preferredLaunchSection: AppSection? {
@@ -40,10 +41,12 @@ final class AppViewModel: ObservableObject {
init(
service: IDPServicing = MockIDPService(),
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
launchArguments: [String] = ProcessInfo.processInfo.arguments
) {
self.service = service
self.notificationCoordinator = notificationCoordinator
self.appStateStore = appStateStore
self.launchArguments = launchArguments
}
@@ -79,14 +82,17 @@ final class AppViewModel: ObservableObject {
guard !hasBootstrapped else { return }
hasBootstrapped = true
restorePersistedState()
isBootstrapping = true
defer { isBootstrapping = false }
notificationPermission = await notificationCoordinator.authorizationStatus()
do {
let bootstrap = try await service.bootstrap()
suggestedPairingPayload = bootstrap.suggestedPairingPayload
manualPairingPayload = bootstrap.suggestedPairingPayload
notificationPermission = await notificationCoordinator.authorizationStatus()
manualPairingPayload = session?.pairingCode ?? bootstrap.suggestedPairingPayload
if launchArguments.contains("--mock-auto-pair"),
session == nil {
@@ -97,7 +103,9 @@ final class AppViewModel: ObservableObject {
}
}
} catch {
errorMessage = "Unable to prepare the app."
if session == nil {
errorMessage = "Unable to prepare the app."
}
}
}
@@ -144,6 +152,7 @@ final class AppViewModel: ObservableObject {
let result = try await service.signIn(with: normalizedRequest)
session = result.session
apply(snapshot: result.snapshot)
persistCurrentState()
notificationPermission = await notificationCoordinator.authorizationStatus()
selectedSection = .overview
errorMessage = nil
@@ -200,6 +209,7 @@ final class AppViewModel: ObservableObject {
do {
let snapshot = try await service.identify(with: normalizedRequest)
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
isScannerPresented = false
} catch let error as AppError {
@@ -218,6 +228,7 @@ final class AppViewModel: ObservableObject {
do {
let snapshot = try await service.refreshDashboard()
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch {
errorMessage = "Unable to refresh the dashboard."
@@ -238,6 +249,7 @@ final class AppViewModel: ObservableObject {
do {
let snapshot = try await service.simulateIncomingRequest()
apply(snapshot: snapshot)
persistCurrentState()
selectedSection = .requests
errorMessage = nil
} catch {
@@ -271,6 +283,7 @@ final class AppViewModel: ObservableObject {
do {
let snapshot = try await service.markNotificationRead(id: notification.id)
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch {
errorMessage = "Unable to update the notification."
@@ -278,6 +291,7 @@ final class AppViewModel: ObservableObject {
}
func signOut() {
appStateStore.clear()
session = nil
profile = nil
requests = []
@@ -298,12 +312,45 @@ final class AppViewModel: ObservableObject {
? try await service.approveRequest(id: request.id)
: try await service.rejectRequest(id: request.id)
apply(snapshot: snapshot)
persistCurrentState()
errorMessage = nil
} catch {
errorMessage = "Unable to update the identity check."
}
}
private func restorePersistedState() {
guard let state = appStateStore.load() else {
return
}
session = state.session
manualPairingPayload = state.session.pairingCode
apply(
snapshot: DashboardSnapshot(
profile: state.profile,
requests: state.requests,
notifications: state.notifications
)
)
}
private func persistCurrentState() {
guard let session, let profile else {
appStateStore.clear()
return
}
appStateStore.save(
PersistedAppState(
session: session,
profile: profile,
requests: requests,
notifications: notifications
)
)
}
private func apply(snapshot: DashboardSnapshot) {
profile = snapshot.profile
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
+13 -13
View File
@@ -1,7 +1,7 @@
import CryptoKit
import Foundation
enum AppSection: String, CaseIterable, Identifiable, Hashable {
enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable {
case overview
case requests
case activity
@@ -28,7 +28,7 @@ enum AppSection: String, CaseIterable, Identifiable, Hashable {
}
}
enum NotificationPermissionState: String, CaseIterable, Identifiable {
enum NotificationPermissionState: String, CaseIterable, Identifiable, Codable {
case unknown
case allowed
case provisional
@@ -72,7 +72,7 @@ struct BootstrapContext {
let suggestedPairingPayload: String
}
enum PairingTransport: String, Hashable {
enum PairingTransport: String, Hashable, Codable {
case qr
case nfc
case manual
@@ -98,7 +98,7 @@ struct PairingAuthenticationRequest: Hashable {
let signedGPSPosition: SignedGPSPosition?
}
struct SignedGPSPosition: Hashable {
struct SignedGPSPosition: Hashable, Codable {
let latitude: Double
let longitude: Double
let horizontalAccuracyMeters: Double
@@ -185,7 +185,7 @@ struct SignInResult {
let snapshot: DashboardSnapshot
}
struct MemberProfile: Identifiable, Hashable {
struct MemberProfile: Identifiable, Hashable, Codable {
let id: UUID
let name: String
let handle: String
@@ -210,7 +210,7 @@ struct MemberProfile: Identifiable, Hashable {
}
}
struct AuthSession: Identifiable, Hashable {
struct AuthSession: Identifiable, Hashable, Codable {
let id: UUID
let deviceName: String
let originHost: String
@@ -241,7 +241,7 @@ struct AuthSession: Identifiable, Hashable {
}
}
enum ApprovalRequestKind: String, CaseIterable, Hashable {
enum ApprovalRequestKind: String, CaseIterable, Hashable, Codable {
case signIn
case accessGrant
case elevatedAction
@@ -263,7 +263,7 @@ enum ApprovalRequestKind: String, CaseIterable, Hashable {
}
}
enum ApprovalRisk: String, Hashable {
enum ApprovalRisk: String, Hashable, Codable {
case routine
case elevated
@@ -293,7 +293,7 @@ enum ApprovalRisk: String, Hashable {
}
}
enum ApprovalStatus: String, Hashable {
enum ApprovalStatus: String, Hashable, Codable {
case pending
case approved
case rejected
@@ -315,7 +315,7 @@ enum ApprovalStatus: String, Hashable {
}
}
struct ApprovalRequest: Identifiable, Hashable {
struct ApprovalRequest: Identifiable, Hashable, Codable {
let id: UUID
let title: String
let subtitle: String
@@ -382,7 +382,7 @@ struct ApprovalRequest: Identifiable, Hashable {
}
}
enum AppNotificationKind: String, Hashable {
enum AppNotificationKind: String, Hashable, Codable {
case approval
case security
case system
@@ -415,7 +415,7 @@ enum AppNotificationKind: String, Hashable {
}
}
struct AppNotification: Identifiable, Hashable {
struct AppNotification: Identifiable, Hashable, Codable {
let id: UUID
let title: String
let message: String
@@ -440,7 +440,7 @@ struct AppNotification: Identifiable, Hashable {
}
}
enum AppError: LocalizedError {
enum AppError: LocalizedError, Equatable {
case invalidPairingPayload
case missingSignedGPSPosition
case invalidSignedGPSPosition
+46
View File
@@ -0,0 +1,46 @@
import Foundation
struct PersistedAppState: Codable, Equatable {
let session: AuthSession
let profile: MemberProfile
let requests: [ApprovalRequest]
let notifications: [AppNotification]
}
protocol AppStateStoring {
func load() -> PersistedAppState?
func save(_ state: PersistedAppState)
func clear()
}
final class UserDefaultsAppStateStore: AppStateStoring {
private let defaults: UserDefaults
private let storageKey: String
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(defaults: UserDefaults = .standard, storageKey: String = "persisted-app-state") {
self.defaults = defaults
self.storageKey = storageKey
}
func load() -> PersistedAppState? {
guard let data = defaults.data(forKey: storageKey) else {
return nil
}
return try? decoder.decode(PersistedAppState.self, from: data)
}
func save(_ state: PersistedAppState) {
guard let data = try? encoder.encode(state) else {
return
}
defaults.set(data, forKey: storageKey)
}
func clear() {
defaults.removeObject(forKey: storageKey)
}
}
+3 -36
View File
@@ -61,7 +61,7 @@ actor MockIDPService: IDPServicing {
try await Task.sleep(for: .milliseconds(180))
try validateSignedGPSPosition(in: request)
let context = try parsePayloadContext(from: request.pairingPayload)
let context = try PairingPayloadParser.parse(request.pairingPayload)
notifications.insert(
AppNotification(
title: "Identity proof completed",
@@ -186,7 +186,7 @@ actor MockIDPService: IDPServicing {
}
private func parseSession(from request: PairingAuthenticationRequest) throws -> AuthSession {
let context = try parsePayloadContext(from: request.pairingPayload)
let context = try PairingPayloadParser.parse(request.pairingPayload)
return AuthSession(
deviceName: context.deviceName,
@@ -199,33 +199,6 @@ actor MockIDPService: IDPServicing {
)
}
private func parsePayloadContext(from payload: String) throws -> PayloadContext {
if let components = URLComponents(string: payload),
components.scheme == "idp.global",
components.host == "pair" {
let queryItems = components.queryItems ?? []
let token = queryItems.first(where: { $0.name == "token" })?.value ?? "demo-token"
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
return PayloadContext(
deviceName: device,
originHost: origin,
tokenPreview: String(token.suffix(6))
)
}
if payload.contains("token") || payload.contains("pair") {
return PayloadContext(
deviceName: "Manual Session",
originHost: "code.foss.global",
tokenPreview: String(payload.suffix(6))
)
}
throw AppError.invalidPairingPayload
}
private func pairingMessage(for session: AuthSession) -> String {
let transportSummary: String
switch session.pairingTransport {
@@ -246,7 +219,7 @@ actor MockIDPService: IDPServicing {
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost)."
}
private func identificationMessage(for context: PayloadContext, signedGPSPosition: SignedGPSPosition?) -> String {
private func identificationMessage(for context: PairingPayloadContext, signedGPSPosition: SignedGPSPosition?) -> String {
if let signedGPSPosition {
return "A signed GPS proof was sent for \(context.deviceName) on \(context.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
}
@@ -254,12 +227,6 @@ actor MockIDPService: IDPServicing {
return "An identity proof was completed for \(context.deviceName) on \(context.originHost)."
}
private struct PayloadContext {
let deviceName: String
let originHost: String
let tokenPreview: String
}
private static func seedRequests() -> [ApprovalRequest] {
[
ApprovalRequest(
@@ -0,0 +1,19 @@
import CryptoKit
import Foundation
enum OneTimePasscodeGenerator {
static func code(for pairingCode: String, at date: Date) -> String {
let timeSlot = Int(date.timeIntervalSince1970 / 30)
let digest = SHA256.hash(data: Data("\(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))
}
static func renewalCountdown(at date: Date) -> Int {
let elapsed = Int(date.timeIntervalSince1970) % 30
return elapsed == 0 ? 30 : 30 - elapsed
}
}
@@ -0,0 +1,38 @@
import Foundation
struct PairingPayloadContext: Equatable {
let deviceName: String
let originHost: String
let tokenPreview: String
}
enum PairingPayloadParser {
static func parse(_ payload: String) throws -> PairingPayloadContext {
let trimmedPayload = payload.trimmingCharacters(in: .whitespacesAndNewlines)
if let components = URLComponents(string: trimmedPayload),
components.scheme == "idp.global",
components.host == "pair" {
let queryItems = components.queryItems ?? []
let token = queryItems.first(where: { $0.name == "token" })?.value ?? "demo-token"
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
return PairingPayloadContext(
deviceName: device,
originHost: origin,
tokenPreview: String(token.suffix(6))
)
}
if trimmedPayload.contains("token") || trimmedPayload.contains("pair") {
return PairingPayloadContext(
deviceName: "Manual Session",
originHost: "code.foss.global",
tokenPreview: String(trimmedPayload.suffix(6))
)
}
throw AppError.invalidPairingPayload
}
}
+330
View File
@@ -0,0 +1,330 @@
import SwiftUI
struct RequestList: View {
let requests: [ApprovalRequest]
let compactLayout: Bool
let activeRequestID: ApprovalRequest.ID?
let onApprove: ((ApprovalRequest) -> Void)?
let onReject: ((ApprovalRequest) -> Void)?
let onOpenRequest: (ApprovalRequest) -> Void
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
}
}
}
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
}
}
}
struct NotificationBellButton: View {
@ObservedObject var model: AppViewModel
var body: some View {
Button {
model.isNotificationCenterPresented = true
} label: {
Image(systemName: imageName)
.font(.headline)
.foregroundStyle(iconTone)
.frame(width: 28, height: 28, alignment: .center)
.background(alignment: .center) {
#if os(iOS)
GeometryReader { proxy in
Color.clear
.preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global))
}
#endif
}
}
.accessibilityLabel("Notifications")
}
private var imageName: String {
#if os(iOS)
model.unreadNotificationCount == 0 ? "bell" : "bell.fill"
#else
model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill"
#endif
}
private var iconTone: some ShapeStyle {
model.unreadNotificationCount == 0 ? Color.primary : dashboardAccent
}
}
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
}
}
+317
View File
@@ -0,0 +1,317 @@
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)
}
}
+4 -853
View File
@@ -1,11 +1,9 @@
import CryptoKit
import Foundation
import SwiftUI
private let dashboardAccent = AppTheme.accent
private let dashboardGold = AppTheme.warmAccent
let dashboardAccent = AppTheme.accent
let dashboardGold = AppTheme.warmAccent
private extension View {
extension View {
@ViewBuilder
func inlineNavigationTitleOnIOS() -> some View {
#if os(iOS)
@@ -123,7 +121,7 @@ private struct DashboardToolbar: ToolbarContent {
}
}
private struct NotificationBellFrameKey: PreferenceKey {
struct NotificationBellFrameKey: PreferenceKey {
static var defaultValue: CGRect? = nil
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
@@ -356,850 +354,3 @@ private struct SidebarStatusCard: View {
.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: {
Image(systemName: imageName)
.font(.headline)
.foregroundStyle(iconTone)
.frame(width: 28, height: 28, alignment: .center)
.background(alignment: .center) {
#if os(iOS)
GeometryReader { proxy in
Color.clear
.preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global))
}
#endif
}
}
.accessibilityLabel("Notifications")
}
private var imageName: String {
#if os(iOS)
model.unreadNotificationCount == 0 ? "bell" : "bell.fill"
#else
model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill"
#endif
}
private var iconTone: some ShapeStyle {
model.unreadNotificationCount == 0 ? Color.primary : dashboardAccent
}
}
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)
}
}
+188
View File
@@ -0,0 +1,188 @@
import SwiftUI
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)
}
}
}
}
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 = OneTimePasscodeGenerator.code(for: session.pairingCode, at: context.date)
let secondsRemaining = OneTimePasscodeGenerator.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
}
}