From 6936ad5cfe96d3515cfcdd0be56d08db6e3b338a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Kunz?= Date: Fri, 17 Apr 2026 22:08:27 +0200 Subject: [PATCH] Build passport-style identity app shell --- .gitignore | 5 + IDPGlobal.xcodeproj/project.pbxproj | 359 ++ README.md | 34 + Sources/App/AppViewModel.swift | 238 ++ Sources/App/IDPGlobalApp.swift | 63 + Sources/Core/Models/AppModels.swift | 346 ++ Sources/Core/Services/MockIDPService.swift | 246 ++ .../Services/NotificationCoordinator.swift | 54 + Sources/Features/Auth/LoginRootView.swift | 300 ++ Sources/Features/Auth/QRScannerView.swift | 359 ++ Sources/Features/Home/HomeRootView.swift | 2918 +++++++++++++++++ 11 files changed, 4922 insertions(+) create mode 100644 .gitignore create mode 100644 IDPGlobal.xcodeproj/project.pbxproj create mode 100644 README.md create mode 100644 Sources/App/AppViewModel.swift create mode 100644 Sources/App/IDPGlobalApp.swift create mode 100644 Sources/Core/Models/AppModels.swift create mode 100644 Sources/Core/Services/MockIDPService.swift create mode 100644 Sources/Core/Services/NotificationCoordinator.swift create mode 100644 Sources/Features/Auth/LoginRootView.swift create mode 100644 Sources/Features/Auth/QRScannerView.swift create mode 100644 Sources/Features/Home/HomeRootView.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8c1e30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +build/ +DerivedData/ +xcuserdata/ +*.xcuserstate diff --git a/IDPGlobal.xcodeproj/project.pbxproj b/IDPGlobal.xcodeproj/project.pbxproj new file mode 100644 index 0000000..575f3f6 --- /dev/null +++ b/IDPGlobal.xcodeproj/project.pbxproj @@ -0,0 +1,359 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + B10000000000000000000001 /* IDPGlobalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* IDPGlobalApp.swift */; }; + B10000000000000000000002 /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002 /* AppViewModel.swift */; }; + B10000000000000000000003 /* AppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000003 /* AppModels.swift */; }; + B10000000000000000000004 /* MockIDPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004 /* MockIDPService.swift */; }; + B10000000000000000000005 /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000005 /* NotificationCoordinator.swift */; }; + B10000000000000000000006 /* LoginRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000006 /* LoginRootView.swift */; }; + B10000000000000000000007 /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000007 /* QRScannerView.swift */; }; + B10000000000000000000008 /* HomeRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000008 /* HomeRootView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + B20000000000000000000001 /* IDPGlobalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDPGlobalApp.swift; sourceTree = ""; }; + B20000000000000000000002 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = ""; }; + B20000000000000000000003 /* AppModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModels.swift; sourceTree = ""; }; + B20000000000000000000004 /* MockIDPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIDPService.swift; sourceTree = ""; }; + B20000000000000000000005 /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = ""; }; + B20000000000000000000006 /* LoginRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginRootView.swift; sourceTree = ""; }; + B20000000000000000000007 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = ""; }; + B20000000000000000000008 /* HomeRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeRootView.swift; sourceTree = ""; }; + B20000000000000000000009 /* IDPGlobal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IDPGlobal.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + B30000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + B40000000000000000000001 = { + isa = PBXGroup; + children = ( + B40000000000000000000002 /* IDPGlobal */, + B40000000000000000000009 /* Products */, + ); + sourceTree = ""; + }; + B40000000000000000000002 /* IDPGlobal */ = { + isa = PBXGroup; + children = ( + B40000000000000000000003 /* Sources */, + ); + name = IDPGlobal; + sourceTree = ""; + }; + B40000000000000000000003 /* Sources */ = { + isa = PBXGroup; + children = ( + B40000000000000000000004 /* App */, + B40000000000000000000005 /* Core */, + B40000000000000000000008 /* Features */, + ); + path = Sources; + sourceTree = ""; + }; + B40000000000000000000004 /* App */ = { + isa = PBXGroup; + children = ( + B20000000000000000000001 /* IDPGlobalApp.swift */, + B20000000000000000000002 /* AppViewModel.swift */, + ); + path = App; + sourceTree = ""; + }; + B40000000000000000000005 /* Core */ = { + isa = PBXGroup; + children = ( + B40000000000000000000006 /* Models */, + B40000000000000000000007 /* Services */, + ); + path = Core; + sourceTree = ""; + }; + B40000000000000000000006 /* Models */ = { + isa = PBXGroup; + children = ( + B20000000000000000000003 /* AppModels.swift */, + ); + path = Models; + sourceTree = ""; + }; + B40000000000000000000007 /* Services */ = { + isa = PBXGroup; + children = ( + B20000000000000000000004 /* MockIDPService.swift */, + B20000000000000000000005 /* NotificationCoordinator.swift */, + ); + path = Services; + sourceTree = ""; + }; + B40000000000000000000008 /* Features */ = { + isa = PBXGroup; + children = ( + B4000000000000000000000A /* Auth */, + B4000000000000000000000B /* Home */, + ); + path = Features; + sourceTree = ""; + }; + B40000000000000000000009 /* Products */ = { + isa = PBXGroup; + children = ( + B20000000000000000000009 /* IDPGlobal.app */, + ); + name = Products; + sourceTree = ""; + }; + B4000000000000000000000A /* Auth */ = { + isa = PBXGroup; + children = ( + B20000000000000000000006 /* LoginRootView.swift */, + B20000000000000000000007 /* QRScannerView.swift */, + ); + path = Auth; + sourceTree = ""; + }; + B4000000000000000000000B /* Home */ = { + isa = PBXGroup; + children = ( + B20000000000000000000008 /* HomeRootView.swift */, + ); + path = Home; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + B50000000000000000000001 /* IDPGlobal */ = { + isa = PBXNativeTarget; + buildConfigurationList = B70000000000000000000002 /* Build configuration list for PBXNativeTarget "IDPGlobal" */; + buildPhases = ( + B30000000000000000000002 /* Sources */, + B30000000000000000000001 /* Frameworks */, + B30000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = IDPGlobal; + productName = IDPGlobal; + productReference = B20000000000000000000009 /* IDPGlobal.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B60000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + B50000000000000000000001 = { + CreatedOnToolsVersion = 26.0; + }; + }; + }; + buildConfigurationList = B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = B40000000000000000000001; + productRefGroup = B40000000000000000000009 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B50000000000000000000001 /* IDPGlobal */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B30000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + B30000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000002 /* AppViewModel.swift in Sources */, + B10000000000000000000008 /* HomeRootView.swift in Sources */, + B10000000000000000000001 /* IDPGlobalApp.swift in Sources */, + B10000000000000000000006 /* LoginRootView.swift in Sources */, + B10000000000000000000004 /* MockIDPService.swift in Sources */, + B10000000000000000000005 /* NotificationCoordinator.swift in Sources */, + B10000000000000000000003 /* AppModels.swift in Sources */, + B10000000000000000000007 /* QRScannerView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + B80000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; + SDKROOT = auto; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + B80000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; + SDKROOT = auto; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + B80000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "idp.global"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan pairing QR codes from the idp.global web portal."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = global.idp.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBSERVATION_ENABLED = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + B80000000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "idp.global"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan pairing QR codes from the idp.global web portal."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = global.idp.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBSERVATION_ENABLED = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B80000000000000000000001 /* Debug */, + B80000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B70000000000000000000002 /* Build configuration list for PBXNativeTarget "IDPGlobal" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B80000000000000000000003 /* Debug */, + B80000000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = B60000000000000000000001 /* Project object */; +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..5dd52b9 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# idp.global Swift App + +Multiplatform SwiftUI scaffold for the personal `idp.global` companion app on iPhone, iPad, and Mac. + +## Included in this first pass + +- QR-based sign-in flow with a live camera scanner and a seeded mock QR payload fallback +- Mocked approval inbox for accepting or rejecting identity requests +- Notification center with local notification permission flow and a test notification trigger +- Shared app state and mock backend boundary so a real API can be connected later + +## Open the project + +1. Open [IDPGlobal.xcodeproj](/Users/philkunz/gitea/idp.global-swiftapp/IDPGlobal/IDPGlobal.xcodeproj). +2. Build the `IDPGlobal` scheme for: + - `My Mac` + - an iPad simulator + - an iPhone simulator + +## Mock QR payload + +The app seeds this pairing payload on first launch: + +`idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP` + +You can paste it manually or use the "Use Mock QR" action while the backend is still mocked. + +## Next integration step + +Replace `MockIDPService` with a live service that: + +- exchanges the QR payload for a session token +- loads approval requests and notifications from the backend +- posts approval decisions back to `idp.global` diff --git a/Sources/App/AppViewModel.swift b/Sources/App/AppViewModel.swift new file mode 100644 index 0000000..cb7e37e --- /dev/null +++ b/Sources/App/AppViewModel.swift @@ -0,0 +1,238 @@ +import Combine +import Foundation + +@MainActor +final class AppViewModel: ObservableObject { + @Published var suggestedQRCodePayload = "" + @Published var manualQRCodePayload = "" + @Published var session: AuthSession? + @Published var profile: MemberProfile? + @Published var requests: [ApprovalRequest] = [] + @Published var notifications: [AppNotification] = [] + @Published var notificationPermission: NotificationPermissionState = .unknown + @Published var selectedSection: AppSection = .overview + @Published var isBootstrapping = false + @Published var isAuthenticating = false + @Published var isRefreshing = false + @Published var isNotificationCenterPresented = false + @Published var activeRequestID: ApprovalRequest.ID? + @Published var isScannerPresented = false + @Published var bannerMessage: String? + @Published var errorMessage: String? + + private var hasBootstrapped = false + private let service: IDPServicing + private let notificationCoordinator: NotificationCoordinating + private let launchArguments: [String] + + private var preferredLaunchSection: AppSection? { + guard let argument = launchArguments.first(where: { $0.hasPrefix("--mock-section=") }) else { + return nil + } + + let rawValue = String(argument.dropFirst("--mock-section=".count)) + if rawValue == "notifications" { + return .activity + } + return AppSection(rawValue: rawValue) + } + + init( + service: IDPServicing = MockIDPService(), + notificationCoordinator: NotificationCoordinating = NotificationCoordinator(), + launchArguments: [String] = ProcessInfo.processInfo.arguments + ) { + self.service = service + self.notificationCoordinator = notificationCoordinator + self.launchArguments = launchArguments + } + + var pendingRequests: [ApprovalRequest] { + requests + .filter { $0.status == .pending } + .sorted { $0.createdAt > $1.createdAt } + } + + var handledRequests: [ApprovalRequest] { + requests + .filter { $0.status != .pending } + .sorted { $0.createdAt > $1.createdAt } + } + + var unreadNotificationCount: Int { + notifications.filter(\.isUnread).count + } + + var elevatedPendingCount: Int { + pendingRequests.filter { $0.risk == .elevated }.count + } + + var latestNotification: AppNotification? { + notifications.first + } + + var pairedDeviceSummary: String { + session?.deviceName ?? "No active device" + } + + func bootstrap() async { + guard !hasBootstrapped else { return } + hasBootstrapped = true + + isBootstrapping = true + defer { isBootstrapping = false } + + do { + let bootstrap = try await service.bootstrap() + suggestedQRCodePayload = bootstrap.suggestedQRCodePayload + manualQRCodePayload = bootstrap.suggestedQRCodePayload + notificationPermission = await notificationCoordinator.authorizationStatus() + + if launchArguments.contains("--mock-auto-pair"), + session == nil { + await signIn(with: bootstrap.suggestedQRCodePayload) + + if let preferredLaunchSection { + selectedSection = preferredLaunchSection + } + } + } catch { + errorMessage = "Unable to prepare the app." + } + } + + func signInWithManualCode() async { + await signIn(with: manualQRCodePayload) + } + + func signInWithSuggestedCode() async { + manualQRCodePayload = suggestedQRCodePayload + await signIn(with: suggestedQRCodePayload) + } + + func signIn(with payload: String) async { + let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + errorMessage = "Paste or scan a QR payload first." + return + } + + isAuthenticating = true + defer { isAuthenticating = false } + + do { + let result = try await service.signIn(withQRCode: trimmed) + session = result.session + apply(snapshot: result.snapshot) + notificationPermission = await notificationCoordinator.authorizationStatus() + selectedSection = .overview + bannerMessage = "Paired with \(result.session.deviceName)." + isScannerPresented = false + } catch let error as AppError { + errorMessage = error.errorDescription + } catch { + errorMessage = "Unable to complete sign-in." + } + } + + func refreshDashboard() async { + guard session != nil else { return } + + isRefreshing = true + defer { isRefreshing = false } + + do { + let snapshot = try await service.refreshDashboard() + apply(snapshot: snapshot) + } catch { + errorMessage = "Unable to refresh the dashboard." + } + } + + func approve(_ request: ApprovalRequest) async { + await mutateRequest(request, approve: true) + } + + func reject(_ request: ApprovalRequest) async { + await mutateRequest(request, approve: false) + } + + func simulateIncomingRequest() async { + guard session != nil else { return } + + do { + let snapshot = try await service.simulateIncomingRequest() + apply(snapshot: snapshot) + selectedSection = .requests + bannerMessage = "A new mock approval request arrived." + } catch { + errorMessage = "Unable to seed a new request right now." + } + } + + func requestNotificationAccess() async { + do { + notificationPermission = try await notificationCoordinator.requestAuthorization() + if notificationPermission == .allowed || notificationPermission == .provisional { + bannerMessage = "Notifications are ready on this device." + } + } catch { + errorMessage = "Unable to update notification permission." + } + } + + func sendTestNotification() async { + do { + try await notificationCoordinator.scheduleTestNotification( + title: "idp.global approval pending", + body: "A mock request is waiting for approval in the app." + ) + bannerMessage = "A local test notification will appear in a few seconds." + notificationPermission = await notificationCoordinator.authorizationStatus() + } catch { + errorMessage = "Unable to schedule a test notification." + } + } + + func markNotificationRead(_ notification: AppNotification) async { + do { + let snapshot = try await service.markNotificationRead(id: notification.id) + apply(snapshot: snapshot) + } catch { + errorMessage = "Unable to update the notification." + } + } + + func signOut() { + session = nil + profile = nil + requests = [] + notifications = [] + selectedSection = .overview + bannerMessage = nil + manualQRCodePayload = suggestedQRCodePayload + } + + private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async { + guard session != nil else { return } + + activeRequestID = request.id + defer { activeRequestID = nil } + + do { + let snapshot = approve + ? try await service.approveRequest(id: request.id) + : try await service.rejectRequest(id: request.id) + apply(snapshot: snapshot) + bannerMessage = approve ? "Request approved for \(request.source)." : "Request rejected for \(request.source)." + } catch { + errorMessage = "Unable to update the request." + } + } + + private func apply(snapshot: DashboardSnapshot) { + profile = snapshot.profile + requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt } + notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt } + } +} diff --git a/Sources/App/IDPGlobalApp.swift b/Sources/App/IDPGlobalApp.swift new file mode 100644 index 0000000..442bd8c --- /dev/null +++ b/Sources/App/IDPGlobalApp.swift @@ -0,0 +1,63 @@ +import SwiftUI + +@main +struct IDPGlobalApp: App { + @StateObject private var model = AppViewModel() + + var body: some Scene { + WindowGroup { + RootView(model: model) + .tint(Color(red: 0.12, green: 0.40, blue: 0.31)) + .task { + await model.bootstrap() + } + .alert("Something went wrong", isPresented: errorPresented) { + Button("OK") { + model.errorMessage = nil + } + } message: { + Text(model.errorMessage ?? "") + } + } + #if os(macOS) + .defaultSize(width: 1380, height: 920) + #endif + } + + private var errorPresented: Binding { + Binding( + get: { model.errorMessage != nil }, + set: { isPresented in + if !isPresented { + model.errorMessage = nil + } + } + ) + } +} + +private struct RootView: View { + @ObservedObject var model: AppViewModel + + var body: some View { + Group { + if model.session == nil { + LoginRootView(model: model) + } else { + HomeRootView(model: model) + } + } + .background( + LinearGradient( + colors: [ + Color(red: 0.96, green: 0.97, blue: 0.94), + Color(red: 0.89, green: 0.94, blue: 0.92), + Color(red: 0.94, green: 0.91, blue: 0.84) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + ) + } +} diff --git a/Sources/Core/Models/AppModels.swift b/Sources/Core/Models/AppModels.swift new file mode 100644 index 0000000..5f84dd6 --- /dev/null +++ b/Sources/Core/Models/AppModels.swift @@ -0,0 +1,346 @@ +import Foundation + +enum AppSection: String, CaseIterable, Identifiable, Hashable { + case overview + case requests + case activity + case account + + var id: String { rawValue } + + var title: String { + switch self { + case .overview: "Passport" + case .requests: "Requests" + case .activity: "Activity" + case .account: "Account" + } + } + + var systemImage: String { + switch self { + case .overview: "person.crop.square.fill" + case .requests: "checklist.checked" + case .activity: "clock.arrow.trianglehead.counterclockwise.rotate.90" + case .account: "person.crop.circle.fill" + } + } +} + +enum NotificationPermissionState: String, CaseIterable, Identifiable { + case unknown + case allowed + case provisional + case denied + + var id: String { rawValue } + + var title: String { + switch self { + case .unknown: "Not Asked Yet" + case .allowed: "Enabled" + case .provisional: "Delivered Quietly" + case .denied: "Disabled" + } + } + + var systemImage: String { + switch self { + case .unknown: "bell" + case .allowed: "bell.badge.fill" + case .provisional: "bell.badge" + case .denied: "bell.slash.fill" + } + } + + var summary: String { + switch self { + case .unknown: + "The app has not asked for notification delivery yet." + case .allowed: + "Alerts can break through immediately when a request arrives." + case .provisional: + "Notifications can be delivered quietly until the user promotes them." + case .denied: + "Approval events stay in-app until the user re-enables notifications." + } + } +} + +struct BootstrapContext { + let suggestedQRCodePayload: String +} + +struct DashboardSnapshot { + let profile: MemberProfile + let requests: [ApprovalRequest] + let notifications: [AppNotification] +} + +struct SignInResult { + let session: AuthSession + let snapshot: DashboardSnapshot +} + +struct MemberProfile: Identifiable, Hashable { + let id: UUID + let name: String + let handle: String + let organization: String + let deviceCount: Int + let recoverySummary: String + + init( + id: UUID = UUID(), + name: String, + handle: String, + organization: String, + deviceCount: Int, + recoverySummary: String + ) { + self.id = id + self.name = name + self.handle = handle + self.organization = organization + self.deviceCount = deviceCount + self.recoverySummary = recoverySummary + } +} + +struct AuthSession: Identifiable, Hashable { + let id: UUID + let deviceName: String + let originHost: String + let pairedAt: Date + let tokenPreview: String + let pairingCode: String + + init( + id: UUID = UUID(), + deviceName: String, + originHost: String, + pairedAt: Date, + tokenPreview: String, + pairingCode: String + ) { + self.id = id + self.deviceName = deviceName + self.originHost = originHost + self.pairedAt = pairedAt + self.tokenPreview = tokenPreview + self.pairingCode = pairingCode + } +} + +enum ApprovalRequestKind: String, CaseIterable, Hashable { + case signIn + case accessGrant + case elevatedAction + + var title: String { + switch self { + case .signIn: "Sign-In" + case .accessGrant: "Access Grant" + case .elevatedAction: "Elevated Action" + } + } + + var systemImage: String { + switch self { + case .signIn: "qrcode.viewfinder" + case .accessGrant: "key.fill" + case .elevatedAction: "shield.lefthalf.filled" + } + } +} + +enum ApprovalRisk: String, Hashable { + case routine + case elevated + + var title: String { + switch self { + case .routine: "Routine" + case .elevated: "Elevated" + } + } + + var summary: String { + switch self { + case .routine: + "Routine access to profile or sign-in scopes." + case .elevated: + "Sensitive access that can sign, publish, or unlock privileged actions." + } + } + + var guidance: String { + switch self { + case .routine: + "Review the origin and scope list, then approve if the session matches the device you expect." + case .elevated: + "Treat this like a privileged operation. Verify the origin, the requested scopes, and whether the action is time-bound before approving." + } + } +} + +enum ApprovalStatus: String, Hashable { + case pending + case approved + case rejected + + var title: String { + switch self { + case .pending: "Pending" + case .approved: "Approved" + case .rejected: "Rejected" + } + } + + var systemImage: String { + switch self { + case .pending: "clock.badge" + case .approved: "checkmark.circle.fill" + case .rejected: "xmark.circle.fill" + } + } +} + +struct ApprovalRequest: Identifiable, Hashable { + let id: UUID + let title: String + let subtitle: String + let source: String + let createdAt: Date + let kind: ApprovalRequestKind + let risk: ApprovalRisk + let scopes: [String] + var status: ApprovalStatus + + init( + id: UUID = UUID(), + title: String, + subtitle: String, + source: String, + createdAt: Date, + kind: ApprovalRequestKind, + risk: ApprovalRisk, + scopes: [String], + status: ApprovalStatus + ) { + self.id = id + self.title = title + self.subtitle = subtitle + self.source = source + self.createdAt = createdAt + self.kind = kind + self.risk = risk + self.scopes = scopes + self.status = status + } + + var scopeSummary: String { + if scopes.isEmpty { + return "No scopes listed" + } + + let suffix = scopes.count == 1 ? "" : "s" + return "\(scopes.count) requested scope\(suffix)" + } + + var trustHeadline: String { + switch (kind, risk) { + case (.signIn, .routine): + "Low-friction sign-in request" + case (.signIn, .elevated): + "Privileged sign-in request" + case (.accessGrant, _): + "Token grant request" + case (.elevatedAction, _): + "Sensitive action request" + } + } + + var trustDetail: String { + switch kind { + case .signIn: + "This request usually creates or refreshes a session token for a browser, CLI, or device." + case .accessGrant: + "This request issues scoped access for a service or automation that wants to act on your behalf." + case .elevatedAction: + "This request performs a privileged action such as signing, publishing, or creating short-lived credentials." + } + } +} + +enum AppNotificationKind: String, Hashable { + case approval + case security + case system + + var title: String { + switch self { + case .approval: "Approval" + case .security: "Security" + case .system: "System" + } + } + + var systemImage: String { + switch self { + case .approval: "checkmark.seal.fill" + case .security: "shield.fill" + case .system: "sparkles" + } + } + + var summary: String { + switch self { + case .approval: + "Decision and approval activity" + case .security: + "Pairing and security posture updates" + case .system: + "Product and environment status messages" + } + } +} + +struct AppNotification: Identifiable, Hashable { + let id: UUID + let title: String + let message: String + let sentAt: Date + let kind: AppNotificationKind + var isUnread: Bool + + init( + id: UUID = UUID(), + title: String, + message: String, + sentAt: Date, + kind: AppNotificationKind, + isUnread: Bool + ) { + self.id = id + self.title = title + self.message = message + self.sentAt = sentAt + self.kind = kind + self.isUnread = isUnread + } +} + +enum AppError: LocalizedError { + case invalidQRCode + case requestNotFound + + var errorDescription: String? { + switch self { + case .invalidQRCode: + "That QR payload is not valid for idp.global sign-in." + case .requestNotFound: + "The selected request could not be found." + } + } +} diff --git a/Sources/Core/Services/MockIDPService.swift b/Sources/Core/Services/MockIDPService.swift new file mode 100644 index 0000000..5723449 --- /dev/null +++ b/Sources/Core/Services/MockIDPService.swift @@ -0,0 +1,246 @@ +import Foundation + +protocol IDPServicing { + func bootstrap() async throws -> BootstrapContext + func signIn(withQRCode payload: String) async throws -> SignInResult + func refreshDashboard() async throws -> DashboardSnapshot + func approveRequest(id: UUID) async throws -> DashboardSnapshot + func rejectRequest(id: UUID) async throws -> DashboardSnapshot + func simulateIncomingRequest() async throws -> DashboardSnapshot + func markNotificationRead(id: UUID) async throws -> DashboardSnapshot +} + +actor MockIDPService: IDPServicing { + private let profile = MemberProfile( + name: "Phil Kunz", + handle: "phil@idp.global", + organization: "idp.global", + deviceCount: 4, + recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified." + ) + + private var requests: [ApprovalRequest] = [] + private var notifications: [AppNotification] = [] + + init() { + requests = Self.seedRequests() + notifications = Self.seedNotifications() + } + + func bootstrap() async throws -> BootstrapContext { + try await Task.sleep(for: .milliseconds(120)) + return BootstrapContext( + suggestedQRCodePayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP" + ) + } + + func signIn(withQRCode payload: String) async throws -> SignInResult { + try await Task.sleep(for: .milliseconds(260)) + + let session = try parseSession(from: payload) + notifications.insert( + AppNotification( + title: "New device paired", + message: "\(session.deviceName) completed a QR pairing against \(session.originHost).", + sentAt: .now, + kind: .security, + isUnread: true + ), + at: 0 + ) + + return SignInResult( + session: session, + snapshot: snapshot() + ) + } + + func refreshDashboard() async throws -> DashboardSnapshot { + try await Task.sleep(for: .milliseconds(180)) + return snapshot() + } + + func approveRequest(id: UUID) async throws -> DashboardSnapshot { + try await Task.sleep(for: .milliseconds(150)) + + guard let index = requests.firstIndex(where: { $0.id == id }) else { + throw AppError.requestNotFound + } + + requests[index].status = .approved + notifications.insert( + AppNotification( + title: "Request approved", + message: "\(requests[index].title) was approved for \(requests[index].source).", + sentAt: .now, + kind: .approval, + isUnread: true + ), + at: 0 + ) + + return snapshot() + } + + func rejectRequest(id: UUID) async throws -> DashboardSnapshot { + try await Task.sleep(for: .milliseconds(150)) + + guard let index = requests.firstIndex(where: { $0.id == id }) else { + throw AppError.requestNotFound + } + + requests[index].status = .rejected + notifications.insert( + AppNotification( + title: "Request rejected", + message: "\(requests[index].title) was rejected before token issuance.", + sentAt: .now, + kind: .security, + isUnread: true + ), + at: 0 + ) + + return snapshot() + } + + func simulateIncomingRequest() async throws -> DashboardSnapshot { + try await Task.sleep(for: .milliseconds(120)) + + let syntheticRequest = ApprovalRequest( + title: "Approve SSH certificate issue", + subtitle: "CI runner wants a short-lived signing certificate for a deployment pipeline.", + source: "deploy.idp.global", + createdAt: .now, + kind: .elevatedAction, + risk: .elevated, + scopes: ["sign:ssh", "ttl:10m", "environment:staging"], + status: .pending + ) + + requests.insert(syntheticRequest, at: 0) + notifications.insert( + AppNotification( + title: "Fresh approval request", + message: "A staging deployment is waiting for your approval.", + sentAt: .now, + kind: .approval, + isUnread: true + ), + at: 0 + ) + + return snapshot() + } + + func markNotificationRead(id: UUID) async throws -> DashboardSnapshot { + try await Task.sleep(for: .milliseconds(80)) + + guard let index = notifications.firstIndex(where: { $0.id == id }) else { + return snapshot() + } + + notifications[index].isUnread = false + return snapshot() + } + + private func snapshot() -> DashboardSnapshot { + DashboardSnapshot( + profile: profile, + requests: requests, + notifications: notifications + ) + } + + private func parseSession(from payload: String) throws -> AuthSession { + 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 AuthSession( + deviceName: device, + originHost: origin, + pairedAt: .now, + tokenPreview: String(token.suffix(6)), + pairingCode: payload + ) + } + + if payload.contains("token") || payload.contains("pair") { + return AuthSession( + deviceName: "Manual Pairing", + originHost: "code.foss.global", + pairedAt: .now, + tokenPreview: String(payload.suffix(6)), + pairingCode: payload + ) + } + + throw AppError.invalidQRCode + } + + private static func seedRequests() -> [ApprovalRequest] { + [ + ApprovalRequest( + title: "Approve Safari sign-in", + subtitle: "A browser session from Berlin wants an SSO token for the portal.", + source: "code.foss.global", + createdAt: .now.addingTimeInterval(-60 * 12), + kind: .signIn, + risk: .routine, + scopes: ["openid", "profile", "groups:read"], + status: .pending + ), + ApprovalRequest( + title: "Grant package publish access", + subtitle: "The release bot is asking for a scoped publish token.", + source: "registry.foss.global", + createdAt: .now.addingTimeInterval(-60 * 42), + kind: .accessGrant, + risk: .elevated, + scopes: ["packages:write", "ttl:30m"], + status: .pending + ), + ApprovalRequest( + title: "Approve CLI login", + subtitle: "A terminal session completed QR pairing earlier today.", + source: "cli.idp.global", + createdAt: .now.addingTimeInterval(-60 * 180), + kind: .signIn, + risk: .routine, + scopes: ["openid", "profile"], + status: .approved + ) + ] + } + + private static func seedNotifications() -> [AppNotification] { + [ + AppNotification( + title: "Two requests are waiting", + message: "The queue includes one routine sign-in and one elevated access grant.", + sentAt: .now.addingTimeInterval(-60 * 8), + kind: .approval, + isUnread: true + ), + AppNotification( + title: "Recovery health check passed", + message: "Backup recovery channels were verified in the last 24 hours.", + sentAt: .now.addingTimeInterval(-60 * 95), + kind: .system, + isUnread: false + ), + AppNotification( + title: "Quiet hours active on mobile", + message: "Routine notifications will be delivered silently until the morning.", + sentAt: .now.addingTimeInterval(-60 * 220), + kind: .security, + isUnread: false + ) + ] + } +} diff --git a/Sources/Core/Services/NotificationCoordinator.swift b/Sources/Core/Services/NotificationCoordinator.swift new file mode 100644 index 0000000..4eeca9e --- /dev/null +++ b/Sources/Core/Services/NotificationCoordinator.swift @@ -0,0 +1,54 @@ +import Foundation +import UserNotifications + +protocol NotificationCoordinating { + func authorizationStatus() async -> NotificationPermissionState + func requestAuthorization() async throws -> NotificationPermissionState + func scheduleTestNotification(title: String, body: String) async throws +} + +final class NotificationCoordinator: NotificationCoordinating { + private let center = UNUserNotificationCenter.current() + + func authorizationStatus() async -> NotificationPermissionState { + let settings = await center.notificationSettings() + return NotificationPermissionState(settings.authorizationStatus) + } + + func requestAuthorization() async throws -> NotificationPermissionState { + _ = try await center.requestAuthorization(options: [.alert, .badge, .sound]) + return await authorizationStatus() + } + + func scheduleTestNotification(title: String, body: String) async throws { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false) + ) + + try await center.add(request) + } +} + +private extension NotificationPermissionState { + init(_ status: UNAuthorizationStatus) { + switch status { + case .authorized: + self = .allowed + case .provisional, .ephemeral: + self = .provisional + case .denied: + self = .denied + case .notDetermined: + self = .unknown + @unknown default: + self = .unknown + } + } +} diff --git a/Sources/Features/Auth/LoginRootView.swift b/Sources/Features/Auth/LoginRootView.swift new file mode 100644 index 0000000..d2e54d3 --- /dev/null +++ b/Sources/Features/Auth/LoginRootView.swift @@ -0,0 +1,300 @@ +import SwiftUI + +private let loginAccent = Color(red: 0.12, green: 0.40, blue: 0.31) +private let loginGold = Color(red: 0.90, green: 0.79, blue: 0.60) + +struct LoginRootView: View { + @ObservedObject var model: AppViewModel + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + var body: some View { + ScrollView { + VStack(spacing: compactLayout ? 18 : 24) { + LoginHeroPanel(model: model, compactLayout: compactLayout) + PairingConsoleCard(model: model, compactLayout: compactLayout) + TrustFootprintCard(model: model, compactLayout: compactLayout) + } + .frame(maxWidth: 1040) + .padding(compactLayout ? 18 : 28) + } + .sheet(isPresented: $model.isScannerPresented) { + QRScannerSheet( + seededPayload: model.suggestedQRCodePayload, + onCodeScanned: { payload in + model.manualQRCodePayload = payload + Task { + await model.signIn(with: payload) + } + } + ) + } + } + + private var compactLayout: Bool { + #if os(iOS) + horizontalSizeClass == .compact + #else + false + #endif + } +} + +private struct LoginHeroPanel: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + var body: some View { + ZStack(alignment: .bottomLeading) { + RoundedRectangle(cornerRadius: 36, style: .continuous) + .fill( + LinearGradient( + colors: [ + Color(red: 0.13, green: 0.22, blue: 0.19), + Color(red: 0.20, green: 0.41, blue: 0.33), + loginGold + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + VStack(alignment: .leading, spacing: compactLayout ? 16 : 18) { + Text("Bind this device to your idp.global account") + .font(.system(size: compactLayout ? 32 : 44, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + Text("Scan the pairing QR from your account to turn this device into your approval and notification app.") + .font(compactLayout ? .body : .title3) + .foregroundStyle(.white.opacity(0.88)) + + if compactLayout { + VStack(alignment: .leading, spacing: 10) { + HeroTag(title: "Account binding") + HeroTag(title: "QR pairing") + HeroTag(title: "iPhone, iPad, Mac") + } + } else { + HStack(spacing: 12) { + HeroTag(title: "Account binding") + HeroTag(title: "QR pairing") + HeroTag(title: "iPhone, iPad, Mac") + } + } + + if model.isBootstrapping { + ProgressView("Preparing preview pairing payload…") + .tint(.white) + } + } + .padding(compactLayout ? 22 : 32) + } + .frame(minHeight: compactLayout ? 280 : 320) + } +} + +private struct PairingConsoleCard: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + var body: some View { + LoginCard(title: "Bind your account", subtitle: "Scan the QR code from your idp.global account or use the preview payload while backend wiring is still in progress.") { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Open your account pairing screen, then scan the QR code here.") + .font(.headline) + Text("If you are testing the preview build without the live backend yet, the seeded payload below will still bind the mock session.") + .foregroundStyle(.secondary) + } + + TextEditor(text: $model.manualQRCodePayload) + .font(.body.monospaced()) + .scrollContentBackground(.hidden) + .padding(16) + .frame(minHeight: compactLayout ? 130 : 150) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + + if model.isAuthenticating { + HStack(spacing: 10) { + ProgressView() + Text("Binding this device to your account…") + .foregroundStyle(.secondary) + } + } + + Group { + if compactLayout { + VStack(spacing: 12) { + primaryButtons + secondaryButtons + } + } else { + VStack(spacing: 12) { + HStack(spacing: 12) { + primaryButtons + } + HStack(spacing: 12) { + secondaryButtons + } + } + } + } + } + } + } + + @ViewBuilder + private var primaryButtons: some View { + Button { + model.isScannerPresented = true + } label: { + Label("Bind With QR Code", systemImage: "qrcode.viewfinder") + } + .buttonStyle(.borderedProminent) + + Button { + Task { + await model.signInWithManualCode() + } + } label: { + if model.isAuthenticating { + ProgressView() + } else { + Label("Bind With Payload", systemImage: "arrow.right.circle.fill") + } + } + .buttonStyle(.bordered) + .disabled(model.isAuthenticating) + } + + @ViewBuilder + private var secondaryButtons: some View { + Button { + Task { + await model.signInWithSuggestedCode() + } + } label: { + Label("Use Preview QR", systemImage: "wand.and.stars") + } + .buttonStyle(.bordered) + + Text("This preview keeps the account-binding flow realistic while the live API is still being wired in.") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .trailing) + } +} + +private struct TrustFootprintCard: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + var body: some View { + LoginCard(title: "About this build", subtitle: "Keep the first-run screen simple, but still explain the trust context and preview status clearly.") { + VStack(alignment: .leading, spacing: 16) { + if compactLayout { + VStack(spacing: 12) { + trustFacts + } + } else { + HStack(alignment: .top, spacing: 12) { + trustFacts + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Preview Pairing Payload") + .font(.headline) + Text(model.suggestedQRCodePayload.isEmpty ? "Preparing preview payload…" : model.suggestedQRCodePayload) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + } + } + } + } + + @ViewBuilder + private var trustFacts: some View { + TrustFactCard( + icon: "person.badge.key.fill", + title: "Account Binding", + message: "This device binds to your idp.global account and becomes your place for approvals and alerts." + ) + TrustFactCard( + icon: "person.2.badge.gearshape.fill", + title: "Built by foss.global", + message: "foss.global is the open-source collective behind idp.global and the current preview environment." + ) + TrustFactCard( + icon: "bolt.badge.clock", + title: "Preview Backend", + message: "Login, requests, and notifications are mocked behind a clean service boundary until live integration is ready." + ) + } +} + +private struct LoginCard: View { + let title: String + let subtitle: String + let content: () -> Content + + init(title: String, subtitle: String, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.subtitle = subtitle + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.title2.weight(.semibold)) + Text(subtitle) + .foregroundStyle(.secondary) + } + + content() + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.68), in: RoundedRectangle(cornerRadius: 32, style: .continuous)) + } +} + +private struct HeroTag: View { + let title: String + + var body: some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +private struct TrustFactCard: View { + let icon: String + let title: String + let message: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(loginAccent) + + Text(title) + .font(.headline) + + Text(message) + .foregroundStyle(.secondary) + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + } +} diff --git a/Sources/Features/Auth/QRScannerView.swift b/Sources/Features/Auth/QRScannerView.swift new file mode 100644 index 0000000..567e224 --- /dev/null +++ b/Sources/Features/Auth/QRScannerView.swift @@ -0,0 +1,359 @@ +import AVFoundation +import Combine +import SwiftUI +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +struct QRScannerSheet: View { + let seededPayload: String + let onCodeScanned: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var manualFallback = "" + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Use the camera to scan the QR code shown by the web portal. If you’re on a simulator or desktop without a camera, the seeded payload works as a mock fallback.") + .foregroundStyle(.secondary) + + LiveQRScannerView(onCodeScanned: onCodeScanned) + .frame(minHeight: 340) + + VStack(alignment: .leading, spacing: 12) { + Text("Fallback Pairing Payload") + .font(.headline) + + TextEditor(text: $manualFallback) + .font(.body.monospaced()) + .scrollContentBackground(.hidden) + .padding(14) + .frame(minHeight: 120) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + + HStack(spacing: 12) { + Button { + let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines) + onCodeScanned(chosen.isEmpty ? seededPayload : chosen) + dismiss() + } label: { + Label("Use Fallback Payload", systemImage: "arrow.up.forward.square") + } + .buttonStyle(.borderedProminent) + + Button { + manualFallback = seededPayload + } label: { + Label("Use Seeded Mock", systemImage: "wand.and.rays") + } + .buttonStyle(.bordered) + } + } + .padding(20) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + } + .padding(24) + } + .navigationTitle("Scan QR Code") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismiss() + } + } + } + .onAppear { + manualFallback = seededPayload + } + } + } +} + +private struct LiveQRScannerView: View { + let onCodeScanned: (String) -> Void + + @StateObject private var scanner = QRScannerViewModel() + + var body: some View { + ZStack(alignment: .bottomLeading) { + Group { + if scanner.isPreviewAvailable { + ScannerPreview(session: scanner.captureSession) + .clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous)) + } else { + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill(Color.black.opacity(0.86)) + + VStack(alignment: .leading, spacing: 12) { + Image(systemName: "video.slash.fill") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(.white) + Text("Live camera preview unavailable") + .font(.title3.weight(.semibold)) + .foregroundStyle(.white) + Text(scanner.statusMessage) + .foregroundStyle(.white.opacity(0.78)) + } + .padding(24) + } + } + + RoundedRectangle(cornerRadius: 30, style: .continuous) + .strokeBorder(.white.opacity(0.22), lineWidth: 1.5) + + VStack(alignment: .leading, spacing: 8) { + Text("Camera Scanner") + .font(.headline.weight(.semibold)) + .foregroundStyle(.white) + Text(scanner.statusMessage) + .foregroundStyle(.white.opacity(0.84)) + } + .padding(22) + + ScanFrameOverlay() + .padding(40) + } + .task { + scanner.onCodeScanned = { payload in + onCodeScanned(payload) + } + await scanner.start() + } + .onDisappear { + scanner.stop() + } + } +} + +private struct ScanFrameOverlay: View { + var body: some View { + GeometryReader { geometry in + let size = min(geometry.size.width, geometry.size.height) * 0.5 + + RoundedRectangle(cornerRadius: 28, style: .continuous) + .strokeBorder(.white.opacity(0.82), style: StrokeStyle(lineWidth: 3, dash: [10, 8])) + .frame(width: size, height: size) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + } + .allowsHitTesting(false) + } +} + +private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { + @Published var isPreviewAvailable = false + @Published var statusMessage = "Point the camera at the QR code from the idp.global web portal." + + let captureSession = AVCaptureSession() + + var onCodeScanned: ((String) -> Void)? + + private let queue = DispatchQueue(label: "global.idp.qrscanner") + private var isConfigured = false + private var hasDeliveredCode = false + + func start() async { + #if os(iOS) && targetEnvironment(simulator) + await MainActor.run { + isPreviewAvailable = false + statusMessage = "The iOS simulator has no live camera feed. Use the seeded payload below." + } + #else + #endif + + #if !(os(iOS) && targetEnvironment(simulator)) + let authorization = AVCaptureDevice.authorizationStatus(for: .video) + switch authorization { + case .authorized: + await configureIfNeeded() + startRunning() + case .notDetermined: + let granted = await requestCameraAccess() + await MainActor.run { + self.statusMessage = granted + ? "Point the camera at the QR code from the idp.global web portal." + : "Camera access was denied. Use the fallback payload below." + } + guard granted else { return } + await configureIfNeeded() + startRunning() + case .denied, .restricted: + await MainActor.run { + isPreviewAvailable = false + statusMessage = "Camera access is unavailable. Use the fallback payload below." + } + @unknown default: + await MainActor.run { + isPreviewAvailable = false + statusMessage = "Camera access could not be initialized on this device." + } + } + #endif + } + + func stop() { + queue.async { + if self.captureSession.isRunning { + self.captureSession.stopRunning() + } + } + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard !hasDeliveredCode, + let readable = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + readable.type == .qr, + let payload = readable.stringValue else { + return + } + + hasDeliveredCode = true + stop() + + #if os(iOS) + UINotificationFeedbackGenerator().notificationOccurred(.success) + #endif + + DispatchQueue.main.async { [onCodeScanned] in + onCodeScanned?(payload) + } + } + + private func requestCameraAccess() async -> Bool { + await withCheckedContinuation { continuation in + AVCaptureDevice.requestAccess(for: .video) { granted in + continuation.resume(returning: granted) + } + } + } + + private func configureIfNeeded() async { + guard !isConfigured else { + await MainActor.run { + self.isPreviewAvailable = true + } + return + } + + await withCheckedContinuation { (continuation: CheckedContinuation) in + queue.async { + self.captureSession.beginConfiguration() + defer { + self.captureSession.commitConfiguration() + continuation.resume() + } + + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + self.captureSession.canAddInput(input) else { + DispatchQueue.main.async { + self.isPreviewAvailable = false + self.statusMessage = "No compatible camera was found. Use the fallback payload below." + } + return + } + + self.captureSession.addInput(input) + + let output = AVCaptureMetadataOutput() + guard self.captureSession.canAddOutput(output) else { + DispatchQueue.main.async { + self.isPreviewAvailable = false + self.statusMessage = "Unable to configure QR metadata scanning on this device." + } + return + } + + self.captureSession.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + output.metadataObjectTypes = [.qr] + self.isConfigured = true + + DispatchQueue.main.async { + self.isPreviewAvailable = true + self.statusMessage = "Point the camera at the QR code from the idp.global web portal." + } + } + } + } + + private func startRunning() { + queue.async { + guard !self.captureSession.isRunning else { return } + self.hasDeliveredCode = false + self.captureSession.startRunning() + } + } +} + +extension QRScannerViewModel: @unchecked Sendable {} + +#if os(iOS) +private struct ScannerPreview: UIViewRepresentable { + let session: AVCaptureSession + + func makeUIView(context: Context) -> ScannerPreviewUIView { + let view = ScannerPreviewUIView() + view.previewLayer.session = session + view.previewLayer.videoGravity = .resizeAspectFill + return view + } + + func updateUIView(_ uiView: ScannerPreviewUIView, context: Context) { + uiView.previewLayer.session = session + } +} + +private final class ScannerPreviewUIView: UIView { + override class var layerClass: AnyClass { + AVCaptureVideoPreviewLayer.self + } + + var previewLayer: AVCaptureVideoPreviewLayer { + layer as! AVCaptureVideoPreviewLayer + } +} +#elseif os(macOS) +private struct ScannerPreview: NSViewRepresentable { + let session: AVCaptureSession + + func makeNSView(context: Context) -> ScannerPreviewNSView { + let view = ScannerPreviewNSView() + view.attach(session: session) + return view + } + + func updateNSView(_ nsView: ScannerPreviewNSView, context: Context) { + nsView.attach(session: session) + } +} + +private final class ScannerPreviewNSView: NSView { + private var previewLayer: AVCaptureVideoPreviewLayer? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + } + + func attach(session: AVCaptureSession) { + let layer = previewLayer ?? AVCaptureVideoPreviewLayer(session: session) + layer.session = session + layer.videoGravity = .resizeAspectFill + self.layer = layer + previewLayer = layer + } +} +#endif diff --git a/Sources/Features/Home/HomeRootView.swift b/Sources/Features/Home/HomeRootView.swift new file mode 100644 index 0000000..5860055 --- /dev/null +++ b/Sources/Features/Home/HomeRootView.swift @@ -0,0 +1,2918 @@ +import SwiftUI + +private let dashboardAccent = Color(red: 0.12, green: 0.40, blue: 0.31) +private let dashboardGold = Color(red: 0.84, green: 0.71, blue: 0.48) + +struct HomeRootView: View { + @ObservedObject var model: AppViewModel + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + var body: some View { + Group { + if usesCompactNavigation { + CompactHomeContainer(model: model) + } else { + RegularHomeContainer(model: model) + } + } + .sheet(isPresented: $model.isNotificationCenterPresented) { + NotificationCenterSheet(model: model) + } + } + + private var usesCompactNavigation: Bool { + #if os(iOS) + horizontalSizeClass == .compact + #else + false + #endif + } +} + +private struct CompactHomeContainer: View { + @ObservedObject var model: AppViewModel + + var body: some View { + TabView(selection: $model.selectedSection) { + compactTab(for: .overview) + compactTab(for: .requests) + compactTab(for: .activity) + compactTab(for: .account) + } + } + + @ViewBuilder + private func compactTab(for section: AppSection) -> some View { + NavigationStack { + HomeSectionScreen(model: model, section: section, compactLayout: true) + .navigationTitle(section.title) + .toolbar { + DashboardToolbar(model: model, compactLayout: true) + } + } + .tag(section) + .tabItem { + Label(section.title, systemImage: section.systemImage) + } + } +} + +private struct RegularHomeContainer: View { + @ObservedObject var model: AppViewModel + + var body: some View { + NavigationSplitView { + Sidebar(model: model) + } detail: { + HomeSectionScreen(model: model, section: model.selectedSection, compactLayout: false) + .navigationTitle(model.selectedSection.title) + .toolbar { + DashboardToolbar(model: model, compactLayout: false) + } + } + .navigationSplitViewStyle(.balanced) + } +} + +private struct DashboardToolbar: ToolbarContent { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + var body: some ToolbarContent { + if compactLayout { + ToolbarItemGroup(placement: .primaryAction) { + NotificationBellButton(model: model) + + Menu { + Button { + Task { + await model.refreshDashboard() + } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + + Button { + Task { + await model.simulateIncomingRequest() + } + } label: { + Label("Mock Request", systemImage: "sparkles.rectangle.stack.fill") + } + + Button { + Task { + await model.sendTestNotification() + } + } label: { + Label("Send Test Alert", systemImage: "paperplane.fill") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } else { + ToolbarItemGroup(placement: .primaryAction) { + NotificationBellButton(model: model) + + Button { + Task { + await model.refreshDashboard() + } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(model.isRefreshing) + + Button { + Task { + await model.simulateIncomingRequest() + } + } label: { + Label("Mock Request", systemImage: "sparkles.rectangle.stack.fill") + } + + Button { + Task { + await model.sendTestNotification() + } + } label: { + Label("Test Alert", systemImage: "paperplane.fill") + } + } + } + } +} + +private struct HomeSectionScreen: View { + @ObservedObject var model: AppViewModel + let section: AppSection + let compactLayout: Bool + + @State private var focusedRequest: ApprovalRequest? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) { + if let banner = model.bannerMessage { + BannerCard(message: banner, compactLayout: compactLayout) + } + + switch section { + case .overview: + OverviewPanel( + model: model, + compactLayout: compactLayout, + onOpenRequest: { focusedRequest = $0 } + ) + case .requests: + RequestsPanel( + model: model, + compactLayout: compactLayout, + onOpenRequest: { focusedRequest = $0 } + ) + case .activity: + ActivityPanel( + model: model, + compactLayout: compactLayout, + onOpenRequest: { focusedRequest = $0 } + ) + case .account: + AccountPanel(model: model, compactLayout: compactLayout) + } + } + .padding(compactLayout ? 18 : 24) + .frame(maxWidth: compactLayout ? 720 : 1120, alignment: .leading) + } + .scrollIndicators(.hidden) + .sheet(item: $focusedRequest) { request in + RequestDetailSheet(request: request, model: model) + } + } +} + +private struct Sidebar: View { + @ObservedObject var model: AppViewModel + + var body: some View { + List { + Section { + SidebarStatusCard( + profile: model.profile, + pendingCount: model.pendingRequests.count, + unreadCount: model.unreadNotificationCount + ) + } + + Section("Workspace") { + ForEach(AppSection.allCases) { section in + sidebarRow(for: section) + } + } + } + .navigationTitle("idp.global") + } + + private func badgeCount(for section: AppSection) -> Int { + switch section { + case .overview: + 0 + case .requests: + model.pendingRequests.count + case .activity: + 0 + case .account: + 0 + } + } + + @ViewBuilder + private func sidebarRow(for section: AppSection) -> some View { + Button { + model.selectedSection = section + } label: { + HStack(spacing: 14) { + Image(systemName: section.systemImage) + .font(.headline) + .frame(width: 30, height: 30) + .background { + if model.selectedSection == section { + Circle() + .fill(dashboardAccent.opacity(0.18)) + } else { + Circle() + .fill(.thinMaterial) + } + } + .foregroundStyle(model.selectedSection == section ? dashboardAccent : .primary) + + Text(section.title) + .font(.headline) + + Spacer() + + if badgeCount(for: section) > 0 { + Text("\(badgeCount(for: section))") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(.thinMaterial, in: Capsule()) + } + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground( + model.selectedSection == section + ? dashboardAccent.opacity(0.12) + : Color.clear + ) + } +} + +private struct SidebarStatusCard: View { + let profile: MemberProfile? + let pendingCount: Int + let unreadCount: Int + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Digital Passport") + .font(.title3.weight(.semibold)) + + Text(profile?.handle ?? "Not paired yet") + .foregroundStyle(.secondary) + + HStack(spacing: 10) { + SmallMetricPill(title: "Pending", value: "\(pendingCount)") + SmallMetricPill(title: "Unread", value: "\(unreadCount)") + } + } + .padding(.vertical, 6) + } +} + +private struct OverviewPanel: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + let onOpenRequest: (ApprovalRequest) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) { + if let profile = model.profile, let session = model.session { + OverviewHero( + profile: profile, + session: session, + pendingCount: model.pendingRequests.count, + unreadCount: model.unreadNotificationCount, + compactLayout: compactLayout + ) + } + + SectionCard( + title: "Quick Actions", + subtitle: "Refresh the bound session, seed a request, or test device alerts while the backend is still mocked.", + compactLayout: compactLayout + ) { + QuickActionsDeck(model: model, compactLayout: compactLayout) + } + + SectionCard( + title: "Requests In Focus", + subtitle: "Your passport is the identity surface. This queue is where anything asking for access should earn trust.", + compactLayout: compactLayout + ) { + if model.pendingRequests.isEmpty { + EmptyStateCopy( + title: "Nothing waiting", + systemImage: "checkmark.shield.fill", + message: "Every pending approval has been handled." + ) + } else { + VStack(spacing: 16) { + if let featured = model.pendingRequests.first { + FeaturedRequestCard( + request: featured, + compactLayout: compactLayout, + onOpenRequest: { onOpenRequest(featured) } + ) + } + + ForEach(model.pendingRequests.dropFirst().prefix(2)) { request in + RequestCard( + request: request, + compactLayout: compactLayout, + isBusy: model.activeRequestID == request.id, + onApprove: { + Task { await model.approve(request) } + }, + onReject: { + Task { await model.reject(request) } + }, + onOpenRequest: { + onOpenRequest(request) + } + ) + } + } + } + } + + SectionCard( + title: "Recent Activity", + subtitle: "Keep the full timeline in its own view, and use the bell above for alerts that need device-level attention.", + compactLayout: compactLayout + ) { + ActivityPreviewCard(model: model, compactLayout: compactLayout) + } + } + } +} + +private struct ActivityPanel: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + let onOpenRequest: (ApprovalRequest) -> Void + + @State private var selectedNotificationID: AppNotification.ID? + + private var notificationIDs: [AppNotification.ID] { + model.notifications.map(\.id) + } + + private var selectedNotification: AppNotification? { + if let selectedNotificationID, + let match = model.notifications.first(where: { $0.id == selectedNotificationID }) { + return match + } + + return model.notifications.first + } + + var body: some View { + VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) { + if compactLayout { + SectionCard( + title: "Recent Activity", + subtitle: "A dedicated home for approvals, pairing events, and system changes after they happen." + ) { + VStack(spacing: 16) { + activityMetricRow + + if model.notifications.isEmpty { + EmptyStateCopy( + title: "No activity yet", + systemImage: "clock.badge.xmark", + message: "Once requests and pairing events arrive, the timeline will fill in here." + ) + } else { + ForEach(model.notifications) { notification in + NotificationCard( + notification: notification, + compactLayout: compactLayout, + onMarkRead: { + Task { await model.markNotificationRead(notification) } + } + ) + } + } + } + } + } else { + SectionCard( + title: "Activity Timeline", + subtitle: "Review what already happened across approvals, pairing, and system state without mixing it into the notification surface." + ) { + VStack(alignment: .leading, spacing: 18) { + activityMetricRow + + if model.notifications.isEmpty { + EmptyStateCopy( + title: "No activity yet", + systemImage: "clock.badge.xmark", + message: "Once requests and pairing events arrive, the timeline will fill in here." + ) + } else { + HStack(alignment: .top, spacing: 18) { + VStack(alignment: .leading, spacing: 14) { + Text("Timeline") + .font(.headline) + + Text("The latest product and security events stay readable here, while the bell above stays focused on device notifications.") + .foregroundStyle(.secondary) + + VStack(spacing: 12) { + ForEach(model.notifications) { notification in + NotificationFeedRow( + notification: notification, + isSelected: notification.id == selectedNotification?.id + ) { + selectedNotificationID = notification.id + } + } + } + } + .frame(maxWidth: 340, alignment: .leading) + .padding(18) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + + if let notification = selectedNotification { + NotificationWorkbenchDetail( + notification: notification, + permissionState: model.notificationPermission, + onMarkRead: { + Task { await model.markNotificationRead(notification) } + } + ) + } + } + } + } + } + } + + if !model.handledRequests.isEmpty { + SectionCard( + title: "Handled Requests", + subtitle: "A compact audit trail for the approvals and rejections that already moved through the queue." + ) { + LazyVStack(spacing: 14) { + ForEach(model.handledRequests.prefix(compactLayout ? 4 : 6)) { request in + RequestCard( + request: request, + compactLayout: compactLayout, + isBusy: false, + onApprove: nil, + onReject: nil, + onOpenRequest: { + onOpenRequest(request) + } + ) + } + } + } + } + } + .onChange(of: notificationIDs, initial: true) { _, _ in + syncSelectedNotification() + } + } + + @ViewBuilder + private var activityMetricRow: some View { + if compactLayout { + VStack(spacing: 10) { + SmallMetricPill(title: "Events", value: "\(model.notifications.count)") + SmallMetricPill(title: "Unread", value: "\(model.unreadNotificationCount)") + SmallMetricPill(title: "Handled", value: "\(model.handledRequests.count)") + } + } else { + HStack(spacing: 14) { + NotificationMetricCard( + title: "Events", + value: "\(model.notifications.count)", + subtitle: model.notifications.isEmpty ? "Quiet so far" : "Timeline active", + accent: dashboardAccent + ) + NotificationMetricCard( + title: "Unread", + value: "\(model.unreadNotificationCount)", + subtitle: model.unreadNotificationCount == 0 ? "Everything acknowledged" : "Still highlighted", + accent: .orange + ) + NotificationMetricCard( + title: "Handled", + value: "\(model.handledRequests.count)", + subtitle: model.handledRequests.isEmpty ? "No completed approvals yet" : "Recent decisions ready to review", + accent: dashboardGold + ) + } + } + } + + private func syncSelectedNotification() { + if let selectedNotificationID, + notificationIDs.contains(selectedNotificationID) { + return + } + + selectedNotificationID = model.notifications.first?.id + } +} + +private struct RequestsPanel: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + let onOpenRequest: (ApprovalRequest) -> Void + + @State private var selectedRequestID: ApprovalRequest.ID? + + private var requestIDs: [ApprovalRequest.ID] { + model.requests.map(\.id) + } + + private var selectedRequest: ApprovalRequest? { + if let selectedRequestID, + let match = model.requests.first(where: { $0.id == selectedRequestID }) { + return match + } + + return model.pendingRequests.first ?? model.handledRequests.first + } + + var body: some View { + VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) { + if compactLayout { + SectionCard( + title: "Approval Desk", + subtitle: "Treat every request like a border checkpoint: verify the origin, timing, and scope before letting it through.", + compactLayout: compactLayout + ) { + VStack(spacing: 16) { + RequestQueueSummary( + pendingCount: model.pendingRequests.count, + elevatedCount: model.elevatedPendingCount, + compactLayout: compactLayout + ) + + if model.pendingRequests.isEmpty { + EmptyStateCopy( + title: "Queue is clear", + systemImage: "checkmark.circle", + message: "Use the toolbar to simulate another request if you want to keep testing." + ) + } else { + ForEach(model.pendingRequests) { request in + RequestCard( + request: request, + compactLayout: compactLayout, + isBusy: model.activeRequestID == request.id, + onApprove: { + Task { await model.approve(request) } + }, + onReject: { + Task { await model.reject(request) } + }, + onOpenRequest: { + onOpenRequest(request) + } + ) + } + } + } + } + + SectionCard( + title: "Decision Guide", + subtitle: "What to check before approving high-sensitivity actions from your phone.", + compactLayout: compactLayout + ) { + VStack(alignment: .leading, spacing: 14) { + GuidanceRow( + icon: "network.badge.shield.half.filled", + title: "Confirm the origin", + message: "The service hostname should match the product or automation you intentionally triggered." + ) + GuidanceRow( + icon: "timer", + title: "Look for short lifetimes", + message: "Privileged grants should usually be limited in time instead of creating long-lived access." + ) + GuidanceRow( + icon: "lock.shield", + title: "Escalate mentally for elevated scopes", + message: "Signing, publishing, and write scopes deserve a slower second look before approval." + ) + } + } + + if !model.handledRequests.isEmpty { + SectionCard( + title: "Recently Handled", + subtitle: "A compact audit trail of the latest approvals and rejections.", + compactLayout: compactLayout + ) { + LazyVStack(spacing: 14) { + ForEach(model.handledRequests.prefix(4)) { request in + RequestCard( + request: request, + compactLayout: compactLayout, + isBusy: false, + onApprove: nil, + onReject: nil, + onOpenRequest: { + onOpenRequest(request) + } + ) + } + } + } + } + } else { + SectionCard( + title: "Approval Workbench", + subtitle: "Use the queue on the left and a richer inline review on the right so each decision feels deliberate instead of mechanical." + ) { + VStack(alignment: .leading, spacing: 18) { + RequestQueueSummary( + pendingCount: model.pendingRequests.count, + elevatedCount: model.elevatedPendingCount, + compactLayout: compactLayout + ) + + if model.requests.isEmpty { + EmptyStateCopy( + title: "Queue is clear", + systemImage: "checkmark.circle", + message: "Use the toolbar to simulate another request if you want to keep testing." + ) + } else { + HStack(alignment: .top, spacing: 18) { + VStack(alignment: .leading, spacing: 14) { + Text("Queue") + .font(.headline) + + Text("Pending and recently handled items stay visible here so you can sanity-check decisions without leaving the flow.") + .foregroundStyle(.secondary) + + VStack(spacing: 12) { + ForEach(model.requests) { request in + RequestQueueRow( + request: request, + isSelected: request.id == selectedRequest?.id + ) { + selectedRequestID = request.id + } + } + } + } + .frame(maxWidth: 340, alignment: .leading) + .padding(18) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + + if let request = selectedRequest { + RequestWorkbenchDetail( + request: request, + isBusy: model.activeRequestID == request.id, + onApprove: request.status == .pending ? { + Task { await model.approve(request) } + } : nil, + onReject: request.status == .pending ? { + Task { await model.reject(request) } + } : nil, + onOpenRequest: { + onOpenRequest(request) + } + ) + } + } + } + } + } + + SectionCard( + title: "Operator Checklist", + subtitle: "A calm review pattern for larger screens, especially when elevated scopes show up." + ) { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 14), + GridItem(.flexible(), spacing: 14) + ], + alignment: .leading, + spacing: 14 + ) { + GuidanceCard( + icon: "network.badge.shield.half.filled", + title: "Confirm the origin", + message: "The hostname should map to the workflow or portal you intentionally triggered." + ) + GuidanceCard( + icon: "timer", + title: "Look for short lifetimes", + message: "Elevated grants are safer when they expire quickly instead of becoming ambient access." + ) + GuidanceCard( + icon: "lock.shield", + title: "Escalate for signing and publish scopes", + message: "If the action can sign, publish, or write, slow down and verify the target system twice." + ) + GuidanceCard( + icon: "person.badge.shield.checkmark", + title: "Match the device", + message: "The request story should line up with the paired browser, CLI, or automation session you expect." + ) + } + } + } + } + .onChange(of: requestIDs, initial: true) { _, _ in + syncSelectedRequest() + } + } + + private func syncSelectedRequest() { + if let selectedRequestID, + requestIDs.contains(selectedRequestID) { + return + } + + selectedRequestID = model.pendingRequests.first?.id ?? model.handledRequests.first?.id + } +} + +private struct NotificationsPanel: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + @State private var selectedNotificationID: AppNotification.ID? + + private var notificationIDs: [AppNotification.ID] { + model.notifications.map(\.id) + } + + private var selectedNotification: AppNotification? { + if let selectedNotificationID, + let match = model.notifications.first(where: { $0.id == selectedNotificationID }) { + return match + } + + return model.notifications.first + } + + var body: some View { + VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) { + if compactLayout { + SectionCard( + title: "Notification Delivery", + subtitle: "Control lock-screen delivery now, then evolve this into remote push once the backend is live.", + compactLayout: compactLayout + ) { + NotificationPermissionCard(model: model, compactLayout: compactLayout) + } + + SectionCard( + title: "Alert Inbox", + subtitle: "Unread alerts stay emphasized here until you explicitly clear them.", + compactLayout: compactLayout + ) { + if model.notifications.isEmpty { + EmptyStateCopy( + title: "No alerts yet", + systemImage: "bell.slash", + message: "New pairing and approval alerts will accumulate here." + ) + } else { + LazyVStack(spacing: 14) { + ForEach(model.notifications) { notification in + NotificationCard( + notification: notification, + compactLayout: compactLayout, + onMarkRead: { + Task { await model.markNotificationRead(notification) } + } + ) + } + } + } + } + } else { + SectionCard( + title: "Delivery Posture", + subtitle: "Keep delivery health, unread pressure, and the latest alert in one glance from the notification center." + ) { + VStack(alignment: .leading, spacing: 18) { + HStack(spacing: 14) { + NotificationMetricCard( + title: "Unread", + value: "\(model.unreadNotificationCount)", + subtitle: model.unreadNotificationCount == 0 ? "Inbox clear" : "Needs triage", + accent: .orange + ) + NotificationMetricCard( + title: "Permission", + value: model.notificationPermission.title, + subtitle: model.notificationPermission == .allowed ? "Lock screen ready" : "Review device status", + accent: dashboardAccent + ) + NotificationMetricCard( + title: "Latest", + value: model.latestNotification?.kind.title ?? "Quiet", + subtitle: model.latestNotification?.sentAt.formatted(date: .omitted, time: .shortened) ?? "No recent events", + accent: dashboardGold + ) + } + + NotificationPermissionCard(model: model, compactLayout: compactLayout) + } + } + + SectionCard( + title: "Alert Inbox", + subtitle: "Select an alert to inspect the message body, delivery state, and the right follow-up action." + ) { + if model.notifications.isEmpty { + EmptyStateCopy( + title: "No alerts yet", + systemImage: "bell.slash", + message: "New pairing and approval alerts will accumulate here." + ) + } else { + HStack(alignment: .top, spacing: 18) { + VStack(alignment: .leading, spacing: 14) { + Text("Feed") + .font(.headline) + + Text("Unread items stay visually lifted until you clear them, which makes it easier to scan the important changes first.") + .foregroundStyle(.secondary) + + VStack(spacing: 12) { + ForEach(model.notifications) { notification in + NotificationFeedRow( + notification: notification, + isSelected: notification.id == selectedNotification?.id + ) { + selectedNotificationID = notification.id + } + } + } + } + .frame(maxWidth: 340, alignment: .leading) + .padding(18) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + + if let notification = selectedNotification { + NotificationWorkbenchDetail( + notification: notification, + permissionState: model.notificationPermission, + onMarkRead: { + Task { await model.markNotificationRead(notification) } + } + ) + } + } + } + } + } + } + .onChange(of: notificationIDs, initial: true) { _, _ in + syncSelectedNotification() + } + } + + private func syncSelectedNotification() { + if let selectedNotificationID, + notificationIDs.contains(selectedNotificationID) { + return + } + + selectedNotificationID = model.notifications.first?.id + } +} + +private struct AccountPanel: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + var body: some View { + VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) { + if let profile = model.profile, let session = model.session { + AccountHero(profile: profile, session: session, compactLayout: compactLayout) + + SectionCard( + title: "Session Security", + subtitle: "The core trust facts for the currently paired session.", + compactLayout: compactLayout + ) { + AccountFactGrid(profile: profile, session: session, compactLayout: compactLayout) + } + } + + SectionCard( + title: "Mock Pairing Payload", + subtitle: "Useful for testing QR flow while the real portal integration is still pending.", + compactLayout: compactLayout + ) { + Text(model.suggestedQRCodePayload) + .font(.body.monospaced()) + .textSelection(.enabled) + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + } + + SectionCard( + title: "Session Controls", + subtitle: "Use this once you want to reset back to the login and pairing flow.", + compactLayout: compactLayout + ) { + Button(role: .destructive) { + model.signOut() + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + } + .buttonStyle(.bordered) + } + } + } +} + +private struct OverviewHero: View { + let profile: MemberProfile + let session: AuthSession + let pendingCount: Int + let unreadCount: Int + let compactLayout: Bool + + var body: some View { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 34, style: .continuous) + .fill( + LinearGradient( + colors: [ + Color(red: 0.07, green: 0.18, blue: 0.15), + Color(red: 0.11, green: 0.28, blue: 0.24), + Color(red: 0.29, green: 0.24, blue: 0.12) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 34, style: .continuous) + .strokeBorder(dashboardGold.opacity(0.55), lineWidth: 1.2) + ) + + Circle() + .fill(.white.opacity(0.08)) + .frame(width: compactLayout ? 180 : 260, height: compactLayout ? 180 : 260) + .offset(x: compactLayout ? 210 : 420, y: compactLayout ? -30 : -50) + + Image(systemName: "globe.europe.africa.fill") + .font(.system(size: compactLayout ? 92 : 122)) + .foregroundStyle(.white.opacity(0.07)) + .offset(x: compactLayout ? 220 : 455, y: compactLayout ? 4 : 8) + + VStack(alignment: .leading, spacing: compactLayout ? 16 : 20) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text("IDP.GLOBAL DIGITAL PASSPORT") + .font(.caption.weight(.bold)) + .tracking(1.8) + .foregroundStyle(.white.opacity(0.78)) + + Text(profile.name) + .font(.system(size: compactLayout ? 30 : 36, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + Text("Bound to \(session.deviceName) for requests coming from \(session.originHost).") + .font(compactLayout ? .subheadline : .title3) + .foregroundStyle(.white.opacity(0.88)) + } + + Spacer(minLength: 0) + + VStack(alignment: .trailing, spacing: 8) { + StatusBadge(title: "Bound", tone: .white) + + Text(session.pairingCode) + .font(.caption.monospaced().weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(.white.opacity(0.12), in: Capsule()) + .foregroundStyle(.white) + } + } + + Group { + if compactLayout { + VStack(alignment: .leading, spacing: 14) { + passportPortrait + + VStack(spacing: 12) { + passportPrimaryFields + passportSecondaryFields + } + } + } else { + HStack(alignment: .top, spacing: 18) { + passportPortrait + + HStack(alignment: .top, spacing: 14) { + passportPrimaryFields + passportSecondaryFields + } + } + } + } + .padding(compactLayout ? 18 : 20) + .background(.white.opacity(0.11), in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + + PassportMachineStrip(code: machineReadableCode) + + if compactLayout { + VStack(spacing: 10) { + PassportMetricBadge( + title: "Pending", + value: "\(pendingCount)", + subtitle: pendingCount == 0 ? "No approvals waiting" : "Requests still at the border" + ) + PassportMetricBadge( + title: "Alerts", + value: "\(unreadCount)", + subtitle: unreadCount == 0 ? "Notification bell is clear" : "Unread device alerts" + ) + PassportMetricBadge( + title: "Devices", + value: "\(profile.deviceCount)", + subtitle: "\(profile.organization) membership" + ) + } + } else { + HStack(spacing: 12) { + PassportMetricBadge( + title: "Pending", + value: "\(pendingCount)", + subtitle: pendingCount == 0 ? "No approvals waiting" : "Requests still at the border" + ) + PassportMetricBadge( + title: "Alerts", + value: "\(unreadCount)", + subtitle: unreadCount == 0 ? "Notification bell is clear" : "Unread device alerts" + ) + PassportMetricBadge( + title: "Devices", + value: "\(profile.deviceCount)", + subtitle: "\(profile.organization) membership" + ) + } + } + } + .padding(compactLayout ? 22 : 28) + } + .frame(minHeight: compactLayout ? 470 : 390) + } + + private var passportPortrait: some View { + VStack(alignment: .leading, spacing: 12) { + RoundedRectangle(cornerRadius: 26, style: .continuous) + .fill(.white.opacity(0.12)) + .frame(width: compactLayout ? 118 : 132, height: compactLayout ? 148 : 166) + .overlay { + VStack(spacing: 10) { + Circle() + .fill(.white.opacity(0.18)) + .frame(width: compactLayout ? 56 : 64, height: compactLayout ? 56 : 64) + .overlay { + Text(holderInitials) + .font(.system(size: compactLayout ? 24 : 28, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + + Text("TRUSTED HOLDER") + .font(.caption2.weight(.bold)) + .tracking(1.2) + .foregroundStyle(.white.opacity(0.72)) + + Text(profile.handle) + .font(.footnote.monospaced()) + .foregroundStyle(.white.opacity(0.9)) + } + .padding(12) + } + + Text("Issued \(session.pairedAt.formatted(date: .abbreviated, time: .shortened))") + .font(.caption) + .foregroundStyle(.white.opacity(0.74)) + } + } + + private var passportPrimaryFields: some View { + VStack(alignment: .leading, spacing: 12) { + PassportField(label: "Holder", value: profile.name, emphasized: true) + PassportField(label: "Handle", value: profile.handle, monospaced: true) + PassportField(label: "Organization", value: profile.organization) + } + } + + private var passportSecondaryFields: some View { + VStack(alignment: .leading, spacing: 12) { + PassportField(label: "Bound Device", value: session.deviceName) + PassportField(label: "Origin", value: session.originHost, monospaced: true) + PassportField(label: "Token Preview", value: "...\(session.tokenPreview)", monospaced: true) + } + } + + private var holderInitials: String { + let parts = profile.name + .split(separator: " ") + .prefix(2) + .compactMap { $0.first } + + let initials = String(parts) + return initials.isEmpty ? "ID" : initials.uppercased() + } + + private var machineReadableCode: String { + let normalizedName = sanitize(profile.name) + let normalizedHandle = sanitize(profile.handle) + let normalizedToken = sanitize(session.tokenPreview) + return "P String { + value + .uppercased() + .map { character in + character.isLetter || character.isNumber ? String(character) : "<" + } + .joined() + } +} + +private struct PassportField: View { + let label: String + let value: String + var monospaced: Bool = false + var emphasized: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label.uppercased()) + .font(.caption2.weight(.bold)) + .tracking(1.0) + .foregroundStyle(.white.opacity(0.72)) + + Text(value) + .font(valueFont) + .foregroundStyle(.white) + .lineLimit(2) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var valueFont: Font { + if monospaced { + return .body.monospaced() + } + + return emphasized ? .headline : .body + } +} + +private struct PassportMetricBadge: View { + let title: String + let value: String + let subtitle: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title.uppercased()) + .font(.caption.weight(.bold)) + .tracking(1.0) + .foregroundStyle(.white.opacity(0.72)) + + Text(value) + .font(.title2.weight(.bold)) + .foregroundStyle(.white) + + Text(subtitle) + .font(.footnote) + .foregroundStyle(.white.opacity(0.82)) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.white.opacity(0.10), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + } +} + +private struct PassportMachineStrip: View { + let code: String + + var body: some View { + Text(code) + .font(.caption.monospaced().weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.5) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.black.opacity(0.22), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .foregroundStyle(.white.opacity(0.94)) + } +} + +private struct QuickActionsDeck: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + var body: some View { + Group { + if compactLayout { + VStack(spacing: 12) { + actionButtons + } + } else { + HStack(alignment: .top, spacing: 14) { + actionButtons + } + } + } + } + + @ViewBuilder + private var actionButtons: some View { + ActionTile( + title: "Refresh State", + subtitle: "Pull the latest requests and notifications from the mock service.", + systemImage: "arrow.clockwise" + ) { + Task { + await model.refreshDashboard() + } + } + + ActionTile( + title: "Seed Request", + subtitle: "Inject a new elevated approval flow to test the queue.", + systemImage: "sparkles.rectangle.stack.fill" + ) { + Task { + await model.simulateIncomingRequest() + } + } + + ActionTile( + title: "Test Alert", + subtitle: "Schedule a local notification so the phone behavior is easy to verify.", + systemImage: "bell.badge.fill" + ) { + Task { + await model.sendTestNotification() + } + } + } +} + +private struct ActionTile: View { + let title: String + let subtitle: String + let systemImage: String + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: systemImage) + .font(.title2) + .foregroundStyle(dashboardAccent) + Text(title) + .font(.headline) + .foregroundStyle(.primary) + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + } + .buttonStyle(.plain) + } +} + +private struct FeaturedRequestCard: View { + let request: ApprovalRequest + let compactLayout: Bool + let onOpenRequest: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center, spacing: 12) { + Image(systemName: request.risk == .elevated ? "shield.lefthalf.filled.badge.checkmark" : request.kind.systemImage) + .font(.title2) + .foregroundStyle(request.risk == .elevated ? .orange : dashboardAccent) + + VStack(alignment: .leading, spacing: 4) { + Text(request.trustHeadline) + .font(.headline) + Text(request.title) + .font(.title3.weight(.semibold)) + } + + Spacer() + + StatusBadge( + title: request.risk.title, + tone: request.risk == .routine ? .mint : .orange + ) + } + + Text(request.trustDetail) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + StatusBadge(title: request.kind.title, tone: .blue) + StatusBadge(title: request.source, tone: .gray) + StatusBadge(title: request.scopeSummary, tone: .green) + } + + if compactLayout { + VStack(alignment: .leading, spacing: 12) { + Button("Review Full Context", action: onOpenRequest) + .buttonStyle(.borderedProminent) + Text(request.risk.guidance) + .font(.footnote) + .foregroundStyle(.secondary) + } + } else { + HStack { + Button("Review Full Context", action: onOpenRequest) + .buttonStyle(.borderedProminent) + Spacer() + Text(request.risk.guidance) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) + } + } + } + .padding(compactLayout ? 18 : 22) + .background( + LinearGradient( + colors: [ + request.risk == .routine ? dashboardAccent.opacity(0.12) : Color.orange.opacity(0.16), + Color.white.opacity(0.7) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: 28, style: .continuous) + ) + } +} + +private struct RequestQueueSummary: View { + let pendingCount: Int + let elevatedCount: Int + let compactLayout: Bool + + var body: some View { + Group { + if compactLayout { + VStack(spacing: 12) { + summaryCards + } + } else { + HStack(spacing: 12) { + summaryCards + } + } + } + } + + @ViewBuilder + private var summaryCards: some View { + RequestSummaryMetricCard( + title: "Pending", + value: "\(pendingCount)", + subtitle: pendingCount == 0 ? "Queue is clear" : "Still waiting on your call", + accent: dashboardAccent + ) + + RequestSummaryMetricCard( + title: "Elevated", + value: "\(elevatedCount)", + subtitle: elevatedCount == 0 ? "No privileged scopes" : "Needs slower review", + accent: .orange + ) + + RequestSummaryMetricCard( + title: "Posture", + value: trustMode, + subtitle: postureSummary, + accent: dashboardGold + ) + } + + private var trustMode: String { + if pendingCount == 0 { + return "Clear" + } + + if elevatedCount == 0 { + return "Active" + } + + return elevatedCount > 1 ? "Escalate" : "Guarded" + } + + private var postureSummary: String { + if pendingCount == 0 { + return "Nothing at the border" + } + + if elevatedCount == 0 { + return "Routine traffic only" + } + + return "Privileged access in queue" + } +} + +private struct RequestSummaryMetricCard: View { + let title: String + let value: String + let subtitle: String + let accent: Color + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + Text(value) + .font(.title3.weight(.semibold)) + .foregroundStyle(.primary) + + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + } +} + +private struct NotificationPermissionCard: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top, spacing: 14) { + Image(systemName: model.notificationPermission.systemImage) + .font(.title2) + .frame(width: 38, height: 38) + .background(.thinMaterial, in: Circle()) + .foregroundStyle(dashboardAccent) + + VStack(alignment: .leading, spacing: 5) { + Text(model.notificationPermission.title) + .font(.headline) + Text(model.notificationPermission.summary) + .foregroundStyle(.secondary) + } + } + + Group { + if compactLayout { + VStack(spacing: 12) { + permissionButtons + } + } else { + HStack(spacing: 12) { + permissionButtons + } + } + } + } + .padding(18) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 26, style: .continuous)) + } + + @ViewBuilder + private var permissionButtons: some View { + Button { + Task { + await model.requestNotificationAccess() + } + } label: { + Label("Enable Notifications", systemImage: "bell.and.waves.left.and.right.fill") + } + .buttonStyle(.borderedProminent) + + Button { + Task { + await model.sendTestNotification() + } + } label: { + Label("Send Test Alert", systemImage: "paperplane.fill") + } + .buttonStyle(.bordered) + } +} + +private struct ActivityPreviewCard: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if let latest = model.latestNotification { + NotificationCard( + notification: latest, + compactLayout: compactLayout, + onMarkRead: { + Task { await model.markNotificationRead(latest) } + } + ) + } else { + EmptyStateCopy( + title: "No activity yet", + systemImage: "clock.badge.xmark", + message: "Once requests and pairing events arrive, the activity timeline will fill in here." + ) + } + + if compactLayout { + VStack(alignment: .leading, spacing: 12) { + Button { + model.selectedSection = .activity + } label: { + Label("Open Activity", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + .buttonStyle(.borderedProminent) + + Button { + model.isNotificationCenterPresented = true + } label: { + Label("Open Notification Bell", systemImage: "bell") + } + .buttonStyle(.bordered) + } + } else { + HStack(spacing: 12) { + Button { + model.selectedSection = .activity + } label: { + Label("Open Activity", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + .buttonStyle(.borderedProminent) + + Button { + model.isNotificationCenterPresented = true + } label: { + Label("Open Notifications", systemImage: "bell") + } + .buttonStyle(.bordered) + + Spacer() + + Text("Unread device alerts now live in the bell above instead of taking a full navigation slot.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) + } + } + } + } +} + +private struct NotificationBellButton: View { + @ObservedObject var model: AppViewModel + + var body: some View { + Button { + model.isNotificationCenterPresented = true + } label: { + ZStack(alignment: .topTrailing) { + Image(systemName: model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill") + .font(.headline) + .foregroundStyle(model.unreadNotificationCount == 0 ? .primary : dashboardAccent) + + if model.unreadNotificationCount > 0 { + Text("\(min(model.unreadNotificationCount, 9))") + .font(.caption2.weight(.bold)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.orange, in: Capsule()) + .foregroundStyle(.white) + .offset(x: 10, y: -10) + } + } + .frame(width: 28, height: 28) + } + .accessibilityLabel("Notifications") + } +} + +private struct NotificationCenterSheet: View { + @ObservedObject var model: AppViewModel + @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + var body: some View { + NavigationStack { + ScrollView { + NotificationsPanel(model: model, compactLayout: compactLayout) + .padding(compactLayout ? 18 : 24) + .frame(maxWidth: compactLayout ? 720 : 1120, alignment: .leading) + } + .scrollIndicators(.hidden) + .navigationTitle("Notifications") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + } + #if os(iOS) + .presentationDetents(compactLayout ? [.large] : [.medium, .large]) + #endif + } + + private var compactLayout: Bool { + #if os(iOS) + horizontalSizeClass == .compact + #else + false + #endif + } +} + +private struct AccountHero: View { + let profile: MemberProfile + let session: AuthSession + let compactLayout: Bool + + var body: some View { + ZStack(alignment: .bottomLeading) { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill( + LinearGradient( + colors: [ + dashboardAccent.opacity(0.95), + Color(red: 0.19, green: 0.49, blue: 0.40), + dashboardGold.opacity(0.92) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + VStack(alignment: .leading, spacing: 14) { + Text(profile.name) + .font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + Text(profile.handle) + .font(.headline) + .foregroundStyle(.white.opacity(0.84)) + Text("Current trusted device: \(session.deviceName)") + .foregroundStyle(.white.opacity(0.86)) + } + .padding(compactLayout ? 22 : 28) + } + .frame(minHeight: compactLayout ? 190 : 220) + } +} + +private struct AccountFactGrid: View { + let profile: MemberProfile + let session: AuthSession + let compactLayout: Bool + + private var columns: [GridItem] { + Array(repeating: GridItem(.flexible(), spacing: 12), count: compactLayout ? 1 : 2) + } + + var body: some View { + LazyVGrid(columns: columns, spacing: 12) { + FactCard(label: "Organization", value: profile.organization) + FactCard(label: "Origin", value: session.originHost) + FactCard(label: "Paired At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened)) + FactCard(label: "Token Preview", value: "…\(session.tokenPreview)") + FactCard(label: "Trusted Devices", value: "\(profile.deviceCount)") + FactCard(label: "Recovery", value: profile.recoverySummary) + } + } +} + +private struct RequestCard: View { + let request: ApprovalRequest + let compactLayout: Bool + let isBusy: Bool + let onApprove: (() -> Void)? + let onReject: (() -> Void)? + let onOpenRequest: (() -> Void)? + + private var infoColumns: [GridItem] { + Array(repeating: GridItem(.flexible(), spacing: 10), count: compactLayout ? 2 : 3) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 14) { + ZStack { + Circle() + .fill(requestAccent.opacity(0.14)) + + Image(systemName: request.kind.systemImage) + .font(.title2) + .foregroundStyle(requestAccent) + } + .frame(width: 46, height: 46) + + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(request.trustHeadline) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(requestAccent) + + Text(request.title) + .font(.headline) + .foregroundStyle(.primary) + } + + Spacer() + + StatusBadge( + title: request.status.title, + tone: statusTone + ) + } + + Text(request.subtitle) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + StatusBadge(title: request.kind.title, tone: .blue) + StatusBadge(title: request.risk.title, tone: request.risk == .routine ? .mint : .orange) + Text(request.createdAt, style: .relative) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + LazyVGrid(columns: infoColumns, alignment: .leading, spacing: 10) { + RequestFactPill(label: "Source", value: request.source, accent: dashboardAccent) + RequestFactPill( + label: "Requested", + value: request.createdAt.formatted(date: .abbreviated, time: .shortened), + accent: dashboardGold + ) + RequestFactPill(label: "Access", value: request.scopeSummary, accent: requestAccent) + } + + VStack(alignment: .leading, spacing: 10) { + Label(request.status == .pending ? "Decision posture" : "Decision record", systemImage: request.status.systemImage) + .font(.headline) + .foregroundStyle(.primary) + + Text(request.trustDetail) + .foregroundStyle(.secondary) + + Text(reviewSummary) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(requestAccent) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(requestAccent.opacity(0.10), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + + if !request.scopes.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("Requested scopes") + .font(.subheadline.weight(.semibold)) + + FlowScopes(scopes: request.scopes) + } + } + + VStack(spacing: 12) { + if let onOpenRequest { + Button { + onOpenRequest() + } label: { + Label("Review Details", systemImage: "arrow.up.forward.app") + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let onApprove, let onReject, request.status == .pending { + if compactLayout { + VStack(spacing: 10) { + Button { + onApprove() + } label: { + if isBusy { + ProgressView() + } else { + Label("Approve Request", systemImage: "checkmark.circle.fill") + } + } + .buttonStyle(.borderedProminent) + .disabled(isBusy) + + Button(role: .destructive) { + onReject() + } label: { + Label("Reject Request", systemImage: "xmark.circle.fill") + } + .buttonStyle(.bordered) + .disabled(isBusy) + } + } else { + HStack(spacing: 12) { + Button { + onApprove() + } label: { + if isBusy { + ProgressView() + } else { + Label("Approve", systemImage: "checkmark.circle.fill") + } + } + .buttonStyle(.borderedProminent) + .disabled(isBusy) + + Button(role: .destructive) { + onReject() + } label: { + Label("Reject", systemImage: "xmark.circle.fill") + } + .buttonStyle(.bordered) + .disabled(isBusy) + } + } + } + } + } + .padding(compactLayout ? 18 : 20) + .background( + LinearGradient( + colors: [ + requestAccent.opacity(0.10), + Color.white.opacity(0.74) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: 28, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(requestAccent.opacity(0.16), lineWidth: 1) + ) + } + + private var statusTone: Color { + switch request.status { + case .pending: + return .orange + case .approved: + return .green + case .rejected: + return .red + } + } + + private var requestAccent: Color { + switch request.status { + case .approved: + return .green + case .rejected: + return .red + case .pending: + return request.risk == .routine ? dashboardAccent : .orange + } + } + + private var reviewSummary: String { + switch request.status { + case .pending: + if request.risk == .elevated { + return "This is privileged access. Let it through only if the origin and the moment both match what you just initiated." + } + return "This looks routine, but it still needs to match the browser, CLI, or device session you expect." + case .approved: + return "This request was already approved in the mock queue and is now part of the recent audit trail." + case .rejected: + return "This request was rejected and should remain a closed lane unless a new request is issued." + } + } +} + +private struct RequestQueueRow: View { + let request: ApprovalRequest + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .top, spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(rowAccent.opacity(0.14)) + + Image(systemName: request.kind.systemImage) + .font(.headline) + .foregroundStyle(rowAccent) + } + .frame(width: 38, height: 38) + + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(request.title) + .font(.headline) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + + Text(request.trustHeadline) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(rowAccent) + } + + Spacer(minLength: 0) + + StatusBadge( + title: request.status.title, + tone: statusTone + ) + } + + Text(request.source) + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(request.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + HStack(spacing: 8) { + StatusBadge(title: request.risk.title, tone: request.risk == .routine ? .mint : .orange) + StatusBadge(title: request.scopeSummary, tone: .blue) + Spacer() + Text(request.createdAt, style: .relative) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Image(systemName: isSelected ? "chevron.right.circle.fill" : "chevron.right") + .font(.headline) + .foregroundStyle(isSelected ? rowAccent : .secondary.opacity(0.7)) + .padding(.top, 2) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(backgroundStyle, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(isSelected ? dashboardAccent.opacity(0.36) : Color.clear, lineWidth: 1.5) + ) + } + .buttonStyle(.plain) + } + + private var statusTone: Color { + switch request.status { + case .pending: + .orange + case .approved: + .green + case .rejected: + .red + } + } + + private var backgroundStyle: Color { + isSelected ? rowAccent.opacity(0.12) : Color.white.opacity(0.58) + } + + private var rowAccent: Color { + switch request.status { + case .approved: + .green + case .rejected: + .red + case .pending: + request.risk == .routine ? dashboardAccent : .orange + } + } +} + +private struct RequestWorkbenchDetail: View { + let request: ApprovalRequest + let isBusy: Bool + let onApprove: (() -> Void)? + let onReject: (() -> Void)? + let onOpenRequest: () -> Void + + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + ZStack(alignment: .bottomLeading) { + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill( + LinearGradient( + colors: [ + request.risk == .routine ? dashboardAccent.opacity(0.95) : Color.orange.opacity(0.92), + dashboardGold.opacity(0.88) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 30, style: .continuous) + .strokeBorder(requestAccent.opacity(0.20), lineWidth: 1) + ) + + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + StatusBadge(title: request.kind.title, tone: .white) + StatusBadge(title: request.risk.title, tone: .white) + StatusBadge(title: request.status.title, tone: .white) + } + + Text(request.title) + .font(.system(size: 30, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + Text(request.trustHeadline) + .font(.headline) + .foregroundStyle(.white.opacity(0.84)) + } + + Spacer(minLength: 0) + + VStack(alignment: .trailing, spacing: 6) { + Text("REQUESTED") + .font(.caption.weight(.bold)) + .foregroundStyle(.white.opacity(0.72)) + + Text(request.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + } + } + + Text(request.subtitle) + .foregroundStyle(.white.opacity(0.88)) + + HStack(spacing: 14) { + Label(request.source, systemImage: "network") + Label(request.scopeSummary, systemImage: "lock.shield") + } + .font(.subheadline) + .foregroundStyle(.white.opacity(0.88)) + } + .padding(24) + } + .frame(minHeight: 250) + + LazyVGrid(columns: columns, spacing: 12) { + FactCard(label: "Source", value: request.source) + FactCard(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened)) + FactCard(label: "Type", value: request.kind.title) + FactCard(label: "Status", value: request.status.title) + FactCard(label: "Risk", value: request.risk.summary) + FactCard(label: "Access", value: request.scopeSummary) + } + + HStack(alignment: .top, spacing: 12) { + RequestSignalCard( + title: "Trust Signals", + subtitle: "The approval story should match the device, the product, and the moment you just triggered.", + accent: requestAccent + ) { + VStack(alignment: .leading, spacing: 14) { + GuidanceRow( + icon: "network.badge.shield.half.filled", + title: "Source must look familiar", + message: "This request comes from \(request.source). Only approve if that host or product lines up with what you intended." + ) + GuidanceRow( + icon: "person.badge.shield.checkmark", + title: "Action should fit the session", + message: request.trustDetail + ) + GuidanceRow( + icon: request.risk == .routine ? "checkmark.shield" : "exclamationmark.shield", + title: request.risk == .routine ? "Routine review is still a review" : "Elevated access deserves a pause", + message: request.risk.guidance + ) + } + } + + RequestSignalCard( + title: "Access Envelope", + subtitle: "These are the capabilities this request wants before it can proceed.", + accent: dashboardGold + ) { + if request.scopes.isEmpty { + Text("The mock backend did not provide explicit scopes for this request.") + .foregroundStyle(.secondary) + } else { + FlowScopes(scopes: request.scopes) + } + } + } + + RequestSignalCard( + title: request.status == .pending ? "Decision Rail" : "Decision Record", + subtitle: request.status == .pending + ? "Use the actions below only once the request story matches the device in your hand." + : "This request already moved through the queue, so this rail becomes a compact audit note.", + accent: statusTone + ) { + VStack(alignment: .leading, spacing: 14) { + Text(request.trustDetail) + .foregroundStyle(.secondary) + + Text(decisionSummary) + .font(.headline) + + HStack(spacing: 12) { + Button { + onOpenRequest() + } label: { + Label("Open Full Review", systemImage: "arrow.up.forward.app") + } + .buttonStyle(.bordered) + + Spacer() + + if let onApprove, let onReject, request.status == .pending { + Button { + onApprove() + } label: { + if isBusy { + ProgressView() + } else { + Label("Approve", systemImage: "checkmark.circle.fill") + } + } + .buttonStyle(.borderedProminent) + .disabled(isBusy) + + Button(role: .destructive) { + onReject() + } label: { + Label("Reject", systemImage: "xmark.circle.fill") + } + .buttonStyle(.bordered) + .disabled(isBusy) + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var statusTone: Color { + switch request.status { + case .pending: + return .orange + case .approved: + return .green + case .rejected: + return .red + } + } + + private var requestAccent: Color { + request.risk == .routine ? dashboardAccent : .orange + } + + private var decisionSummary: String { + switch request.status { + case .pending: + return request.risk == .routine + ? "Approve only if the origin and timing feel boringly expected." + : "Privileged requests should feel unmistakably intentional before you approve them." + case .approved: + return "This request has already been approved and should now be treated as part of your recent decision history." + case .rejected: + return "This request was rejected and is now a record of a blocked access attempt." + } + } +} + +private struct RequestFactPill: View { + let label: String + let value: String + let accent: Color + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label.uppercased()) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + + Text(value) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(2) + .minimumScaleFactor(0.8) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } +} + +private struct RequestSignalCard: View { + let title: String + let subtitle: String + let accent: Color + let content: () -> Content + + init( + title: String, + subtitle: String, + accent: Color, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.subtitle = subtitle + self.accent = accent + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + Circle() + .fill(accent.opacity(0.16)) + .frame(width: 34, height: 34) + .overlay { + Circle() + .stroke(accent.opacity(0.30), lineWidth: 1) + } + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(subtitle) + .foregroundStyle(.secondary) + } + } + + content() + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 26, style: .continuous)) + } +} + +private struct RequestDetailSheet: View { + let request: ApprovalRequest + @ObservedObject var model: AppViewModel + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + RequestDetailHero(request: request) + + SectionCard( + title: "Requested Access", + subtitle: "The exact scopes or capabilities this action wants to receive." + ) { + if request.scopes.isEmpty { + Text("No explicit scopes were provided by the mock backend.") + .foregroundStyle(.secondary) + } else { + FlowScopes(scopes: request.scopes) + } + } + + SectionCard( + title: "Trust Signals", + subtitle: "The details to validate before you approve anything sensitive." + ) { + VStack(alignment: .leading, spacing: 12) { + FactCard(label: "Source", value: request.source) + FactCard(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened)) + FactCard(label: "Type", value: request.kind.title) + FactCard(label: "Risk", value: request.risk.summary) + } + } + + SectionCard( + title: "Decision Guidance", + subtitle: "A short operator-minded reminder before you accept or reject this request." + ) { + Text(request.trustDetail) + .foregroundStyle(.secondary) + + Text(request.risk.guidance) + .font(.headline) + } + + if request.status == .pending { + VStack(spacing: 12) { + Button { + Task { + await model.approve(request) + dismiss() + } + } label: { + if model.activeRequestID == request.id { + ProgressView() + } else { + Label("Approve Request", systemImage: "checkmark.circle.fill") + } + } + .buttonStyle(.borderedProminent) + .disabled(model.activeRequestID == request.id) + + Button(role: .destructive) { + Task { + await model.reject(request) + dismiss() + } + } label: { + Label("Reject Request", systemImage: "xmark.circle.fill") + } + .buttonStyle(.bordered) + .disabled(model.activeRequestID == request.id) + } + } + } + .padding(20) + } + .navigationTitle("Review Request") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismiss() + } + } + } + } + } +} + +private struct RequestDetailHero: View { + let request: ApprovalRequest + + var body: some View { + ZStack(alignment: .bottomLeading) { + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill( + LinearGradient( + colors: [ + request.risk == .routine ? dashboardAccent.opacity(0.92) : Color.orange.opacity(0.92), + dashboardGold.opacity(0.88) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + VStack(alignment: .leading, spacing: 12) { + Text(request.trustHeadline) + .font(.headline) + .foregroundStyle(.white.opacity(0.86)) + Text(request.title) + .font(.system(size: 30, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + Text(request.subtitle) + .foregroundStyle(.white.opacity(0.86)) + } + .padding(24) + } + .frame(minHeight: 210) + } +} + +private struct NotificationCard: View { + let notification: AppNotification + let compactLayout: Bool + let onMarkRead: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 14) { + Image(systemName: notification.kind.systemImage) + .font(.title3) + .frame(width: 38, height: 38) + .background(.thinMaterial, in: Circle()) + .foregroundStyle(accentColor) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(notification.title) + .font(.headline) + Spacer() + if notification.isUnread { + StatusBadge(title: "Unread", tone: .orange) + } + } + + Text(notification.kind.summary) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Text(notification.message) + .foregroundStyle(.secondary) + + Group { + if compactLayout { + VStack(alignment: .leading, spacing: 10) { + timestampLabel + if notification.isUnread { + markReadButton + } + } + } else { + HStack { + timestampLabel + Spacer() + if notification.isUnread { + markReadButton + } + } + } + } + } + .padding(compactLayout ? 16 : 18) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 26, style: .continuous)) + } + + private var timestampLabel: some View { + Text(notification.sentAt.formatted(date: .abbreviated, time: .shortened)) + .font(.footnote) + .foregroundStyle(.secondary) + } + + private var markReadButton: some View { + Button { + onMarkRead() + } label: { + Label("Mark Read", systemImage: "checkmark") + } + .buttonStyle(.bordered) + } + + private var accentColor: Color { + switch notification.kind { + case .approval: + .green + case .security: + .orange + case .system: + .blue + } + } +} + +private struct NotificationMetricCard: View { + let title: String + let value: String + let subtitle: String + let accent: Color + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + Text(value) + .font(.title3.weight(.semibold)) + .foregroundStyle(.primary) + + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + } +} + +private struct NotificationFeedRow: View { + let notification: AppNotification + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: notification.kind.systemImage) + .font(.headline) + .foregroundStyle(accentColor) + .frame(width: 34, height: 34) + .background(.thinMaterial, in: Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(notification.title) + .font(.headline) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + + Text(notification.kind.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + + if notification.isUnread { + Circle() + .fill(Color.orange) + .frame(width: 10, height: 10) + } + } + + Text(notification.message) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + HStack { + StatusBadge(title: notification.kind.title, tone: accentColor) + Spacer() + Text(notification.sentAt.formatted(date: .omitted, time: .shortened)) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(backgroundStyle, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(isSelected ? accentColor.opacity(0.35) : Color.clear, lineWidth: 1.5) + ) + } + .buttonStyle(.plain) + } + + private var accentColor: Color { + switch notification.kind { + case .approval: + .green + case .security: + .orange + case .system: + .blue + } + } + + private var backgroundStyle: Color { + isSelected ? accentColor.opacity(0.10) : Color.white.opacity(0.58) + } +} + +private struct NotificationWorkbenchDetail: View { + let notification: AppNotification + let permissionState: NotificationPermissionState + let onMarkRead: () -> Void + + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + ZStack(alignment: .bottomLeading) { + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill( + LinearGradient( + colors: [ + accentColor.opacity(0.95), + accentColor.opacity(0.70), + dashboardGold.opacity(0.82) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + StatusBadge(title: notification.kind.title, tone: .white) + StatusBadge(title: notification.isUnread ? "Unread" : "Read", tone: .white) + } + + Text(notification.title) + .font(.system(size: 30, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + Text(notification.message) + .foregroundStyle(.white.opacity(0.9)) + } + .padding(24) + } + .frame(minHeight: 210) + + LazyVGrid(columns: columns, spacing: 12) { + FactCard(label: "Category", value: notification.kind.summary) + FactCard(label: "Sent", value: notification.sentAt.formatted(date: .abbreviated, time: .shortened)) + FactCard(label: "Inbox State", value: notification.isUnread ? "Still highlighted" : "Already cleared") + FactCard(label: "Delivery", value: permissionState.title) + } + + VStack(alignment: .leading, spacing: 10) { + Text("Delivery Context") + .font(.headline) + + Text(permissionState.summary) + .foregroundStyle(.secondary) + + Text(notification.isUnread ? "This alert is still asking for attention in the in-app feed." : "This alert has already been acknowledged in the mock inbox.") + .font(.headline) + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 26, style: .continuous)) + + if notification.isUnread { + Button { + onMarkRead() + } label: { + Label("Mark Read", systemImage: "checkmark") + } + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var accentColor: Color { + switch notification.kind { + case .approval: + .green + case .security: + .orange + case .system: + .blue + } + } +} + +private struct SectionCard: View { + let title: String + let subtitle: String + let compactLayout: Bool + let content: () -> Content + + init( + title: String, + subtitle: String, + compactLayout: Bool = false, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.subtitle = subtitle + self.compactLayout = compactLayout + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.title2.weight(.semibold)) + Text(subtitle) + .foregroundStyle(.secondary) + } + + content() + } + .padding(compactLayout ? 18 : 24) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(compactLayout ? 0.78 : 0.68), in: RoundedRectangle(cornerRadius: 32, style: .continuous)) + } +} + +private struct BannerCard: View { + let message: String + let compactLayout: Bool + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "sparkles") + .font(.title3) + .foregroundStyle(dashboardAccent) + Text(message) + .font(compactLayout ? .subheadline.weight(.semibold) : .headline) + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(.ultraThinMaterial, in: Capsule()) + } +} + +private struct SmallMetricPill: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title.uppercased()) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Text(value) + .font(.headline) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } +} + +private struct HeroMetric: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.72)) + Text(value) + .font(.title2.weight(.bold)) + .foregroundStyle(.white) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.white.opacity(0.12), in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} + +private struct GuidanceRow: View { + let icon: String + let title: String + let message: String + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .font(.title3) + .frame(width: 32) + .foregroundStyle(dashboardAccent) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(message) + .foregroundStyle(.secondary) + } + } + } +} + +private struct GuidanceCard: View { + let icon: String + let title: String + let message: String + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: icon) + .font(.title3) + .foregroundStyle(dashboardAccent) + + Text(title) + .font(.headline) + + Text(message) + .foregroundStyle(.secondary) + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + } +} + +private struct FlowScopes: View { + let scopes: [String] + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(scopes, id: \.self) { scope in + Text(scope) + .font(.caption.monospaced()) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(.thinMaterial, in: Capsule()) + } + } + } + } +} + +private struct EmptyStateCopy: View { + let title: String + let systemImage: String + let message: String + + var body: some View { + ContentUnavailableView( + title, + systemImage: systemImage, + description: Text(message) + ) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } +} + +private struct FactCard: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(label.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(value) + .font(.body) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} + +private struct StatusBadge: View { + let title: String + let tone: Color + + var body: some View { + Text(title) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(tone.opacity(0.14), in: Capsule()) + .foregroundStyle(tone) + } +}