diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f5d182e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: macos-15 + + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app + + - name: Build macOS app + run: xcodebuild build -project "IDPGlobal.xcodeproj" -scheme "IDPGlobal" -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO + + - name: Run macOS tests + run: xcodebuild test -project "IDPGlobal.xcodeproj" -scheme "IDPGlobal" -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO diff --git a/IDPGlobal.xcodeproj/project.pbxproj b/IDPGlobal.xcodeproj/project.pbxproj index 7ec2d5b..112482c 100644 --- a/IDPGlobal.xcodeproj/project.pbxproj +++ b/IDPGlobal.xcodeproj/project.pbxproj @@ -26,6 +26,20 @@ B10000000000000000000011 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000E /* Assets.xcassets */; }; B10000000000000000000012 /* AppComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000F /* AppComponents.swift */; }; B10000000000000000000013 /* AppComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000F /* AppComponents.swift */; }; + B10000000000000000000014 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000010 /* AppTheme.swift */; }; + B10000000000000000000015 /* AppStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000011 /* AppStateStore.swift */; }; + B10000000000000000000016 /* OneTimePasscodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000012 /* OneTimePasscodeGenerator.swift */; }; + B10000000000000000000017 /* PairingPayloadParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000013 /* PairingPayloadParser.swift */; }; + B10000000000000000000018 /* HomePanels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000014 /* HomePanels.swift */; }; + B10000000000000000000019 /* HomeCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000015 /* HomeCards.swift */; }; + B1000000000000000000001A /* HomeSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000016 /* HomeSheets.swift */; }; + B1000000000000000000001B /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000010 /* AppTheme.swift */; }; + B1000000000000000000001C /* AppStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000011 /* AppStateStore.swift */; }; + B1000000000000000000001D /* PairingPayloadParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000013 /* PairingPayloadParser.swift */; }; + B1000000000000000000001E /* PairingPayloadParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000017 /* PairingPayloadParserTests.swift */; }; + B1000000000000000000001F /* OneTimePasscodeGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */; }; + B10000000000000000000020 /* AppViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000019 /* AppViewModelTests.swift */; }; + B10000000000000000000021 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B2000000000000000000001B /* XCTest.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -36,6 +50,13 @@ remoteGlobalIDString = B50000000000000000000002; remoteInfo = IDPGlobalWatch; }; + B90000000000000000000003 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B60000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B50000000000000000000001; + remoteInfo = IDPGlobal; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -68,6 +89,18 @@ B2000000000000000000000D /* NFCPairingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCPairingView.swift; sourceTree = ""; }; B2000000000000000000000E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B2000000000000000000000F /* AppComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppComponents.swift; sourceTree = ""; }; + B20000000000000000000010 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; + B20000000000000000000011 /* AppStateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateStore.swift; sourceTree = ""; }; + B20000000000000000000012 /* OneTimePasscodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneTimePasscodeGenerator.swift; sourceTree = ""; }; + B20000000000000000000013 /* PairingPayloadParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingPayloadParser.swift; sourceTree = ""; }; + B20000000000000000000014 /* HomePanels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePanels.swift; sourceTree = ""; }; + B20000000000000000000015 /* HomeCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCards.swift; sourceTree = ""; }; + B20000000000000000000016 /* HomeSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSheets.swift; sourceTree = ""; }; + B20000000000000000000017 /* PairingPayloadParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingPayloadParserTests.swift; sourceTree = ""; }; + B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneTimePasscodeGeneratorTests.swift; sourceTree = ""; }; + B20000000000000000000019 /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = ""; }; + B2000000000000000000001A /* IDPGlobalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IDPGlobalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B2000000000000000000001B /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +118,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B30000000000000000000009 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000021 /* XCTest.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -102,6 +143,7 @@ B2000000000000000000000E /* Assets.xcassets */, B40000000000000000000003 /* Sources */, B4000000000000000000000C /* WatchApp */, + B4000000000000000000000F /* Tests */, ); name = IDPGlobal; sourceTree = ""; @@ -119,6 +161,7 @@ B40000000000000000000004 /* App */ = { isa = PBXGroup; children = ( + B20000000000000000000010 /* AppTheme.swift */, B2000000000000000000000F /* AppComponents.swift */, B20000000000000000000001 /* IDPGlobalApp.swift */, B20000000000000000000002 /* AppViewModel.swift */, @@ -146,8 +189,11 @@ B40000000000000000000007 /* Services */ = { isa = PBXGroup; children = ( + B20000000000000000000011 /* AppStateStore.swift */, B20000000000000000000004 /* MockIDPService.swift */, + B20000000000000000000012 /* OneTimePasscodeGenerator.swift */, B20000000000000000000005 /* NotificationCoordinator.swift */, + B20000000000000000000013 /* PairingPayloadParser.swift */, ); path = Services; sourceTree = ""; @@ -166,6 +212,7 @@ children = ( B20000000000000000000009 /* IDPGlobal.app */, B2000000000000000000000A /* IDPGlobalWatch.app */, + B2000000000000000000001A /* IDPGlobalTests.xctest */, ); name = Products; sourceTree = ""; @@ -183,7 +230,10 @@ B4000000000000000000000B /* Home */ = { isa = PBXGroup; children = ( + B20000000000000000000015 /* HomeCards.swift */, + B20000000000000000000014 /* HomePanels.swift */, B20000000000000000000008 /* HomeRootView.swift */, + B20000000000000000000016 /* HomeSheets.swift */, ); path = Home; sourceTree = ""; @@ -213,6 +263,16 @@ path = Features; sourceTree = ""; }; + B4000000000000000000000F /* Tests */ = { + isa = PBXGroup; + children = ( + B20000000000000000000019 /* AppViewModelTests.swift */, + B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */, + B20000000000000000000017 /* PairingPayloadParserTests.swift */, + ); + path = Tests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -252,6 +312,24 @@ productReference = B2000000000000000000000A /* IDPGlobalWatch.app */; productType = "com.apple.product-type.application"; }; + B50000000000000000000003 /* IDPGlobalTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B70000000000000000000004 /* Build configuration list for PBXNativeTarget "IDPGlobalTests" */; + buildPhases = ( + B30000000000000000000008 /* Sources */, + B30000000000000000000009 /* Frameworks */, + B3000000000000000000000A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + B90000000000000000000004 /* PBXTargetDependency */, + ); + name = IDPGlobalTests; + productName = IDPGlobalTests; + productReference = B2000000000000000000001A /* IDPGlobalTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -268,6 +346,10 @@ B50000000000000000000002 = { CreatedOnToolsVersion = 26.0; }; + B50000000000000000000003 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = B50000000000000000000001; + }; }; }; buildConfigurationList = B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */; @@ -285,6 +367,7 @@ targets = ( B50000000000000000000001 /* IDPGlobal */, B50000000000000000000002 /* IDPGlobalWatch */, + B50000000000000000000003 /* IDPGlobalTests */, ); }; /* End PBXProject section */ @@ -305,6 +388,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B3000000000000000000000A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -312,15 +402,22 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B10000000000000000000015 /* AppStateStore.swift in Sources */, B10000000000000000000012 /* AppComponents.swift in Sources */, + B10000000000000000000014 /* AppTheme.swift in Sources */, B10000000000000000000002 /* AppViewModel.swift in Sources */, + B10000000000000000000019 /* HomeCards.swift in Sources */, + B10000000000000000000018 /* HomePanels.swift in Sources */, B10000000000000000000008 /* HomeRootView.swift in Sources */, + B1000000000000000000001A /* HomeSheets.swift in Sources */, B10000000000000000000001 /* IDPGlobalApp.swift in Sources */, B10000000000000000000006 /* LoginRootView.swift in Sources */, B10000000000000000000004 /* MockIDPService.swift in Sources */, B10000000000000000000010 /* NFCPairingView.swift in Sources */, B10000000000000000000005 /* NotificationCoordinator.swift in Sources */, + B10000000000000000000016 /* OneTimePasscodeGenerator.swift in Sources */, B10000000000000000000003 /* AppModels.swift in Sources */, + B10000000000000000000017 /* PairingPayloadParser.swift in Sources */, B10000000000000000000007 /* QRScannerView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -329,16 +426,29 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B1000000000000000000001C /* AppStateStore.swift in Sources */, B10000000000000000000013 /* AppComponents.swift in Sources */, + B1000000000000000000001B /* AppTheme.swift in Sources */, B10000000000000000000009 /* AppViewModel.swift in Sources */, B1000000000000000000000A /* AppModels.swift in Sources */, B1000000000000000000000D /* IDPGlobalWatchApp.swift in Sources */, B1000000000000000000000B /* MockIDPService.swift in Sources */, B1000000000000000000000C /* NotificationCoordinator.swift in Sources */, + B1000000000000000000001D /* PairingPayloadParser.swift in Sources */, B1000000000000000000000E /* WatchRootView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + B30000000000000000000008 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000020 /* AppViewModelTests.swift in Sources */, + B1000000000000000000001F /* OneTimePasscodeGeneratorTests.swift in Sources */, + B1000000000000000000001E /* PairingPayloadParserTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -348,6 +458,11 @@ target = B50000000000000000000002 /* IDPGlobalWatch */; targetProxy = B90000000000000000000001 /* PBXContainerItemProxy */; }; + B90000000000000000000004 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B50000000000000000000001 /* IDPGlobal */; + targetProxy = B90000000000000000000003 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -415,6 +530,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_TESTABILITY = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "idp.global"; @@ -435,6 +551,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBSERVATION_ENABLED = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -532,6 +649,46 @@ }; name = Release; }; + B80000000000000000000007 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal"; + TEST_TARGET_NAME = IDPGlobal; + }; + name = Debug; + }; + B80000000000000000000008 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal"; + TEST_TARGET_NAME = IDPGlobal; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -562,6 +719,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B70000000000000000000004 /* Build configuration list for PBXNativeTarget "IDPGlobalTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B80000000000000000000007 /* Debug */, + B80000000000000000000008 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = B60000000000000000000001 /* Project object */; diff --git a/IDPGlobal.xcodeproj/xcshareddata/xcschemes/IDPGlobal.xcscheme b/IDPGlobal.xcodeproj/xcshareddata/xcschemes/IDPGlobal.xcscheme new file mode 100644 index 0000000..4e60a53 --- /dev/null +++ b/IDPGlobal.xcodeproj/xcshareddata/xcschemes/IDPGlobal.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/App/AppComponents.swift b/Sources/App/AppComponents.swift index 87d7cf6..f670881 100644 --- a/Sources/App/AppComponents.swift +++ b/Sources/App/AppComponents.swift @@ -1,261 +1,4 @@ import SwiftUI -#if os(macOS) -import AppKit -#elseif canImport(UIKit) -import UIKit -#endif - -private extension Color { - static func adaptive( - light: (red: Double, green: Double, blue: Double, opacity: Double), - dark: (red: Double, green: Double, blue: Double, opacity: Double) - ) -> Color { - #if os(macOS) - Color( - nsColor: NSColor(name: nil) { appearance in - let matchedAppearance = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight]) - let components = matchedAppearance == .darkAqua || matchedAppearance == .vibrantDark ? dark : light - return NSColor( - red: components.red, - green: components.green, - blue: components.blue, - alpha: components.opacity - ) - } - ) - #elseif canImport(UIKit) && !os(watchOS) - Color( - uiColor: UIColor { traits in - let components = traits.userInterfaceStyle == .dark ? dark : light - return UIColor( - red: components.red, - green: components.green, - blue: components.blue, - alpha: components.opacity - ) - } - ) - #elseif os(watchOS) - Color( - red: dark.red, - green: dark.green, - blue: dark.blue, - opacity: dark.opacity - ) - #else - Color( - red: light.red, - green: light.green, - blue: light.blue, - opacity: light.opacity - ) - #endif - } -} - -enum AppTheme { - static let accent = Color(red: 0.12, green: 0.40, blue: 0.31) - static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48) - static let border = Color.adaptive( - light: (0.00, 0.00, 0.00, 0.08), - dark: (1.00, 1.00, 1.00, 0.12) - ) - static let shadow = Color.adaptive( - light: (0.00, 0.00, 0.00, 0.05), - dark: (0.00, 0.00, 0.00, 0.32) - ) - static let cardFill = Color.adaptive( - light: (1.00, 1.00, 1.00, 0.96), - dark: (0.11, 0.12, 0.14, 0.96) - ) - static let mutedFill = Color.adaptive( - light: (0.972, 0.976, 0.970, 1.00), - dark: (0.16, 0.17, 0.19, 1.00) - ) - static let backgroundTop = Color.adaptive( - light: (0.975, 0.978, 0.972, 1.00), - dark: (0.08, 0.09, 0.10, 1.00) - ) - static let backgroundBottom = Color.adaptive( - light: (1.00, 1.00, 1.00, 1.00), - dark: (0.05, 0.06, 0.07, 1.00) - ) - static let backgroundGlow = Color.adaptive( - light: (0.00, 0.00, 0.00, 0.02), - dark: (1.00, 1.00, 1.00, 0.06) - ) - static let chromeFill = Color.adaptive( - light: (1.00, 1.00, 1.00, 0.98), - dark: (0.10, 0.11, 0.13, 0.98) - ) -} - -enum AppLayout { - static let compactHorizontalPadding: CGFloat = 16 - static let regularHorizontalPadding: CGFloat = 28 - static let compactVerticalPadding: CGFloat = 18 - static let regularVerticalPadding: CGFloat = 28 - static let compactContentWidth: CGFloat = 720 - static let regularContentWidth: CGFloat = 920 - static let cardRadius: CGFloat = 24 - static let largeCardRadius: CGFloat = 30 - static let compactSectionPadding: CGFloat = 18 - static let regularSectionPadding: CGFloat = 24 - static let compactSectionSpacing: CGFloat = 18 - static let regularSectionSpacing: CGFloat = 24 - static let compactBottomDockPadding: CGFloat = 120 - static let regularBottomPadding: CGFloat = 56 - - static func horizontalPadding(for compactLayout: Bool) -> CGFloat { - compactLayout ? compactHorizontalPadding : regularHorizontalPadding - } - - static func verticalPadding(for compactLayout: Bool) -> CGFloat { - compactLayout ? compactVerticalPadding : regularVerticalPadding - } - - static func contentWidth(for compactLayout: Bool) -> CGFloat { - compactLayout ? compactContentWidth : regularContentWidth - } - - static func sectionPadding(for compactLayout: Bool) -> CGFloat { - compactLayout ? compactSectionPadding : regularSectionPadding - } - - static func sectionSpacing(for compactLayout: Bool) -> CGFloat { - compactLayout ? compactSectionSpacing : regularSectionSpacing - } -} - -extension View { - func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View { - background( - fill, - in: RoundedRectangle(cornerRadius: radius, style: .continuous) - ) - .overlay( - RoundedRectangle(cornerRadius: radius, style: .continuous) - .stroke(AppTheme.border, lineWidth: 1) - ) - .shadow(color: AppTheme.shadow, radius: 12, y: 3) - } -} - -struct AppBackground: View { - var body: some View { - LinearGradient( - colors: [ - AppTheme.backgroundTop, - AppTheme.backgroundBottom - ], - startPoint: .top, - endPoint: .bottom - ) - .overlay(alignment: .top) { - Rectangle() - .fill(AppTheme.backgroundGlow) - .frame(height: 160) - .blur(radius: 60) - .offset(y: -90) - } - .ignoresSafeArea() - } -} - -struct AppScrollScreen: View { - let compactLayout: Bool - var bottomPadding: CGFloat? = nil - let content: () -> Content - - init( - compactLayout: Bool, - bottomPadding: CGFloat? = nil, - @ViewBuilder content: @escaping () -> Content - ) { - self.compactLayout = compactLayout - self.bottomPadding = bottomPadding - self.content = content - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { - content() - } - .frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading) - .padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout)) - .padding(.top, AppLayout.verticalPadding(for: compactLayout)) - .padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout)) - .frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center) - } - .scrollIndicators(.hidden) - } -} - -struct AppPanel: View { - let compactLayout: Bool - let radius: CGFloat - let content: () -> Content - - init( - compactLayout: Bool, - radius: CGFloat = AppLayout.cardRadius, - @ViewBuilder content: @escaping () -> Content - ) { - self.compactLayout = compactLayout - self.radius = radius - self.content = content - } - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - content() - } - .padding(AppLayout.sectionPadding(for: compactLayout)) - .frame(maxWidth: .infinity, alignment: .leading) - .appSurface(radius: radius) - } -} - -struct AppBadge: View { - let title: String - var tone: Color = AppTheme.accent - - var body: some View { - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(tone) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(tone.opacity(0.10), in: Capsule()) - } -} - -struct AppSectionCard: View { - let title: String - var subtitle: String? = nil - let compactLayout: Bool - let content: () -> Content - - init( - title: String, - subtitle: String? = nil, - compactLayout: Bool, - @ViewBuilder content: @escaping () -> Content - ) { - self.title = title - self.subtitle = subtitle - self.compactLayout = compactLayout - self.content = content - } - - var body: some View { - AppPanel(compactLayout: compactLayout) { - AppSectionTitle(title: title, subtitle: subtitle) - content() - } - } -} struct AppSectionTitle: View { let title: String diff --git a/Sources/App/AppTheme.swift b/Sources/App/AppTheme.swift new file mode 100644 index 0000000..70b421d --- /dev/null +++ b/Sources/App/AppTheme.swift @@ -0,0 +1,258 @@ +import SwiftUI +#if os(macOS) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif + +private extension Color { + static func adaptive( + light: (red: Double, green: Double, blue: Double, opacity: Double), + dark: (red: Double, green: Double, blue: Double, opacity: Double) + ) -> Color { + #if os(macOS) + Color( + nsColor: NSColor(name: nil) { appearance in + let matchedAppearance = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight]) + let components = matchedAppearance == .darkAqua || matchedAppearance == .vibrantDark ? dark : light + return NSColor( + red: components.red, + green: components.green, + blue: components.blue, + alpha: components.opacity + ) + } + ) + #elseif canImport(UIKit) && !os(watchOS) + Color( + uiColor: UIColor { traits in + let components = traits.userInterfaceStyle == .dark ? dark : light + return UIColor( + red: components.red, + green: components.green, + blue: components.blue, + alpha: components.opacity + ) + } + ) + #elseif os(watchOS) + Color( + red: dark.red, + green: dark.green, + blue: dark.blue, + opacity: dark.opacity + ) + #else + Color( + red: light.red, + green: light.green, + blue: light.blue, + opacity: light.opacity + ) + #endif + } +} + +enum AppTheme { + static let accent = Color(red: 0.12, green: 0.40, blue: 0.31) + static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48) + static let border = Color.adaptive( + light: (0.00, 0.00, 0.00, 0.08), + dark: (1.00, 1.00, 1.00, 0.12) + ) + static let shadow = Color.adaptive( + light: (0.00, 0.00, 0.00, 0.05), + dark: (0.00, 0.00, 0.00, 0.32) + ) + static let cardFill = Color.adaptive( + light: (1.00, 1.00, 1.00, 0.96), + dark: (0.11, 0.12, 0.14, 0.96) + ) + static let mutedFill = Color.adaptive( + light: (0.972, 0.976, 0.970, 1.00), + dark: (0.16, 0.17, 0.19, 1.00) + ) + static let backgroundTop = Color.adaptive( + light: (0.975, 0.978, 0.972, 1.00), + dark: (0.08, 0.09, 0.10, 1.00) + ) + static let backgroundBottom = Color.adaptive( + light: (1.00, 1.00, 1.00, 1.00), + dark: (0.05, 0.06, 0.07, 1.00) + ) + static let backgroundGlow = Color.adaptive( + light: (0.00, 0.00, 0.00, 0.02), + dark: (1.00, 1.00, 1.00, 0.06) + ) + static let chromeFill = Color.adaptive( + light: (1.00, 1.00, 1.00, 0.98), + dark: (0.10, 0.11, 0.13, 0.98) + ) +} + +enum AppLayout { + static let compactHorizontalPadding: CGFloat = 16 + static let regularHorizontalPadding: CGFloat = 28 + static let compactVerticalPadding: CGFloat = 18 + static let regularVerticalPadding: CGFloat = 28 + static let compactContentWidth: CGFloat = 720 + static let regularContentWidth: CGFloat = 920 + static let cardRadius: CGFloat = 24 + static let largeCardRadius: CGFloat = 30 + static let compactSectionPadding: CGFloat = 18 + static let regularSectionPadding: CGFloat = 24 + static let compactSectionSpacing: CGFloat = 18 + static let regularSectionSpacing: CGFloat = 24 + static let compactBottomDockPadding: CGFloat = 120 + static let regularBottomPadding: CGFloat = 56 + + static func horizontalPadding(for compactLayout: Bool) -> CGFloat { + compactLayout ? compactHorizontalPadding : regularHorizontalPadding + } + + static func verticalPadding(for compactLayout: Bool) -> CGFloat { + compactLayout ? compactVerticalPadding : regularVerticalPadding + } + + static func contentWidth(for compactLayout: Bool) -> CGFloat { + compactLayout ? compactContentWidth : regularContentWidth + } + + static func sectionPadding(for compactLayout: Bool) -> CGFloat { + compactLayout ? compactSectionPadding : regularSectionPadding + } + + static func sectionSpacing(for compactLayout: Bool) -> CGFloat { + compactLayout ? compactSectionSpacing : regularSectionSpacing + } +} + +extension View { + func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View { + background( + fill, + in: RoundedRectangle(cornerRadius: radius, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: radius, style: .continuous) + .stroke(AppTheme.border, lineWidth: 1) + ) + .shadow(color: AppTheme.shadow, radius: 12, y: 3) + } +} + +struct AppBackground: View { + var body: some View { + LinearGradient( + colors: [ + AppTheme.backgroundTop, + AppTheme.backgroundBottom + ], + startPoint: .top, + endPoint: .bottom + ) + .overlay(alignment: .top) { + Rectangle() + .fill(AppTheme.backgroundGlow) + .frame(height: 160) + .blur(radius: 60) + .offset(y: -90) + } + .ignoresSafeArea() + } +} + +struct AppScrollScreen: View { + let compactLayout: Bool + var bottomPadding: CGFloat? = nil + let content: () -> Content + + init( + compactLayout: Bool, + bottomPadding: CGFloat? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.compactLayout = compactLayout + self.bottomPadding = bottomPadding + self.content = content + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { + content() + } + .frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading) + .padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout)) + .padding(.top, AppLayout.verticalPadding(for: compactLayout)) + .padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout)) + .frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center) + } + .scrollIndicators(.hidden) + } +} + +struct AppPanel: View { + let compactLayout: Bool + let radius: CGFloat + let content: () -> Content + + init( + compactLayout: Bool, + radius: CGFloat = AppLayout.cardRadius, + @ViewBuilder content: @escaping () -> Content + ) { + self.compactLayout = compactLayout + self.radius = radius + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + content() + } + .padding(AppLayout.sectionPadding(for: compactLayout)) + .frame(maxWidth: .infinity, alignment: .leading) + .appSurface(radius: radius) + } +} + +struct AppBadge: View { + let title: String + var tone: Color = AppTheme.accent + + var body: some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(tone) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(tone.opacity(0.10), in: Capsule()) + } +} + +struct AppSectionCard: View { + let title: String + var subtitle: String? = nil + let compactLayout: Bool + let content: () -> Content + + init( + title: String, + subtitle: String? = nil, + compactLayout: Bool, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.subtitle = subtitle + self.compactLayout = compactLayout + self.content = content + } + + var body: some View { + AppPanel(compactLayout: compactLayout) { + AppSectionTitle(title: title, subtitle: subtitle) + content() + } + } +} diff --git a/Sources/App/AppViewModel.swift b/Sources/App/AppViewModel.swift index 79affd5..a0c40cc 100644 --- a/Sources/App/AppViewModel.swift +++ b/Sources/App/AppViewModel.swift @@ -23,6 +23,7 @@ final class AppViewModel: ObservableObject { private var hasBootstrapped = false private let service: IDPServicing private let notificationCoordinator: NotificationCoordinating + private let appStateStore: AppStateStoring private let launchArguments: [String] private var preferredLaunchSection: AppSection? { @@ -40,10 +41,12 @@ final class AppViewModel: ObservableObject { init( service: IDPServicing = MockIDPService(), notificationCoordinator: NotificationCoordinating = NotificationCoordinator(), + appStateStore: AppStateStoring = UserDefaultsAppStateStore(), launchArguments: [String] = ProcessInfo.processInfo.arguments ) { self.service = service self.notificationCoordinator = notificationCoordinator + self.appStateStore = appStateStore self.launchArguments = launchArguments } @@ -79,14 +82,17 @@ final class AppViewModel: ObservableObject { guard !hasBootstrapped else { return } hasBootstrapped = true + restorePersistedState() + isBootstrapping = true defer { isBootstrapping = false } + notificationPermission = await notificationCoordinator.authorizationStatus() + do { let bootstrap = try await service.bootstrap() suggestedPairingPayload = bootstrap.suggestedPairingPayload - manualPairingPayload = bootstrap.suggestedPairingPayload - notificationPermission = await notificationCoordinator.authorizationStatus() + manualPairingPayload = session?.pairingCode ?? bootstrap.suggestedPairingPayload if launchArguments.contains("--mock-auto-pair"), session == nil { @@ -97,7 +103,9 @@ final class AppViewModel: ObservableObject { } } } catch { - errorMessage = "Unable to prepare the app." + if session == nil { + errorMessage = "Unable to prepare the app." + } } } @@ -144,6 +152,7 @@ final class AppViewModel: ObservableObject { let result = try await service.signIn(with: normalizedRequest) session = result.session apply(snapshot: result.snapshot) + persistCurrentState() notificationPermission = await notificationCoordinator.authorizationStatus() selectedSection = .overview errorMessage = nil @@ -200,6 +209,7 @@ final class AppViewModel: ObservableObject { do { let snapshot = try await service.identify(with: normalizedRequest) apply(snapshot: snapshot) + persistCurrentState() errorMessage = nil isScannerPresented = false } catch let error as AppError { @@ -218,6 +228,7 @@ final class AppViewModel: ObservableObject { do { let snapshot = try await service.refreshDashboard() apply(snapshot: snapshot) + persistCurrentState() errorMessage = nil } catch { errorMessage = "Unable to refresh the dashboard." @@ -238,6 +249,7 @@ final class AppViewModel: ObservableObject { do { let snapshot = try await service.simulateIncomingRequest() apply(snapshot: snapshot) + persistCurrentState() selectedSection = .requests errorMessage = nil } catch { @@ -271,6 +283,7 @@ final class AppViewModel: ObservableObject { do { let snapshot = try await service.markNotificationRead(id: notification.id) apply(snapshot: snapshot) + persistCurrentState() errorMessage = nil } catch { errorMessage = "Unable to update the notification." @@ -278,6 +291,7 @@ final class AppViewModel: ObservableObject { } func signOut() { + appStateStore.clear() session = nil profile = nil requests = [] @@ -298,12 +312,45 @@ final class AppViewModel: ObservableObject { ? try await service.approveRequest(id: request.id) : try await service.rejectRequest(id: request.id) apply(snapshot: snapshot) + persistCurrentState() errorMessage = nil } catch { errorMessage = "Unable to update the identity check." } } + private func restorePersistedState() { + guard let state = appStateStore.load() else { + return + } + + session = state.session + manualPairingPayload = state.session.pairingCode + apply( + snapshot: DashboardSnapshot( + profile: state.profile, + requests: state.requests, + notifications: state.notifications + ) + ) + } + + private func persistCurrentState() { + guard let session, let profile else { + appStateStore.clear() + return + } + + appStateStore.save( + PersistedAppState( + session: session, + profile: profile, + requests: requests, + notifications: notifications + ) + ) + } + private func apply(snapshot: DashboardSnapshot) { profile = snapshot.profile requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt } diff --git a/Sources/Core/Models/AppModels.swift b/Sources/Core/Models/AppModels.swift index 326fa49..c1304b9 100644 --- a/Sources/Core/Models/AppModels.swift +++ b/Sources/Core/Models/AppModels.swift @@ -1,7 +1,7 @@ import CryptoKit import Foundation -enum AppSection: String, CaseIterable, Identifiable, Hashable { +enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable { case overview case requests case activity @@ -28,7 +28,7 @@ enum AppSection: String, CaseIterable, Identifiable, Hashable { } } -enum NotificationPermissionState: String, CaseIterable, Identifiable { +enum NotificationPermissionState: String, CaseIterable, Identifiable, Codable { case unknown case allowed case provisional @@ -72,7 +72,7 @@ struct BootstrapContext { let suggestedPairingPayload: String } -enum PairingTransport: String, Hashable { +enum PairingTransport: String, Hashable, Codable { case qr case nfc case manual @@ -98,7 +98,7 @@ struct PairingAuthenticationRequest: Hashable { let signedGPSPosition: SignedGPSPosition? } -struct SignedGPSPosition: Hashable { +struct SignedGPSPosition: Hashable, Codable { let latitude: Double let longitude: Double let horizontalAccuracyMeters: Double @@ -185,7 +185,7 @@ struct SignInResult { let snapshot: DashboardSnapshot } -struct MemberProfile: Identifiable, Hashable { +struct MemberProfile: Identifiable, Hashable, Codable { let id: UUID let name: String let handle: String @@ -210,7 +210,7 @@ struct MemberProfile: Identifiable, Hashable { } } -struct AuthSession: Identifiable, Hashable { +struct AuthSession: Identifiable, Hashable, Codable { let id: UUID let deviceName: String let originHost: String @@ -241,7 +241,7 @@ struct AuthSession: Identifiable, Hashable { } } -enum ApprovalRequestKind: String, CaseIterable, Hashable { +enum ApprovalRequestKind: String, CaseIterable, Hashable, Codable { case signIn case accessGrant case elevatedAction @@ -263,7 +263,7 @@ enum ApprovalRequestKind: String, CaseIterable, Hashable { } } -enum ApprovalRisk: String, Hashable { +enum ApprovalRisk: String, Hashable, Codable { case routine case elevated @@ -293,7 +293,7 @@ enum ApprovalRisk: String, Hashable { } } -enum ApprovalStatus: String, Hashable { +enum ApprovalStatus: String, Hashable, Codable { case pending case approved case rejected @@ -315,7 +315,7 @@ enum ApprovalStatus: String, Hashable { } } -struct ApprovalRequest: Identifiable, Hashable { +struct ApprovalRequest: Identifiable, Hashable, Codable { let id: UUID let title: String let subtitle: String @@ -382,7 +382,7 @@ struct ApprovalRequest: Identifiable, Hashable { } } -enum AppNotificationKind: String, Hashable { +enum AppNotificationKind: String, Hashable, Codable { case approval case security case system @@ -415,7 +415,7 @@ enum AppNotificationKind: String, Hashable { } } -struct AppNotification: Identifiable, Hashable { +struct AppNotification: Identifiable, Hashable, Codable { let id: UUID let title: String let message: String @@ -440,7 +440,7 @@ struct AppNotification: Identifiable, Hashable { } } -enum AppError: LocalizedError { +enum AppError: LocalizedError, Equatable { case invalidPairingPayload case missingSignedGPSPosition case invalidSignedGPSPosition diff --git a/Sources/Core/Services/AppStateStore.swift b/Sources/Core/Services/AppStateStore.swift new file mode 100644 index 0000000..f6a592e --- /dev/null +++ b/Sources/Core/Services/AppStateStore.swift @@ -0,0 +1,46 @@ +import Foundation + +struct PersistedAppState: Codable, Equatable { + let session: AuthSession + let profile: MemberProfile + let requests: [ApprovalRequest] + let notifications: [AppNotification] +} + +protocol AppStateStoring { + func load() -> PersistedAppState? + func save(_ state: PersistedAppState) + func clear() +} + +final class UserDefaultsAppStateStore: AppStateStoring { + private let defaults: UserDefaults + private let storageKey: String + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(defaults: UserDefaults = .standard, storageKey: String = "persisted-app-state") { + self.defaults = defaults + self.storageKey = storageKey + } + + func load() -> PersistedAppState? { + guard let data = defaults.data(forKey: storageKey) else { + return nil + } + + return try? decoder.decode(PersistedAppState.self, from: data) + } + + func save(_ state: PersistedAppState) { + guard let data = try? encoder.encode(state) else { + return + } + + defaults.set(data, forKey: storageKey) + } + + func clear() { + defaults.removeObject(forKey: storageKey) + } +} diff --git a/Sources/Core/Services/MockIDPService.swift b/Sources/Core/Services/MockIDPService.swift index 5c90650..0beac26 100644 --- a/Sources/Core/Services/MockIDPService.swift +++ b/Sources/Core/Services/MockIDPService.swift @@ -61,7 +61,7 @@ actor MockIDPService: IDPServicing { try await Task.sleep(for: .milliseconds(180)) try validateSignedGPSPosition(in: request) - let context = try parsePayloadContext(from: request.pairingPayload) + let context = try PairingPayloadParser.parse(request.pairingPayload) notifications.insert( AppNotification( title: "Identity proof completed", @@ -186,7 +186,7 @@ actor MockIDPService: IDPServicing { } private func parseSession(from request: PairingAuthenticationRequest) throws -> AuthSession { - let context = try parsePayloadContext(from: request.pairingPayload) + let context = try PairingPayloadParser.parse(request.pairingPayload) return AuthSession( deviceName: context.deviceName, @@ -199,33 +199,6 @@ actor MockIDPService: IDPServicing { ) } - private func parsePayloadContext(from payload: String) throws -> PayloadContext { - if let components = URLComponents(string: payload), - components.scheme == "idp.global", - components.host == "pair" { - let queryItems = components.queryItems ?? [] - let token = queryItems.first(where: { $0.name == "token" })?.value ?? "demo-token" - let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global" - let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session" - - return PayloadContext( - deviceName: device, - originHost: origin, - tokenPreview: String(token.suffix(6)) - ) - } - - if payload.contains("token") || payload.contains("pair") { - return PayloadContext( - deviceName: "Manual Session", - originHost: "code.foss.global", - tokenPreview: String(payload.suffix(6)) - ) - } - - throw AppError.invalidPairingPayload - } - private func pairingMessage(for session: AuthSession) -> String { let transportSummary: String switch session.pairingTransport { @@ -246,7 +219,7 @@ actor MockIDPService: IDPServicing { return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost)." } - private func identificationMessage(for context: PayloadContext, signedGPSPosition: SignedGPSPosition?) -> String { + private func identificationMessage(for context: PairingPayloadContext, signedGPSPosition: SignedGPSPosition?) -> String { if let signedGPSPosition { return "A signed GPS proof was sent for \(context.deviceName) on \(context.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)." } @@ -254,12 +227,6 @@ actor MockIDPService: IDPServicing { return "An identity proof was completed for \(context.deviceName) on \(context.originHost)." } - private struct PayloadContext { - let deviceName: String - let originHost: String - let tokenPreview: String - } - private static func seedRequests() -> [ApprovalRequest] { [ ApprovalRequest( diff --git a/Sources/Core/Services/OneTimePasscodeGenerator.swift b/Sources/Core/Services/OneTimePasscodeGenerator.swift new file mode 100644 index 0000000..986980e --- /dev/null +++ b/Sources/Core/Services/OneTimePasscodeGenerator.swift @@ -0,0 +1,19 @@ +import CryptoKit +import Foundation + +enum OneTimePasscodeGenerator { + static func code(for pairingCode: String, at date: Date) -> String { + let timeSlot = Int(date.timeIntervalSince1970 / 30) + let digest = SHA256.hash(data: Data("\(pairingCode)|\(timeSlot)".utf8)) + let value = digest.prefix(4).reduce(UInt32(0)) { partialResult, byte in + (partialResult << 8) | UInt32(byte) + } + + return String(format: "%06d", locale: Locale(identifier: "en_US_POSIX"), Int(value % 1_000_000)) + } + + static func renewalCountdown(at date: Date) -> Int { + let elapsed = Int(date.timeIntervalSince1970) % 30 + return elapsed == 0 ? 30 : 30 - elapsed + } +} diff --git a/Sources/Core/Services/PairingPayloadParser.swift b/Sources/Core/Services/PairingPayloadParser.swift new file mode 100644 index 0000000..5f0739d --- /dev/null +++ b/Sources/Core/Services/PairingPayloadParser.swift @@ -0,0 +1,38 @@ +import Foundation + +struct PairingPayloadContext: Equatable { + let deviceName: String + let originHost: String + let tokenPreview: String +} + +enum PairingPayloadParser { + static func parse(_ payload: String) throws -> PairingPayloadContext { + let trimmedPayload = payload.trimmingCharacters(in: .whitespacesAndNewlines) + + if let components = URLComponents(string: trimmedPayload), + components.scheme == "idp.global", + components.host == "pair" { + let queryItems = components.queryItems ?? [] + let token = queryItems.first(where: { $0.name == "token" })?.value ?? "demo-token" + let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global" + let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session" + + return PairingPayloadContext( + deviceName: device, + originHost: origin, + tokenPreview: String(token.suffix(6)) + ) + } + + if trimmedPayload.contains("token") || trimmedPayload.contains("pair") { + return PairingPayloadContext( + deviceName: "Manual Session", + originHost: "code.foss.global", + tokenPreview: String(trimmedPayload.suffix(6)) + ) + } + + throw AppError.invalidPairingPayload + } +} diff --git a/Sources/Features/Home/HomeCards.swift b/Sources/Features/Home/HomeCards.swift new file mode 100644 index 0000000..05c815e --- /dev/null +++ b/Sources/Features/Home/HomeCards.swift @@ -0,0 +1,330 @@ +import SwiftUI + +struct RequestList: View { + let requests: [ApprovalRequest] + let compactLayout: Bool + let activeRequestID: ApprovalRequest.ID? + let onApprove: ((ApprovalRequest) -> Void)? + let onReject: ((ApprovalRequest) -> Void)? + let onOpenRequest: (ApprovalRequest) -> Void + + var body: some View { + VStack(spacing: 14) { + ForEach(requests) { request in + RequestCard( + request: request, + compactLayout: compactLayout, + isBusy: activeRequestID == request.id, + onApprove: onApprove == nil ? nil : { onApprove?(request) }, + onReject: onReject == nil ? nil : { onReject?(request) }, + onOpenRequest: { onOpenRequest(request) } + ) + } + } + } +} + +private struct RequestCard: View { + let request: ApprovalRequest + let compactLayout: Bool + let isBusy: Bool + let onApprove: (() -> Void)? + let onReject: (() -> Void)? + let onOpenRequest: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: request.kind.systemImage) + .font(.headline) + .foregroundStyle(requestAccent) + .frame(width: 28, height: 28) + + VStack(alignment: .leading, spacing: 4) { + Text(request.title) + .font(.headline) + .multilineTextAlignment(.leading) + + Text(request.source) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 0) + + AppStatusTag(title: request.status.title, tone: statusTone) + } + + Text(request.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + + HStack(spacing: 8) { + AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange) + Text(request.scopeSummary) + .font(.footnote) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + Text(request.createdAt, style: .relative) + .font(.footnote) + .foregroundStyle(.secondary) + } + + if !request.scopes.isEmpty { + Text("Proof details: \(request.scopes.joined(separator: ", "))") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + controls + } + .padding(compactLayout ? 18 : 20) + .appSurface(radius: 24) + } + + @ViewBuilder + private var controls: some View { + if compactLayout { + VStack(alignment: .leading, spacing: 10) { + reviewButton + decisionButtons + } + } else { + HStack(spacing: 12) { + reviewButton + Spacer(minLength: 0) + decisionButtons + } + } + } + + private var reviewButton: some View { + Button { + onOpenRequest() + } label: { + Label("Review proof", systemImage: "arrow.up.forward.app") + } + .buttonStyle(.bordered) + } + + @ViewBuilder + private var decisionButtons: some View { + if request.status == .pending, let onApprove, let onReject { + Button { + onApprove() + } label: { + if isBusy { + ProgressView() + } else { + Label("Verify", systemImage: "checkmark.circle.fill") + } + } + .buttonStyle(.borderedProminent) + .disabled(isBusy) + + Button(role: .destructive) { + onReject() + } label: { + Label("Decline", systemImage: "xmark.circle.fill") + } + .buttonStyle(.bordered) + .disabled(isBusy) + } + } + + private var statusTone: Color { + switch request.status { + case .pending: + .orange + case .approved: + .green + case .rejected: + .red + } + } + + private var requestAccent: Color { + switch request.status { + case .approved: + .green + case .rejected: + .red + case .pending: + request.risk == .routine ? dashboardAccent : .orange + } + } +} + +struct NotificationList: View { + let notifications: [AppNotification] + let compactLayout: Bool + let onMarkRead: (AppNotification) -> Void + + var body: some View { + VStack(spacing: 14) { + ForEach(notifications) { notification in + NotificationCard( + notification: notification, + compactLayout: compactLayout, + onMarkRead: { onMarkRead(notification) } + ) + } + } + } +} + +private struct NotificationCard: View { + let notification: AppNotification + let compactLayout: Bool + let onMarkRead: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: notification.kind.systemImage) + .font(.headline) + .foregroundStyle(accentColor) + .frame(width: 28, height: 28) + + VStack(alignment: .leading, spacing: 4) { + Text(notification.title) + .font(.headline) + + HStack(spacing: 8) { + AppStatusTag(title: notification.kind.title, tone: accentColor) + if notification.isUnread { + AppStatusTag(title: "Unread", tone: .orange) + } + } + } + + Spacer(minLength: 0) + } + + Text(notification.message) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if compactLayout { + VStack(alignment: .leading, spacing: 10) { + timestamp + if notification.isUnread { + markReadButton + } + } + } else { + HStack { + timestamp + Spacer(minLength: 0) + if notification.isUnread { + markReadButton + } + } + } + } + .padding(compactLayout ? 18 : 20) + .appSurface(radius: 24) + } + + private var timestamp: some View { + Text(notification.sentAt.formatted(date: .abbreviated, time: .shortened)) + .font(.footnote) + .foregroundStyle(.secondary) + } + + private var markReadButton: some View { + Button { + onMarkRead() + } label: { + Label("Mark read", systemImage: "checkmark") + } + .buttonStyle(.bordered) + } + + private var accentColor: Color { + switch notification.kind { + case .approval: + .green + case .security: + .orange + case .system: + .blue + } + } +} + +struct NotificationBellButton: View { + @ObservedObject var model: AppViewModel + + var body: some View { + Button { + model.isNotificationCenterPresented = true + } label: { + Image(systemName: imageName) + .font(.headline) + .foregroundStyle(iconTone) + .frame(width: 28, height: 28, alignment: .center) + .background(alignment: .center) { + #if os(iOS) + GeometryReader { proxy in + Color.clear + .preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global)) + } + #endif + } + } + .accessibilityLabel("Notifications") + } + + private var imageName: String { + #if os(iOS) + model.unreadNotificationCount == 0 ? "bell" : "bell.fill" + #else + model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill" + #endif + } + + private var iconTone: some ShapeStyle { + model.unreadNotificationCount == 0 ? Color.primary : dashboardAccent + } +} + +struct NotificationCenterSheet: View { + @ObservedObject var model: AppViewModel + @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + var body: some View { + NavigationStack { + AppScrollScreen( + compactLayout: compactLayout, + bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding + ) { + NotificationsPanel(model: model, compactLayout: compactLayout) + } + .navigationTitle("Notifications") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + } + #if os(iOS) + .presentationDetents(compactLayout ? [.large] : [.medium, .large]) + #endif + } + + private var compactLayout: Bool { + #if os(iOS) + horizontalSizeClass == .compact + #else + false + #endif + } +} diff --git a/Sources/Features/Home/HomePanels.swift b/Sources/Features/Home/HomePanels.swift new file mode 100644 index 0000000..77c9cf6 --- /dev/null +++ b/Sources/Features/Home/HomePanels.swift @@ -0,0 +1,317 @@ +import SwiftUI + +struct OverviewPanel: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + var body: some View { + VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { + if let profile = model.profile, let session = model.session { + OverviewHero( + profile: profile, + session: session, + pendingCount: model.pendingRequests.count, + unreadCount: model.unreadNotificationCount, + compactLayout: compactLayout + ) + } + } + } +} + +struct RequestsPanel: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + let onOpenRequest: (ApprovalRequest) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { + if model.requests.isEmpty { + AppPanel(compactLayout: compactLayout) { + EmptyStateCopy( + title: "No checks waiting", + systemImage: "checkmark.circle", + message: "Identity proof requests from sites and devices appear here." + ) + } + } else { + RequestList( + requests: model.requests, + compactLayout: compactLayout, + activeRequestID: model.activeRequestID, + onApprove: { request in + Task { await model.approve(request) } + }, + onReject: { request in + Task { await model.reject(request) } + }, + onOpenRequest: onOpenRequest + ) + } + } + } +} + +struct ActivityPanel: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + var body: some View { + VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { + if model.notifications.isEmpty { + AppPanel(compactLayout: compactLayout) { + EmptyStateCopy( + title: "No proof activity yet", + systemImage: "clock.badge.xmark", + message: "Identity proofs and security events will appear here." + ) + } + } else { + NotificationList( + notifications: model.notifications, + compactLayout: compactLayout, + onMarkRead: { notification in + Task { await model.markNotificationRead(notification) } + } + ) + } + } + } +} + +struct NotificationsPanel: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + var body: some View { + VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { + AppSectionCard(title: "Delivery", compactLayout: compactLayout) { + NotificationPermissionSummary(model: model, compactLayout: compactLayout) + } + + AppSectionCard(title: "Alerts", compactLayout: compactLayout) { + if model.notifications.isEmpty { + EmptyStateCopy( + title: "No alerts yet", + systemImage: "bell.slash", + message: "New passport and identity-proof alerts will accumulate here." + ) + } else { + NotificationList( + notifications: model.notifications, + compactLayout: compactLayout, + onMarkRead: { notification in + Task { await model.markNotificationRead(notification) } + } + ) + } + } + } + } +} + +struct AccountPanel: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + var body: some View { + VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { + if let profile = model.profile, let session = model.session { + AccountHero(profile: profile, session: session, compactLayout: compactLayout) + + AppSectionCard(title: "Session", compactLayout: compactLayout) { + AccountFactsGrid(profile: profile, session: session, compactLayout: compactLayout) + } + } + + AppSectionCard(title: "Pairing payload", compactLayout: compactLayout) { + AppTextSurface(text: model.suggestedPairingPayload, monospaced: true) + } + + AppSectionCard(title: "Actions", compactLayout: compactLayout) { + Button(role: .destructive) { + model.signOut() + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + } + .buttonStyle(.bordered) + } + } + } +} + +private struct OverviewHero: View { + let profile: MemberProfile + let session: AuthSession + let pendingCount: Int + let unreadCount: Int + let compactLayout: Bool + + private var detailColumns: [GridItem] { + Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2) + } + + private var metricColumns: [GridItem] { + Array(repeating: GridItem(.flexible(), spacing: 16), count: 3) + } + + var body: some View { + AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) { + AppBadge(title: "Digital passport", tone: dashboardAccent) + + VStack(alignment: .leading, spacing: 6) { + Text(profile.name) + .font(.system(size: compactLayout ? 30 : 38, weight: .bold, design: .rounded)) + .lineLimit(2) + + Text("\(profile.handle) • \(profile.organization)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + HStack(spacing: 8) { + AppStatusTag(title: "Passport active", tone: dashboardAccent) + AppStatusTag(title: session.pairingTransport.title, tone: dashboardGold) + } + + Divider() + + LazyVGrid(columns: detailColumns, alignment: .leading, spacing: 16) { + AppKeyValue(label: "Device", value: session.deviceName) + AppKeyValue(label: "Origin", value: session.originHost, monospaced: true) + AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened)) + AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true) + } + + Divider() + + LazyVGrid(columns: metricColumns, alignment: .leading, spacing: 16) { + AppMetric(title: "Pending", value: "\(pendingCount)") + AppMetric(title: "Alerts", value: "\(unreadCount)") + AppMetric(title: "Devices", value: "\(profile.deviceCount)") + } + } + } +} + +private struct NotificationPermissionSummary: View { + @ObservedObject var model: AppViewModel + let compactLayout: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: model.notificationPermission.systemImage) + .font(.headline) + .foregroundStyle(dashboardAccent) + .frame(width: 28, height: 28) + + VStack(alignment: .leading, spacing: 4) { + Text(model.notificationPermission.title) + .font(.headline) + Text(model.notificationPermission.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + if compactLayout { + VStack(alignment: .leading, spacing: 12) { + permissionButtons + } + } else { + HStack(spacing: 12) { + permissionButtons + } + } + } + } + + @ViewBuilder + private var permissionButtons: some View { + Button { + Task { await model.requestNotificationAccess() } + } label: { + Label("Enable notifications", systemImage: "bell.and.waves.left.and.right.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + + Button { + Task { await model.sendTestNotification() } + } label: { + Label("Send test alert", systemImage: "paperplane.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } +} + +private struct AccountHero: View { + let profile: MemberProfile + let session: AuthSession + let compactLayout: Bool + + var body: some View { + AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) { + AppBadge(title: "Account", tone: dashboardAccent) + + Text(profile.name) + .font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded)) + .lineLimit(2) + + Text(profile.handle) + .font(.headline) + .foregroundStyle(.secondary) + + Text("Active client: \(session.deviceName)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } +} + +private struct AccountFactsGrid: View { + let profile: MemberProfile + let session: AuthSession + let compactLayout: Bool + + private var columns: [GridItem] { + Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2) + } + + var body: some View { + LazyVGrid(columns: columns, alignment: .leading, spacing: 16) { + AppKeyValue(label: "Organization", value: profile.organization) + AppKeyValue(label: "Origin", value: session.originHost, monospaced: true) + AppKeyValue(label: "Linked At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened)) + AppKeyValue(label: "Method", value: session.pairingTransport.title) + AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true) + AppKeyValue(label: "Recovery", value: profile.recoverySummary) + if let signedGPSPosition = session.signedGPSPosition { + AppKeyValue( + label: "Signed GPS", + value: "\(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)", + monospaced: true + ) + } + AppKeyValue(label: "Trusted Devices", value: "\(profile.deviceCount)") + } + } +} + +private struct EmptyStateCopy: View { + let title: String + let systemImage: String + let message: String + + var body: some View { + ContentUnavailableView( + title, + systemImage: systemImage, + description: Text(message) + ) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } +} diff --git a/Sources/Features/Home/HomeRootView.swift b/Sources/Features/Home/HomeRootView.swift index 4f102e4..9271797 100644 --- a/Sources/Features/Home/HomeRootView.swift +++ b/Sources/Features/Home/HomeRootView.swift @@ -1,11 +1,9 @@ -import CryptoKit -import Foundation import SwiftUI -private let dashboardAccent = AppTheme.accent -private let dashboardGold = AppTheme.warmAccent +let dashboardAccent = AppTheme.accent +let dashboardGold = AppTheme.warmAccent -private extension View { +extension View { @ViewBuilder func inlineNavigationTitleOnIOS() -> some View { #if os(iOS) @@ -123,7 +121,7 @@ private struct DashboardToolbar: ToolbarContent { } } -private struct NotificationBellFrameKey: PreferenceKey { +struct NotificationBellFrameKey: PreferenceKey { static var defaultValue: CGRect? = nil static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) { @@ -356,850 +354,3 @@ private struct SidebarStatusCard: View { .padding(.vertical, 6) } } - -private struct OverviewPanel: View { - @ObservedObject var model: AppViewModel - let compactLayout: Bool - - var body: some View { - VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { - if let profile = model.profile, let session = model.session { - OverviewHero( - profile: profile, - session: session, - pendingCount: model.pendingRequests.count, - unreadCount: model.unreadNotificationCount, - compactLayout: compactLayout - ) - } - } - } -} - -private struct RequestsPanel: View { - @ObservedObject var model: AppViewModel - let compactLayout: Bool - let onOpenRequest: (ApprovalRequest) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { - if model.requests.isEmpty { - AppPanel(compactLayout: compactLayout) { - EmptyStateCopy( - title: "No checks waiting", - systemImage: "checkmark.circle", - message: "Identity proof requests from sites and devices appear here." - ) - } - } else { - RequestList( - requests: model.requests, - compactLayout: compactLayout, - activeRequestID: model.activeRequestID, - onApprove: { request in - Task { await model.approve(request) } - }, - onReject: { request in - Task { await model.reject(request) } - }, - onOpenRequest: onOpenRequest - ) - } - } - } -} - -private struct ActivityPanel: View { - @ObservedObject var model: AppViewModel - let compactLayout: Bool - - var body: some View { - VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { - if model.notifications.isEmpty { - AppPanel(compactLayout: compactLayout) { - EmptyStateCopy( - title: "No proof activity yet", - systemImage: "clock.badge.xmark", - message: "Identity proofs and security events will appear here." - ) - } - } else { - NotificationList( - notifications: model.notifications, - compactLayout: compactLayout, - onMarkRead: { notification in - Task { await model.markNotificationRead(notification) } - } - ) - } - } - } -} - -private struct NotificationsPanel: View { - @ObservedObject var model: AppViewModel - let compactLayout: Bool - - var body: some View { - VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { - AppSectionCard(title: "Delivery", compactLayout: compactLayout) { - NotificationPermissionSummary(model: model, compactLayout: compactLayout) - } - - AppSectionCard(title: "Alerts", compactLayout: compactLayout) { - if model.notifications.isEmpty { - EmptyStateCopy( - title: "No alerts yet", - systemImage: "bell.slash", - message: "New passport and identity-proof alerts will accumulate here." - ) - } else { - NotificationList( - notifications: model.notifications, - compactLayout: compactLayout, - onMarkRead: { notification in - Task { await model.markNotificationRead(notification) } - } - ) - } - } - } - } -} - -private struct AccountPanel: View { - @ObservedObject var model: AppViewModel - let compactLayout: Bool - - var body: some View { - VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { - if let profile = model.profile, let session = model.session { - AccountHero(profile: profile, session: session, compactLayout: compactLayout) - - AppSectionCard(title: "Session", compactLayout: compactLayout) { - AccountFactsGrid(profile: profile, session: session, compactLayout: compactLayout) - } - } - - AppSectionCard(title: "Pairing payload", compactLayout: compactLayout) { - AppTextSurface(text: model.suggestedPairingPayload, monospaced: true) - } - - AppSectionCard(title: "Actions", compactLayout: compactLayout) { - Button(role: .destructive) { - model.signOut() - } label: { - Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") - } - .buttonStyle(.bordered) - } - } - } -} - -private struct OverviewHero: View { - let profile: MemberProfile - let session: AuthSession - let pendingCount: Int - let unreadCount: Int - let compactLayout: Bool - - private var detailColumns: [GridItem] { - Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2) - } - - private var metricColumns: [GridItem] { - Array(repeating: GridItem(.flexible(), spacing: 16), count: 3) - } - - var body: some View { - AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) { - AppBadge(title: "Digital passport", tone: dashboardAccent) - - VStack(alignment: .leading, spacing: 6) { - Text(profile.name) - .font(.system(size: compactLayout ? 30 : 38, weight: .bold, design: .rounded)) - .lineLimit(2) - - Text("\(profile.handle) • \(profile.organization)") - .font(.subheadline) - .foregroundStyle(.secondary) - } - - HStack(spacing: 8) { - AppStatusTag(title: "Passport active", tone: dashboardAccent) - AppStatusTag(title: session.pairingTransport.title, tone: dashboardGold) - } - - Divider() - - LazyVGrid(columns: detailColumns, alignment: .leading, spacing: 16) { - AppKeyValue(label: "Device", value: session.deviceName) - AppKeyValue(label: "Origin", value: session.originHost, monospaced: true) - AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened)) - AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true) - } - - Divider() - - LazyVGrid(columns: metricColumns, alignment: .leading, spacing: 16) { - AppMetric(title: "Pending", value: "\(pendingCount)") - AppMetric(title: "Alerts", value: "\(unreadCount)") - AppMetric(title: "Devices", value: "\(profile.deviceCount)") - } - } - } -} - -private struct NotificationPermissionSummary: View { - @ObservedObject var model: AppViewModel - let compactLayout: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: model.notificationPermission.systemImage) - .font(.headline) - .foregroundStyle(dashboardAccent) - .frame(width: 28, height: 28) - - VStack(alignment: .leading, spacing: 4) { - Text(model.notificationPermission.title) - .font(.headline) - Text(model.notificationPermission.summary) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - - if compactLayout { - VStack(alignment: .leading, spacing: 12) { - permissionButtons - } - } else { - HStack(spacing: 12) { - permissionButtons - } - } - } - } - - @ViewBuilder - private var permissionButtons: some View { - Button { - Task { await model.requestNotificationAccess() } - } label: { - Label("Enable notifications", systemImage: "bell.and.waves.left.and.right.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - - Button { - Task { await model.sendTestNotification() } - } label: { - Label("Send test alert", systemImage: "paperplane.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - } -} - -private struct AccountHero: View { - let profile: MemberProfile - let session: AuthSession - let compactLayout: Bool - - var body: some View { - AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) { - AppBadge(title: "Account", tone: dashboardAccent) - - Text(profile.name) - .font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded)) - .lineLimit(2) - - Text(profile.handle) - .font(.headline) - .foregroundStyle(.secondary) - - Text("Active client: \(session.deviceName)") - .font(.subheadline) - .foregroundStyle(.secondary) - } - } -} - -private struct AccountFactsGrid: View { - let profile: MemberProfile - let session: AuthSession - let compactLayout: Bool - - private var columns: [GridItem] { - Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2) - } - - var body: some View { - LazyVGrid(columns: columns, alignment: .leading, spacing: 16) { - AppKeyValue(label: "Organization", value: profile.organization) - AppKeyValue(label: "Origin", value: session.originHost, monospaced: true) - AppKeyValue(label: "Linked At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened)) - AppKeyValue(label: "Method", value: session.pairingTransport.title) - AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true) - AppKeyValue(label: "Recovery", value: profile.recoverySummary) - if let signedGPSPosition = session.signedGPSPosition { - AppKeyValue( - label: "Signed GPS", - value: "\(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)", - monospaced: true - ) - } - AppKeyValue(label: "Trusted Devices", value: "\(profile.deviceCount)") - } - } -} - -private struct RequestList: View { - let requests: [ApprovalRequest] - let compactLayout: Bool - let activeRequestID: ApprovalRequest.ID? - let onApprove: ((ApprovalRequest) -> Void)? - let onReject: ((ApprovalRequest) -> Void)? - let onOpenRequest: (ApprovalRequest) -> Void - - var body: some View { - VStack(spacing: 14) { - ForEach(requests) { request in - RequestCard( - request: request, - compactLayout: compactLayout, - isBusy: activeRequestID == request.id, - onApprove: onApprove == nil ? nil : { onApprove?(request) }, - onReject: onReject == nil ? nil : { onReject?(request) }, - onOpenRequest: { onOpenRequest(request) } - ) - } - } - } -} - -private struct RequestCard: View { - let request: ApprovalRequest - let compactLayout: Bool - let isBusy: Bool - let onApprove: (() -> Void)? - let onReject: (() -> Void)? - let onOpenRequest: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: request.kind.systemImage) - .font(.headline) - .foregroundStyle(requestAccent) - .frame(width: 28, height: 28) - - VStack(alignment: .leading, spacing: 4) { - Text(request.title) - .font(.headline) - .multilineTextAlignment(.leading) - - Text(request.source) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer(minLength: 0) - - AppStatusTag(title: request.status.title, tone: statusTone) - } - - Text(request.subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(2) - - HStack(spacing: 8) { - AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange) - Text(request.scopeSummary) - .font(.footnote) - .foregroundStyle(.secondary) - Spacer(minLength: 0) - Text(request.createdAt, style: .relative) - .font(.footnote) - .foregroundStyle(.secondary) - } - - if !request.scopes.isEmpty { - Text("Proof details: \(request.scopes.joined(separator: ", "))") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(2) - } - - controls - } - .padding(compactLayout ? 18 : 20) - .appSurface(radius: 24) - } - - @ViewBuilder - private var controls: some View { - if compactLayout { - VStack(alignment: .leading, spacing: 10) { - reviewButton - decisionButtons - } - } else { - HStack(spacing: 12) { - reviewButton - Spacer(minLength: 0) - decisionButtons - } - } - } - - private var reviewButton: some View { - Button { - onOpenRequest() - } label: { - Label("Review proof", systemImage: "arrow.up.forward.app") - } - .buttonStyle(.bordered) - } - - @ViewBuilder - private var decisionButtons: some View { - if request.status == .pending, let onApprove, let onReject { - Button { - onApprove() - } label: { - if isBusy { - ProgressView() - } else { - Label("Verify", systemImage: "checkmark.circle.fill") - } - } - .buttonStyle(.borderedProminent) - .disabled(isBusy) - - Button(role: .destructive) { - onReject() - } label: { - Label("Decline", systemImage: "xmark.circle.fill") - } - .buttonStyle(.bordered) - .disabled(isBusy) - } - } - - private var statusTone: Color { - switch request.status { - case .pending: - .orange - case .approved: - .green - case .rejected: - .red - } - } - - private var requestAccent: Color { - switch request.status { - case .approved: - .green - case .rejected: - .red - case .pending: - request.risk == .routine ? dashboardAccent : .orange - } - } -} - -private struct NotificationList: View { - let notifications: [AppNotification] - let compactLayout: Bool - let onMarkRead: (AppNotification) -> Void - - var body: some View { - VStack(spacing: 14) { - ForEach(notifications) { notification in - NotificationCard( - notification: notification, - compactLayout: compactLayout, - onMarkRead: { onMarkRead(notification) } - ) - } - } - } -} - -private struct NotificationCard: View { - let notification: AppNotification - let compactLayout: Bool - let onMarkRead: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: notification.kind.systemImage) - .font(.headline) - .foregroundStyle(accentColor) - .frame(width: 28, height: 28) - - VStack(alignment: .leading, spacing: 4) { - Text(notification.title) - .font(.headline) - - HStack(spacing: 8) { - AppStatusTag(title: notification.kind.title, tone: accentColor) - if notification.isUnread { - AppStatusTag(title: "Unread", tone: .orange) - } - } - } - - Spacer(minLength: 0) - } - - Text(notification.message) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - if compactLayout { - VStack(alignment: .leading, spacing: 10) { - timestamp - if notification.isUnread { - markReadButton - } - } - } else { - HStack { - timestamp - Spacer(minLength: 0) - if notification.isUnread { - markReadButton - } - } - } - } - .padding(compactLayout ? 18 : 20) - .appSurface(radius: 24) - } - - private var timestamp: some View { - Text(notification.sentAt.formatted(date: .abbreviated, time: .shortened)) - .font(.footnote) - .foregroundStyle(.secondary) - } - - private var markReadButton: some View { - Button { - onMarkRead() - } label: { - Label("Mark read", systemImage: "checkmark") - } - .buttonStyle(.bordered) - } - - private var accentColor: Color { - switch notification.kind { - case .approval: - .green - case .security: - .orange - case .system: - .blue - } - } -} - -private struct NotificationBellButton: View { - @ObservedObject var model: AppViewModel - - var body: some View { - Button { - model.isNotificationCenterPresented = true - } label: { - Image(systemName: imageName) - .font(.headline) - .foregroundStyle(iconTone) - .frame(width: 28, height: 28, alignment: .center) - .background(alignment: .center) { - #if os(iOS) - GeometryReader { proxy in - Color.clear - .preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global)) - } - #endif - } - } - .accessibilityLabel("Notifications") - } - - private var imageName: String { - #if os(iOS) - model.unreadNotificationCount == 0 ? "bell" : "bell.fill" - #else - model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill" - #endif - } - - private var iconTone: some ShapeStyle { - model.unreadNotificationCount == 0 ? Color.primary : dashboardAccent - } -} - -private struct NotificationCenterSheet: View { - @ObservedObject var model: AppViewModel - @Environment(\.dismiss) private var dismiss - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - var body: some View { - NavigationStack { - AppScrollScreen( - compactLayout: compactLayout, - bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding - ) { - NotificationsPanel(model: model, compactLayout: compactLayout) - } - .navigationTitle("Notifications") - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - } - } - #if os(iOS) - .presentationDetents(compactLayout ? [.large] : [.medium, .large]) - #endif - } - - private var compactLayout: Bool { - #if os(iOS) - horizontalSizeClass == .compact - #else - false - #endif - } -} - -private struct RequestDetailSheet: View { - let request: ApprovalRequest - @ObservedObject var model: AppViewModel - - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - AppScrollScreen( - compactLayout: true, - bottomPadding: AppLayout.compactBottomDockPadding - ) { - RequestDetailHero(request: request) - - AppSectionCard(title: "Summary", compactLayout: true) { - AppKeyValue(label: "Source", value: request.source) - AppKeyValue(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened)) - AppKeyValue(label: "Risk", value: request.risk.summary) - AppKeyValue(label: "Type", value: request.kind.title) - } - - AppSectionCard(title: "Proof details", compactLayout: true) { - if request.scopes.isEmpty { - Text("No explicit proof details were provided by the mock backend.") - .foregroundStyle(.secondary) - } else { - Text(request.scopes.joined(separator: "\n")) - .font(.body.monospaced()) - .foregroundStyle(.secondary) - } - } - - AppSectionCard(title: "Guidance", compactLayout: true) { - Text(request.trustDetail) - .foregroundStyle(.secondary) - - Text(request.risk.guidance) - .font(.headline) - } - - if request.status == .pending { - AppSectionCard(title: "Actions", compactLayout: true) { - VStack(spacing: 12) { - Button { - Task { - await model.approve(request) - dismiss() - } - } label: { - if model.activeRequestID == request.id { - ProgressView() - } else { - Label("Verify identity", systemImage: "checkmark.circle.fill") - .frame(maxWidth: .infinity) - } - } - .buttonStyle(.borderedProminent) - .disabled(model.activeRequestID == request.id) - - Button(role: .destructive) { - Task { - await model.reject(request) - dismiss() - } - } label: { - Label("Decline", systemImage: "xmark.circle.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .disabled(model.activeRequestID == request.id) - } - } - } - } - .navigationTitle("Review Proof") - .inlineNavigationTitleOnIOS() - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close") { - dismiss() - } - } - } - } - } -} - -private struct RequestDetailHero: View { - let request: ApprovalRequest - - private var accent: Color { - switch request.status { - case .approved: - .green - case .rejected: - .red - case .pending: - request.risk == .routine ? dashboardAccent : .orange - } - } - - var body: some View { - AppPanel(compactLayout: true, radius: AppLayout.largeCardRadius) { - AppBadge(title: request.kind.title, tone: accent) - - Text(request.title) - .font(.system(size: 30, weight: .bold, design: .rounded)) - .lineLimit(3) - - Text(request.subtitle) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - AppStatusTag(title: request.status.title, tone: accent) - AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange) - } - } - } -} - -private struct OneTimePasscodeSheet: View { - let session: AuthSession - - @Environment(\.dismiss) private var dismiss - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - var body: some View { - NavigationStack { - TimelineView(.periodic(from: .now, by: 1)) { context in - let code = passcode(at: context.date) - let secondsRemaining = renewalCountdown(at: context.date) - - AppScrollScreen(compactLayout: compactLayout) { - AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) { - AppBadge(title: "One-time passcode", tone: dashboardGold) - - Text("OTP") - .font(.system(size: compactLayout ? 32 : 40, weight: .bold, design: .rounded)) - - Text("Share this code only with the site or device asking you to prove that it is really you.") - .font(.subheadline) - .foregroundStyle(.secondary) - - Text(code) - .font(.system(size: compactLayout ? 42 : 54, weight: .bold, design: .rounded).monospacedDigit()) - .tracking(compactLayout ? 4 : 6) - .frame(maxWidth: .infinity) - .padding(.vertical, compactLayout ? 16 : 20) - .background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(AppTheme.border, lineWidth: 1) - ) - - HStack(spacing: 8) { - AppStatusTag(title: "Renews in \(secondsRemaining)s", tone: dashboardGold) - AppStatusTag(title: session.originHost, tone: dashboardAccent) - } - - Divider() - - AppKeyValue(label: "Client", value: session.deviceName) - AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened)) - } - } - } - .navigationTitle("OTP") - .inlineNavigationTitleOnIOS() - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close") { - dismiss() - } - } - } - } - } - - private var compactLayout: Bool { - #if os(iOS) - horizontalSizeClass == .compact - #else - false - #endif - } - - private func passcode(at date: Date) -> String { - let timeSlot = Int(date.timeIntervalSince1970 / 30) - let digest = SHA256.hash(data: Data("\(session.pairingCode)|\(timeSlot)".utf8)) - let value = digest.prefix(4).reduce(UInt32(0)) { partialResult, byte in - (partialResult << 8) | UInt32(byte) - } - - return String(format: "%06d", locale: Locale(identifier: "en_US_POSIX"), Int(value % 1_000_000)) - } - - private func renewalCountdown(at date: Date) -> Int { - let elapsed = Int(date.timeIntervalSince1970) % 30 - return elapsed == 0 ? 30 : 30 - elapsed - } -} - -private struct EmptyStateCopy: View { - let title: String - let systemImage: String - let message: String - - var body: some View { - ContentUnavailableView( - title, - systemImage: systemImage, - description: Text(message) - ) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - } -} diff --git a/Sources/Features/Home/HomeSheets.swift b/Sources/Features/Home/HomeSheets.swift new file mode 100644 index 0000000..6f9efbf --- /dev/null +++ b/Sources/Features/Home/HomeSheets.swift @@ -0,0 +1,188 @@ +import SwiftUI + +struct RequestDetailSheet: View { + let request: ApprovalRequest + @ObservedObject var model: AppViewModel + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + AppScrollScreen( + compactLayout: true, + bottomPadding: AppLayout.compactBottomDockPadding + ) { + RequestDetailHero(request: request) + + AppSectionCard(title: "Summary", compactLayout: true) { + AppKeyValue(label: "Source", value: request.source) + AppKeyValue(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened)) + AppKeyValue(label: "Risk", value: request.risk.summary) + AppKeyValue(label: "Type", value: request.kind.title) + } + + AppSectionCard(title: "Proof details", compactLayout: true) { + if request.scopes.isEmpty { + Text("No explicit proof details were provided by the mock backend.") + .foregroundStyle(.secondary) + } else { + Text(request.scopes.joined(separator: "\n")) + .font(.body.monospaced()) + .foregroundStyle(.secondary) + } + } + + AppSectionCard(title: "Guidance", compactLayout: true) { + Text(request.trustDetail) + .foregroundStyle(.secondary) + + Text(request.risk.guidance) + .font(.headline) + } + + if request.status == .pending { + AppSectionCard(title: "Actions", compactLayout: true) { + VStack(spacing: 12) { + Button { + Task { + await model.approve(request) + dismiss() + } + } label: { + if model.activeRequestID == request.id { + ProgressView() + } else { + Label("Verify identity", systemImage: "checkmark.circle.fill") + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .disabled(model.activeRequestID == request.id) + + Button(role: .destructive) { + Task { + await model.reject(request) + dismiss() + } + } label: { + Label("Decline", systemImage: "xmark.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(model.activeRequestID == request.id) + } + } + } + } + .navigationTitle("Review Proof") + .inlineNavigationTitleOnIOS() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismiss() + } + } + } + } + } +} + +private struct RequestDetailHero: View { + let request: ApprovalRequest + + private var accent: Color { + switch request.status { + case .approved: + .green + case .rejected: + .red + case .pending: + request.risk == .routine ? dashboardAccent : .orange + } + } + + var body: some View { + AppPanel(compactLayout: true, radius: AppLayout.largeCardRadius) { + AppBadge(title: request.kind.title, tone: accent) + + Text(request.title) + .font(.system(size: 30, weight: .bold, design: .rounded)) + .lineLimit(3) + + Text(request.subtitle) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + AppStatusTag(title: request.status.title, tone: accent) + AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange) + } + } + } +} + +struct OneTimePasscodeSheet: View { + let session: AuthSession + + @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + var body: some View { + NavigationStack { + TimelineView(.periodic(from: .now, by: 1)) { context in + let code = OneTimePasscodeGenerator.code(for: session.pairingCode, at: context.date) + let secondsRemaining = OneTimePasscodeGenerator.renewalCountdown(at: context.date) + + AppScrollScreen(compactLayout: compactLayout) { + AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) { + AppBadge(title: "One-time passcode", tone: dashboardGold) + + Text("OTP") + .font(.system(size: compactLayout ? 32 : 40, weight: .bold, design: .rounded)) + + Text("Share this code only with the site or device asking you to prove that it is really you.") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(code) + .font(.system(size: compactLayout ? 42 : 54, weight: .bold, design: .rounded).monospacedDigit()) + .tracking(compactLayout ? 4 : 6) + .frame(maxWidth: .infinity) + .padding(.vertical, compactLayout ? 16 : 20) + .background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(AppTheme.border, lineWidth: 1) + ) + + HStack(spacing: 8) { + AppStatusTag(title: "Renews in \(secondsRemaining)s", tone: dashboardGold) + AppStatusTag(title: session.originHost, tone: dashboardAccent) + } + + Divider() + + AppKeyValue(label: "Client", value: session.deviceName) + AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened)) + } + } + } + .navigationTitle("OTP") + .inlineNavigationTitleOnIOS() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismiss() + } + } + } + } + } + + private var compactLayout: Bool { + #if os(iOS) + horizontalSizeClass == .compact + #else + false + #endif + } +} diff --git a/Tests/AppViewModelTests.swift b/Tests/AppViewModelTests.swift new file mode 100644 index 0000000..1305797 --- /dev/null +++ b/Tests/AppViewModelTests.swift @@ -0,0 +1,249 @@ +import XCTest +@testable import IDPGlobal + +@MainActor +final class AppViewModelTests: XCTestCase { + func testBootstrapRestoresPersistedState() async { + let session = makeSession() + let profile = makeProfile() + let snapshot = makeSnapshot(profile: profile) + let store = InMemoryAppStateStore( + state: PersistedAppState( + session: session, + profile: profile, + requests: snapshot.requests, + notifications: snapshot.notifications + ) + ) + let service = StubService( + bootstrapContext: BootstrapContext(suggestedPairingPayload: "idp.global://pair?token=fresh-token&origin=code.foss.global&device=Fresh%20Browser"), + signInResult: SignInResult(session: session, snapshot: snapshot), + dashboardSnapshot: snapshot + ) + let coordinator = StubNotificationCoordinator(status: .allowed) + let model = AppViewModel( + service: service, + notificationCoordinator: coordinator, + appStateStore: store, + launchArguments: [] + ) + + await model.bootstrap() + + XCTAssertEqual(model.session, session) + XCTAssertEqual(model.profile, profile) + XCTAssertEqual(model.requests.map(\.id), snapshot.requests.sorted { $0.createdAt > $1.createdAt }.map(\.id)) + XCTAssertEqual(model.notifications.map(\.id), snapshot.notifications.sorted { $0.sentAt > $1.sentAt }.map(\.id)) + XCTAssertEqual(model.manualPairingPayload, session.pairingCode) + XCTAssertEqual(model.suggestedPairingPayload, "idp.global://pair?token=fresh-token&origin=code.foss.global&device=Fresh%20Browser") + XCTAssertEqual(model.notificationPermission, .allowed) + } + + func testSignInPersistsAuthenticatedState() async { + let session = makeSession() + let profile = makeProfile() + let snapshot = makeSnapshot(profile: profile) + let store = InMemoryAppStateStore() + let service = StubService( + bootstrapContext: BootstrapContext(suggestedPairingPayload: session.pairingCode), + signInResult: SignInResult(session: session, snapshot: snapshot), + dashboardSnapshot: snapshot + ) + let model = AppViewModel( + service: service, + notificationCoordinator: StubNotificationCoordinator(status: .allowed), + appStateStore: store, + launchArguments: [] + ) + + await model.signIn(with: session.pairingCode, transport: .preview) + + XCTAssertEqual(model.session, session) + XCTAssertEqual(store.storedState?.session, session) + XCTAssertEqual(store.storedState?.profile, profile) + XCTAssertEqual(store.storedState?.requests.map(\.id), snapshot.requests.sorted { $0.createdAt > $1.createdAt }.map(\.id)) + XCTAssertEqual(store.storedState?.notifications.map(\.id), snapshot.notifications.sorted { $0.sentAt > $1.sentAt }.map(\.id)) + } + + func testSignOutClearsPersistedState() async { + let session = makeSession() + let profile = makeProfile() + let snapshot = makeSnapshot(profile: profile) + let store = InMemoryAppStateStore( + state: PersistedAppState( + session: session, + profile: profile, + requests: snapshot.requests, + notifications: snapshot.notifications + ) + ) + let model = AppViewModel( + service: StubService( + bootstrapContext: BootstrapContext(suggestedPairingPayload: session.pairingCode), + signInResult: SignInResult(session: session, snapshot: snapshot), + dashboardSnapshot: snapshot + ), + notificationCoordinator: StubNotificationCoordinator(status: .allowed), + appStateStore: store, + launchArguments: [] + ) + + await model.bootstrap() + model.signOut() + + XCTAssertNil(model.session) + XCTAssertNil(model.profile) + XCTAssertTrue(store.didClear) + XCTAssertNil(store.storedState) + } + + private func makeSession() -> AuthSession { + AuthSession( + deviceName: "Safari on Berlin MBP", + originHost: "code.foss.global", + pairedAt: Date(timeIntervalSince1970: 1_700_000_000), + tokenPreview: "berlin", + pairingCode: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP", + pairingTransport: .preview + ) + } + + private func makeProfile() -> MemberProfile { + 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 func makeSnapshot(profile: MemberProfile) -> DashboardSnapshot { + DashboardSnapshot( + profile: profile, + requests: [ + ApprovalRequest( + title: "Later request", + subtitle: "Newer", + source: "later.idp.global", + createdAt: Date(timeIntervalSince1970: 200), + kind: .signIn, + risk: .routine, + scopes: ["proof:basic"], + status: .pending + ), + ApprovalRequest( + title: "Earlier request", + subtitle: "Older", + source: "earlier.idp.global", + createdAt: Date(timeIntervalSince1970: 100), + kind: .elevatedAction, + risk: .elevated, + scopes: ["proof:high"], + status: .approved + ) + ], + notifications: [ + AppNotification( + title: "Older notification", + message: "Oldest", + sentAt: Date(timeIntervalSince1970: 100), + kind: .system, + isUnread: false + ), + AppNotification( + title: "Newer notification", + message: "Newest", + sentAt: Date(timeIntervalSince1970: 200), + kind: .security, + isUnread: true + ) + ] + ) + } +} + +private final class InMemoryAppStateStore: AppStateStoring { + var storedState: PersistedAppState? + var didClear = false + + init(state: PersistedAppState? = nil) { + storedState = state + } + + func load() -> PersistedAppState? { + storedState + } + + func save(_ state: PersistedAppState) { + storedState = state + didClear = false + } + + func clear() { + storedState = nil + didClear = true + } +} + +private actor StubService: IDPServicing { + private let bootstrapContext: BootstrapContext + private let signInResult: SignInResult + private let dashboardSnapshot: DashboardSnapshot + + init(bootstrapContext: BootstrapContext, signInResult: SignInResult, dashboardSnapshot: DashboardSnapshot) { + self.bootstrapContext = bootstrapContext + self.signInResult = signInResult + self.dashboardSnapshot = dashboardSnapshot + } + + func bootstrap() async throws -> BootstrapContext { + bootstrapContext + } + + func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult { + signInResult + } + + func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot { + dashboardSnapshot + } + + func refreshDashboard() async throws -> DashboardSnapshot { + dashboardSnapshot + } + + func approveRequest(id: UUID) async throws -> DashboardSnapshot { + dashboardSnapshot + } + + func rejectRequest(id: UUID) async throws -> DashboardSnapshot { + dashboardSnapshot + } + + func simulateIncomingRequest() async throws -> DashboardSnapshot { + dashboardSnapshot + } + + func markNotificationRead(id: UUID) async throws -> DashboardSnapshot { + dashboardSnapshot + } +} + +private final class StubNotificationCoordinator: NotificationCoordinating { + private let status: NotificationPermissionState + + init(status: NotificationPermissionState) { + self.status = status + } + + func authorizationStatus() async -> NotificationPermissionState { + status + } + + func requestAuthorization() async throws -> NotificationPermissionState { + status + } + + func scheduleTestNotification(title: String, body: String) async throws {} +} diff --git a/Tests/OneTimePasscodeGeneratorTests.swift b/Tests/OneTimePasscodeGeneratorTests.swift new file mode 100644 index 0000000..de352ce --- /dev/null +++ b/Tests/OneTimePasscodeGeneratorTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import IDPGlobal + +final class OneTimePasscodeGeneratorTests: XCTestCase { + func testCodeStaysStableWithinWindow() { + let firstDate = Date(timeIntervalSince1970: 60) + let secondDate = Date(timeIntervalSince1970: 89) + let nextWindowDate = Date(timeIntervalSince1970: 90) + + let firstCode = OneTimePasscodeGenerator.code(for: "pairing-payload", at: firstDate) + let secondCode = OneTimePasscodeGenerator.code(for: "pairing-payload", at: secondDate) + let nextWindowCode = OneTimePasscodeGenerator.code(for: "pairing-payload", at: nextWindowDate) + + XCTAssertEqual(firstCode, secondCode) + XCTAssertNotEqual(firstCode, nextWindowCode) + } + + func testRenewalCountdownResetsAtBoundary() { + XCTAssertEqual(OneTimePasscodeGenerator.renewalCountdown(at: Date(timeIntervalSince1970: 90)), 30) + XCTAssertEqual(OneTimePasscodeGenerator.renewalCountdown(at: Date(timeIntervalSince1970: 119)), 1) + XCTAssertEqual(OneTimePasscodeGenerator.renewalCountdown(at: Date(timeIntervalSince1970: 120)), 30) + } +} diff --git a/Tests/PairingPayloadParserTests.swift b/Tests/PairingPayloadParserTests.swift new file mode 100644 index 0000000..7b7ace4 --- /dev/null +++ b/Tests/PairingPayloadParserTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import IDPGlobal + +final class PairingPayloadParserTests: XCTestCase { + func testParsesStructuredPairingPayload() throws { + let payload = "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP" + + let context = try PairingPayloadParser.parse(payload) + + XCTAssertEqual(context.deviceName, "Safari on Berlin MBP") + XCTAssertEqual(context.originHost, "code.foss.global") + XCTAssertEqual(context.tokenPreview, "berlin") + } + + func testParsesManualFallbackPayload() throws { + let context = try PairingPayloadParser.parse("manual pair token 1234567890") + + XCTAssertEqual(context.deviceName, "Manual Session") + XCTAssertEqual(context.originHost, "code.foss.global") + XCTAssertEqual(context.tokenPreview, "567890") + } + + func testRejectsInvalidPayload() { + XCTAssertThrowsError(try PairingPayloadParser.parse("https://example.com")) { error in + XCTAssertEqual(error as? AppError, .invalidPairingPayload) + } + } +}