Refine app structure for persisted sessions and test coverage
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
This commit is contained in:
21
.github/workflows/ci.yml
vendored
Normal file
21
.github/workflows/ci.yml
vendored
Normal file
@@ -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
|
||||
@@ -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 = "<group>"; };
|
||||
B2000000000000000000000E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
B2000000000000000000000F /* AppComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppComponents.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000010 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000011 /* AppStateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateStore.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000012 /* OneTimePasscodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneTimePasscodeGenerator.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000013 /* PairingPayloadParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingPayloadParser.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000014 /* HomePanels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePanels.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000015 /* HomeCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCards.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000016 /* HomeSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSheets.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000017 /* PairingPayloadParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingPayloadParserTests.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneTimePasscodeGeneratorTests.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000019 /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>";
|
||||
@@ -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 = "<group>";
|
||||
@@ -166,6 +212,7 @@
|
||||
children = (
|
||||
B20000000000000000000009 /* IDPGlobal.app */,
|
||||
B2000000000000000000000A /* IDPGlobalWatch.app */,
|
||||
B2000000000000000000001A /* IDPGlobalTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -183,7 +230,10 @@
|
||||
B4000000000000000000000B /* Home */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B20000000000000000000015 /* HomeCards.swift */,
|
||||
B20000000000000000000014 /* HomePanels.swift */,
|
||||
B20000000000000000000008 /* HomeRootView.swift */,
|
||||
B20000000000000000000016 /* HomeSheets.swift */,
|
||||
);
|
||||
path = Home;
|
||||
sourceTree = "<group>";
|
||||
@@ -213,6 +263,16 @@
|
||||
path = Features;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4000000000000000000000F /* Tests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B20000000000000000000019 /* AppViewModelTests.swift */,
|
||||
B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */,
|
||||
B20000000000000000000017 /* PairingPayloadParserTests.swift */,
|
||||
);
|
||||
path = Tests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B50000000000000000000001"
|
||||
BuildableName = "IDPGlobal.app"
|
||||
BlueprintName = "IDPGlobal"
|
||||
ReferencedContainer = "container:IDPGlobal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B50000000000000000000001"
|
||||
BuildableName = "IDPGlobal.app"
|
||||
BlueprintName = "IDPGlobal"
|
||||
ReferencedContainer = "container:IDPGlobal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B50000000000000000000003"
|
||||
BuildableName = "IDPGlobalTests.xctest"
|
||||
BlueprintName = "IDPGlobalTests"
|
||||
ReferencedContainer = "container:IDPGlobal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B50000000000000000000001"
|
||||
BuildableName = "IDPGlobal.app"
|
||||
BlueprintName = "IDPGlobal"
|
||||
ReferencedContainer = "container:IDPGlobal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B50000000000000000000001"
|
||||
BuildableName = "IDPGlobal.app"
|
||||
BlueprintName = "IDPGlobal"
|
||||
ReferencedContainer = "container:IDPGlobal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -1,261 +1,4 @@
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
private extension Color {
|
||||
static func adaptive(
|
||||
light: (red: Double, green: Double, blue: Double, opacity: Double),
|
||||
dark: (red: Double, green: Double, blue: Double, opacity: Double)
|
||||
) -> Color {
|
||||
#if os(macOS)
|
||||
Color(
|
||||
nsColor: NSColor(name: nil) { appearance in
|
||||
let matchedAppearance = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight])
|
||||
let components = matchedAppearance == .darkAqua || matchedAppearance == .vibrantDark ? dark : light
|
||||
return NSColor(
|
||||
red: components.red,
|
||||
green: components.green,
|
||||
blue: components.blue,
|
||||
alpha: components.opacity
|
||||
)
|
||||
}
|
||||
)
|
||||
#elseif canImport(UIKit) && !os(watchOS)
|
||||
Color(
|
||||
uiColor: UIColor { traits in
|
||||
let components = traits.userInterfaceStyle == .dark ? dark : light
|
||||
return UIColor(
|
||||
red: components.red,
|
||||
green: components.green,
|
||||
blue: components.blue,
|
||||
alpha: components.opacity
|
||||
)
|
||||
}
|
||||
)
|
||||
#elseif os(watchOS)
|
||||
Color(
|
||||
red: dark.red,
|
||||
green: dark.green,
|
||||
blue: dark.blue,
|
||||
opacity: dark.opacity
|
||||
)
|
||||
#else
|
||||
Color(
|
||||
red: light.red,
|
||||
green: light.green,
|
||||
blue: light.blue,
|
||||
opacity: light.opacity
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
enum AppTheme {
|
||||
static let accent = Color(red: 0.12, green: 0.40, blue: 0.31)
|
||||
static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48)
|
||||
static let border = Color.adaptive(
|
||||
light: (0.00, 0.00, 0.00, 0.08),
|
||||
dark: (1.00, 1.00, 1.00, 0.12)
|
||||
)
|
||||
static let shadow = Color.adaptive(
|
||||
light: (0.00, 0.00, 0.00, 0.05),
|
||||
dark: (0.00, 0.00, 0.00, 0.32)
|
||||
)
|
||||
static let cardFill = Color.adaptive(
|
||||
light: (1.00, 1.00, 1.00, 0.96),
|
||||
dark: (0.11, 0.12, 0.14, 0.96)
|
||||
)
|
||||
static let mutedFill = Color.adaptive(
|
||||
light: (0.972, 0.976, 0.970, 1.00),
|
||||
dark: (0.16, 0.17, 0.19, 1.00)
|
||||
)
|
||||
static let backgroundTop = Color.adaptive(
|
||||
light: (0.975, 0.978, 0.972, 1.00),
|
||||
dark: (0.08, 0.09, 0.10, 1.00)
|
||||
)
|
||||
static let backgroundBottom = Color.adaptive(
|
||||
light: (1.00, 1.00, 1.00, 1.00),
|
||||
dark: (0.05, 0.06, 0.07, 1.00)
|
||||
)
|
||||
static let backgroundGlow = Color.adaptive(
|
||||
light: (0.00, 0.00, 0.00, 0.02),
|
||||
dark: (1.00, 1.00, 1.00, 0.06)
|
||||
)
|
||||
static let chromeFill = Color.adaptive(
|
||||
light: (1.00, 1.00, 1.00, 0.98),
|
||||
dark: (0.10, 0.11, 0.13, 0.98)
|
||||
)
|
||||
}
|
||||
|
||||
enum AppLayout {
|
||||
static let compactHorizontalPadding: CGFloat = 16
|
||||
static let regularHorizontalPadding: CGFloat = 28
|
||||
static let compactVerticalPadding: CGFloat = 18
|
||||
static let regularVerticalPadding: CGFloat = 28
|
||||
static let compactContentWidth: CGFloat = 720
|
||||
static let regularContentWidth: CGFloat = 920
|
||||
static let cardRadius: CGFloat = 24
|
||||
static let largeCardRadius: CGFloat = 30
|
||||
static let compactSectionPadding: CGFloat = 18
|
||||
static let regularSectionPadding: CGFloat = 24
|
||||
static let compactSectionSpacing: CGFloat = 18
|
||||
static let regularSectionSpacing: CGFloat = 24
|
||||
static let compactBottomDockPadding: CGFloat = 120
|
||||
static let regularBottomPadding: CGFloat = 56
|
||||
|
||||
static func horizontalPadding(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactHorizontalPadding : regularHorizontalPadding
|
||||
}
|
||||
|
||||
static func verticalPadding(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactVerticalPadding : regularVerticalPadding
|
||||
}
|
||||
|
||||
static func contentWidth(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactContentWidth : regularContentWidth
|
||||
}
|
||||
|
||||
static func sectionPadding(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactSectionPadding : regularSectionPadding
|
||||
}
|
||||
|
||||
static func sectionSpacing(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactSectionSpacing : regularSectionSpacing
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View {
|
||||
background(
|
||||
fill,
|
||||
in: RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
.shadow(color: AppTheme.shadow, radius: 12, y: 3)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppBackground: View {
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
AppTheme.backgroundTop,
|
||||
AppTheme.backgroundBottom
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(AppTheme.backgroundGlow)
|
||||
.frame(height: 160)
|
||||
.blur(radius: 60)
|
||||
.offset(y: -90)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
struct AppScrollScreen<Content: View>: View {
|
||||
let compactLayout: Bool
|
||||
var bottomPadding: CGFloat? = nil
|
||||
let content: () -> Content
|
||||
|
||||
init(
|
||||
compactLayout: Bool,
|
||||
bottomPadding: CGFloat? = nil,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.compactLayout = compactLayout
|
||||
self.bottomPadding = bottomPadding
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
|
||||
.padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
|
||||
.padding(.top, AppLayout.verticalPadding(for: compactLayout))
|
||||
.padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
|
||||
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppPanel<Content: View>: View {
|
||||
let compactLayout: Bool
|
||||
let radius: CGFloat
|
||||
let content: () -> Content
|
||||
|
||||
init(
|
||||
compactLayout: Bool,
|
||||
radius: CGFloat = AppLayout.cardRadius,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.compactLayout = compactLayout
|
||||
self.radius = radius
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
content()
|
||||
}
|
||||
.padding(AppLayout.sectionPadding(for: compactLayout))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.appSurface(radius: radius)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppBadge: View {
|
||||
let title: String
|
||||
var tone: Color = AppTheme.accent
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(tone)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(tone.opacity(0.10), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
struct AppSectionCard<Content: View>: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
let compactLayout: Bool
|
||||
let content: () -> Content
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
compactLayout: Bool,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.compactLayout = compactLayout
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout) {
|
||||
AppSectionTitle(title: title, subtitle: subtitle)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppSectionTitle: View {
|
||||
let title: String
|
||||
|
||||
258
Sources/App/AppTheme.swift
Normal file
258
Sources/App/AppTheme.swift
Normal file
@@ -0,0 +1,258 @@
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
private extension Color {
|
||||
static func adaptive(
|
||||
light: (red: Double, green: Double, blue: Double, opacity: Double),
|
||||
dark: (red: Double, green: Double, blue: Double, opacity: Double)
|
||||
) -> Color {
|
||||
#if os(macOS)
|
||||
Color(
|
||||
nsColor: NSColor(name: nil) { appearance in
|
||||
let matchedAppearance = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight])
|
||||
let components = matchedAppearance == .darkAqua || matchedAppearance == .vibrantDark ? dark : light
|
||||
return NSColor(
|
||||
red: components.red,
|
||||
green: components.green,
|
||||
blue: components.blue,
|
||||
alpha: components.opacity
|
||||
)
|
||||
}
|
||||
)
|
||||
#elseif canImport(UIKit) && !os(watchOS)
|
||||
Color(
|
||||
uiColor: UIColor { traits in
|
||||
let components = traits.userInterfaceStyle == .dark ? dark : light
|
||||
return UIColor(
|
||||
red: components.red,
|
||||
green: components.green,
|
||||
blue: components.blue,
|
||||
alpha: components.opacity
|
||||
)
|
||||
}
|
||||
)
|
||||
#elseif os(watchOS)
|
||||
Color(
|
||||
red: dark.red,
|
||||
green: dark.green,
|
||||
blue: dark.blue,
|
||||
opacity: dark.opacity
|
||||
)
|
||||
#else
|
||||
Color(
|
||||
red: light.red,
|
||||
green: light.green,
|
||||
blue: light.blue,
|
||||
opacity: light.opacity
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
enum AppTheme {
|
||||
static let accent = Color(red: 0.12, green: 0.40, blue: 0.31)
|
||||
static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48)
|
||||
static let border = Color.adaptive(
|
||||
light: (0.00, 0.00, 0.00, 0.08),
|
||||
dark: (1.00, 1.00, 1.00, 0.12)
|
||||
)
|
||||
static let shadow = Color.adaptive(
|
||||
light: (0.00, 0.00, 0.00, 0.05),
|
||||
dark: (0.00, 0.00, 0.00, 0.32)
|
||||
)
|
||||
static let cardFill = Color.adaptive(
|
||||
light: (1.00, 1.00, 1.00, 0.96),
|
||||
dark: (0.11, 0.12, 0.14, 0.96)
|
||||
)
|
||||
static let mutedFill = Color.adaptive(
|
||||
light: (0.972, 0.976, 0.970, 1.00),
|
||||
dark: (0.16, 0.17, 0.19, 1.00)
|
||||
)
|
||||
static let backgroundTop = Color.adaptive(
|
||||
light: (0.975, 0.978, 0.972, 1.00),
|
||||
dark: (0.08, 0.09, 0.10, 1.00)
|
||||
)
|
||||
static let backgroundBottom = Color.adaptive(
|
||||
light: (1.00, 1.00, 1.00, 1.00),
|
||||
dark: (0.05, 0.06, 0.07, 1.00)
|
||||
)
|
||||
static let backgroundGlow = Color.adaptive(
|
||||
light: (0.00, 0.00, 0.00, 0.02),
|
||||
dark: (1.00, 1.00, 1.00, 0.06)
|
||||
)
|
||||
static let chromeFill = Color.adaptive(
|
||||
light: (1.00, 1.00, 1.00, 0.98),
|
||||
dark: (0.10, 0.11, 0.13, 0.98)
|
||||
)
|
||||
}
|
||||
|
||||
enum AppLayout {
|
||||
static let compactHorizontalPadding: CGFloat = 16
|
||||
static let regularHorizontalPadding: CGFloat = 28
|
||||
static let compactVerticalPadding: CGFloat = 18
|
||||
static let regularVerticalPadding: CGFloat = 28
|
||||
static let compactContentWidth: CGFloat = 720
|
||||
static let regularContentWidth: CGFloat = 920
|
||||
static let cardRadius: CGFloat = 24
|
||||
static let largeCardRadius: CGFloat = 30
|
||||
static let compactSectionPadding: CGFloat = 18
|
||||
static let regularSectionPadding: CGFloat = 24
|
||||
static let compactSectionSpacing: CGFloat = 18
|
||||
static let regularSectionSpacing: CGFloat = 24
|
||||
static let compactBottomDockPadding: CGFloat = 120
|
||||
static let regularBottomPadding: CGFloat = 56
|
||||
|
||||
static func horizontalPadding(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactHorizontalPadding : regularHorizontalPadding
|
||||
}
|
||||
|
||||
static func verticalPadding(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactVerticalPadding : regularVerticalPadding
|
||||
}
|
||||
|
||||
static func contentWidth(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactContentWidth : regularContentWidth
|
||||
}
|
||||
|
||||
static func sectionPadding(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactSectionPadding : regularSectionPadding
|
||||
}
|
||||
|
||||
static func sectionSpacing(for compactLayout: Bool) -> CGFloat {
|
||||
compactLayout ? compactSectionSpacing : regularSectionSpacing
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func appSurface(radius: CGFloat = AppLayout.cardRadius, fill: Color = AppTheme.cardFill) -> some View {
|
||||
background(
|
||||
fill,
|
||||
in: RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
.shadow(color: AppTheme.shadow, radius: 12, y: 3)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppBackground: View {
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
AppTheme.backgroundTop,
|
||||
AppTheme.backgroundBottom
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(AppTheme.backgroundGlow)
|
||||
.frame(height: 160)
|
||||
.blur(radius: 60)
|
||||
.offset(y: -90)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
struct AppScrollScreen<Content: View>: View {
|
||||
let compactLayout: Bool
|
||||
var bottomPadding: CGFloat? = nil
|
||||
let content: () -> Content
|
||||
|
||||
init(
|
||||
compactLayout: Bool,
|
||||
bottomPadding: CGFloat? = nil,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.compactLayout = compactLayout
|
||||
self.bottomPadding = bottomPadding
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
|
||||
.padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
|
||||
.padding(.top, AppLayout.verticalPadding(for: compactLayout))
|
||||
.padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
|
||||
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppPanel<Content: View>: View {
|
||||
let compactLayout: Bool
|
||||
let radius: CGFloat
|
||||
let content: () -> Content
|
||||
|
||||
init(
|
||||
compactLayout: Bool,
|
||||
radius: CGFloat = AppLayout.cardRadius,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.compactLayout = compactLayout
|
||||
self.radius = radius
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
content()
|
||||
}
|
||||
.padding(AppLayout.sectionPadding(for: compactLayout))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.appSurface(radius: radius)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppBadge: View {
|
||||
let title: String
|
||||
var tone: Color = AppTheme.accent
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(tone)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(tone.opacity(0.10), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
struct AppSectionCard<Content: View>: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
let compactLayout: Bool
|
||||
let content: () -> Content
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
compactLayout: Bool,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.compactLayout = compactLayout
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout) {
|
||||
AppSectionTitle(title: title, subtitle: subtitle)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
enum AppSection: String, CaseIterable, Identifiable, Hashable {
|
||||
enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable {
|
||||
case overview
|
||||
case requests
|
||||
case activity
|
||||
@@ -28,7 +28,7 @@ enum AppSection: String, CaseIterable, Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum NotificationPermissionState: String, CaseIterable, Identifiable {
|
||||
enum NotificationPermissionState: String, CaseIterable, Identifiable, Codable {
|
||||
case unknown
|
||||
case allowed
|
||||
case provisional
|
||||
@@ -72,7 +72,7 @@ struct BootstrapContext {
|
||||
let suggestedPairingPayload: String
|
||||
}
|
||||
|
||||
enum PairingTransport: String, Hashable {
|
||||
enum PairingTransport: String, Hashable, Codable {
|
||||
case qr
|
||||
case nfc
|
||||
case manual
|
||||
@@ -98,7 +98,7 @@ struct PairingAuthenticationRequest: Hashable {
|
||||
let signedGPSPosition: SignedGPSPosition?
|
||||
}
|
||||
|
||||
struct SignedGPSPosition: Hashable {
|
||||
struct SignedGPSPosition: Hashable, Codable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let horizontalAccuracyMeters: Double
|
||||
@@ -185,7 +185,7 @@ struct SignInResult {
|
||||
let snapshot: DashboardSnapshot
|
||||
}
|
||||
|
||||
struct MemberProfile: Identifiable, Hashable {
|
||||
struct MemberProfile: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let handle: String
|
||||
@@ -210,7 +210,7 @@ struct MemberProfile: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthSession: Identifiable, Hashable {
|
||||
struct AuthSession: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let deviceName: String
|
||||
let originHost: String
|
||||
@@ -241,7 +241,7 @@ struct AuthSession: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalRequestKind: String, CaseIterable, Hashable {
|
||||
enum ApprovalRequestKind: String, CaseIterable, Hashable, Codable {
|
||||
case signIn
|
||||
case accessGrant
|
||||
case elevatedAction
|
||||
@@ -263,7 +263,7 @@ enum ApprovalRequestKind: String, CaseIterable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalRisk: String, Hashable {
|
||||
enum ApprovalRisk: String, Hashable, Codable {
|
||||
case routine
|
||||
case elevated
|
||||
|
||||
@@ -293,7 +293,7 @@ enum ApprovalRisk: String, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalStatus: String, Hashable {
|
||||
enum ApprovalStatus: String, Hashable, Codable {
|
||||
case pending
|
||||
case approved
|
||||
case rejected
|
||||
@@ -315,7 +315,7 @@ enum ApprovalStatus: String, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ApprovalRequest: Identifiable, Hashable {
|
||||
struct ApprovalRequest: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let subtitle: String
|
||||
@@ -382,7 +382,7 @@ struct ApprovalRequest: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum AppNotificationKind: String, Hashable {
|
||||
enum AppNotificationKind: String, Hashable, Codable {
|
||||
case approval
|
||||
case security
|
||||
case system
|
||||
@@ -415,7 +415,7 @@ enum AppNotificationKind: String, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
struct AppNotification: Identifiable, Hashable {
|
||||
struct AppNotification: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let message: String
|
||||
@@ -440,7 +440,7 @@ struct AppNotification: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum AppError: LocalizedError {
|
||||
enum AppError: LocalizedError, Equatable {
|
||||
case invalidPairingPayload
|
||||
case missingSignedGPSPosition
|
||||
case invalidSignedGPSPosition
|
||||
|
||||
46
Sources/Core/Services/AppStateStore.swift
Normal file
46
Sources/Core/Services/AppStateStore.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
struct PersistedAppState: Codable, Equatable {
|
||||
let session: AuthSession
|
||||
let profile: MemberProfile
|
||||
let requests: [ApprovalRequest]
|
||||
let notifications: [AppNotification]
|
||||
}
|
||||
|
||||
protocol AppStateStoring {
|
||||
func load() -> PersistedAppState?
|
||||
func save(_ state: PersistedAppState)
|
||||
func clear()
|
||||
}
|
||||
|
||||
final class UserDefaultsAppStateStore: AppStateStoring {
|
||||
private let defaults: UserDefaults
|
||||
private let storageKey: String
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
init(defaults: UserDefaults = .standard, storageKey: String = "persisted-app-state") {
|
||||
self.defaults = defaults
|
||||
self.storageKey = storageKey
|
||||
}
|
||||
|
||||
func load() -> PersistedAppState? {
|
||||
guard let data = defaults.data(forKey: storageKey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return try? decoder.decode(PersistedAppState.self, from: data)
|
||||
}
|
||||
|
||||
func save(_ state: PersistedAppState) {
|
||||
guard let data = try? encoder.encode(state) else {
|
||||
return
|
||||
}
|
||||
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
defaults.removeObject(forKey: storageKey)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
19
Sources/Core/Services/OneTimePasscodeGenerator.swift
Normal file
19
Sources/Core/Services/OneTimePasscodeGenerator.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
38
Sources/Core/Services/PairingPayloadParser.swift
Normal file
38
Sources/Core/Services/PairingPayloadParser.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
struct PairingPayloadContext: Equatable {
|
||||
let deviceName: String
|
||||
let originHost: String
|
||||
let tokenPreview: String
|
||||
}
|
||||
|
||||
enum PairingPayloadParser {
|
||||
static func parse(_ payload: String) throws -> PairingPayloadContext {
|
||||
let trimmedPayload = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if let components = URLComponents(string: trimmedPayload),
|
||||
components.scheme == "idp.global",
|
||||
components.host == "pair" {
|
||||
let queryItems = components.queryItems ?? []
|
||||
let token = queryItems.first(where: { $0.name == "token" })?.value ?? "demo-token"
|
||||
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
|
||||
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
|
||||
|
||||
return PairingPayloadContext(
|
||||
deviceName: device,
|
||||
originHost: origin,
|
||||
tokenPreview: String(token.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
if trimmedPayload.contains("token") || trimmedPayload.contains("pair") {
|
||||
return PairingPayloadContext(
|
||||
deviceName: "Manual Session",
|
||||
originHost: "code.foss.global",
|
||||
tokenPreview: String(trimmedPayload.suffix(6))
|
||||
)
|
||||
}
|
||||
|
||||
throw AppError.invalidPairingPayload
|
||||
}
|
||||
}
|
||||
330
Sources/Features/Home/HomeCards.swift
Normal file
330
Sources/Features/Home/HomeCards.swift
Normal file
@@ -0,0 +1,330 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RequestList: View {
|
||||
let requests: [ApprovalRequest]
|
||||
let compactLayout: Bool
|
||||
let activeRequestID: ApprovalRequest.ID?
|
||||
let onApprove: ((ApprovalRequest) -> Void)?
|
||||
let onReject: ((ApprovalRequest) -> Void)?
|
||||
let onOpenRequest: (ApprovalRequest) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
ForEach(requests) { request in
|
||||
RequestCard(
|
||||
request: request,
|
||||
compactLayout: compactLayout,
|
||||
isBusy: activeRequestID == request.id,
|
||||
onApprove: onApprove == nil ? nil : { onApprove?(request) },
|
||||
onReject: onReject == nil ? nil : { onReject?(request) },
|
||||
onOpenRequest: { onOpenRequest(request) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RequestCard: View {
|
||||
let request: ApprovalRequest
|
||||
let compactLayout: Bool
|
||||
let isBusy: Bool
|
||||
let onApprove: (() -> Void)?
|
||||
let onReject: (() -> Void)?
|
||||
let onOpenRequest: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: request.kind.systemImage)
|
||||
.font(.headline)
|
||||
.foregroundStyle(requestAccent)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(request.title)
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Text(request.source)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
AppStatusTag(title: request.status.title, tone: statusTone)
|
||||
}
|
||||
|
||||
Text(request.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
|
||||
Text(request.scopeSummary)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
Text(request.createdAt, style: .relative)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !request.scopes.isEmpty {
|
||||
Text("Proof details: \(request.scopes.joined(separator: ", "))")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
controls
|
||||
}
|
||||
.padding(compactLayout ? 18 : 20)
|
||||
.appSurface(radius: 24)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var controls: some View {
|
||||
if compactLayout {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
reviewButton
|
||||
decisionButtons
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
reviewButton
|
||||
Spacer(minLength: 0)
|
||||
decisionButtons
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reviewButton: some View {
|
||||
Button {
|
||||
onOpenRequest()
|
||||
} label: {
|
||||
Label("Review proof", systemImage: "arrow.up.forward.app")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var decisionButtons: some View {
|
||||
if request.status == .pending, let onApprove, let onReject {
|
||||
Button {
|
||||
onApprove()
|
||||
} label: {
|
||||
if isBusy {
|
||||
ProgressView()
|
||||
} else {
|
||||
Label("Verify", systemImage: "checkmark.circle.fill")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isBusy)
|
||||
|
||||
Button(role: .destructive) {
|
||||
onReject()
|
||||
} label: {
|
||||
Label("Decline", systemImage: "xmark.circle.fill")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusTone: Color {
|
||||
switch request.status {
|
||||
case .pending:
|
||||
.orange
|
||||
case .approved:
|
||||
.green
|
||||
case .rejected:
|
||||
.red
|
||||
}
|
||||
}
|
||||
|
||||
private var requestAccent: Color {
|
||||
switch request.status {
|
||||
case .approved:
|
||||
.green
|
||||
case .rejected:
|
||||
.red
|
||||
case .pending:
|
||||
request.risk == .routine ? dashboardAccent : .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationList: View {
|
||||
let notifications: [AppNotification]
|
||||
let compactLayout: Bool
|
||||
let onMarkRead: (AppNotification) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
ForEach(notifications) { notification in
|
||||
NotificationCard(
|
||||
notification: notification,
|
||||
compactLayout: compactLayout,
|
||||
onMarkRead: { onMarkRead(notification) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationCard: View {
|
||||
let notification: AppNotification
|
||||
let compactLayout: Bool
|
||||
let onMarkRead: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: notification.kind.systemImage)
|
||||
.font(.headline)
|
||||
.foregroundStyle(accentColor)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(notification.title)
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: notification.kind.title, tone: accentColor)
|
||||
if notification.isUnread {
|
||||
AppStatusTag(title: "Unread", tone: .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Text(notification.message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if compactLayout {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
timestamp
|
||||
if notification.isUnread {
|
||||
markReadButton
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
timestamp
|
||||
Spacer(minLength: 0)
|
||||
if notification.isUnread {
|
||||
markReadButton
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(compactLayout ? 18 : 20)
|
||||
.appSurface(radius: 24)
|
||||
}
|
||||
|
||||
private var timestamp: some View {
|
||||
Text(notification.sentAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var markReadButton: some View {
|
||||
Button {
|
||||
onMarkRead()
|
||||
} label: {
|
||||
Label("Mark read", systemImage: "checkmark")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
private var accentColor: Color {
|
||||
switch notification.kind {
|
||||
case .approval:
|
||||
.green
|
||||
case .security:
|
||||
.orange
|
||||
case .system:
|
||||
.blue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationBellButton: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
model.isNotificationCenterPresented = true
|
||||
} label: {
|
||||
Image(systemName: imageName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(iconTone)
|
||||
.frame(width: 28, height: 28, alignment: .center)
|
||||
.background(alignment: .center) {
|
||||
#if os(iOS)
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Notifications")
|
||||
}
|
||||
|
||||
private var imageName: String {
|
||||
#if os(iOS)
|
||||
model.unreadNotificationCount == 0 ? "bell" : "bell.fill"
|
||||
#else
|
||||
model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill"
|
||||
#endif
|
||||
}
|
||||
|
||||
private var iconTone: some ShapeStyle {
|
||||
model.unreadNotificationCount == 0 ? Color.primary : dashboardAccent
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationCenterSheet: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
AppScrollScreen(
|
||||
compactLayout: compactLayout,
|
||||
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
|
||||
) {
|
||||
NotificationsPanel(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
.navigationTitle("Notifications")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents(compactLayout ? [.large] : [.medium, .large])
|
||||
#endif
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
317
Sources/Features/Home/HomePanels.swift
Normal file
317
Sources/Features/Home/HomePanels.swift
Normal file
@@ -0,0 +1,317 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OverviewPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if let profile = model.profile, let session = model.session {
|
||||
OverviewHero(
|
||||
profile: profile,
|
||||
session: session,
|
||||
pendingCount: model.pendingRequests.count,
|
||||
unreadCount: model.unreadNotificationCount,
|
||||
compactLayout: compactLayout
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RequestsPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
let onOpenRequest: (ApprovalRequest) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if model.requests.isEmpty {
|
||||
AppPanel(compactLayout: compactLayout) {
|
||||
EmptyStateCopy(
|
||||
title: "No checks waiting",
|
||||
systemImage: "checkmark.circle",
|
||||
message: "Identity proof requests from sites and devices appear here."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
RequestList(
|
||||
requests: model.requests,
|
||||
compactLayout: compactLayout,
|
||||
activeRequestID: model.activeRequestID,
|
||||
onApprove: { request in
|
||||
Task { await model.approve(request) }
|
||||
},
|
||||
onReject: { request in
|
||||
Task { await model.reject(request) }
|
||||
},
|
||||
onOpenRequest: onOpenRequest
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActivityPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if model.notifications.isEmpty {
|
||||
AppPanel(compactLayout: compactLayout) {
|
||||
EmptyStateCopy(
|
||||
title: "No proof activity yet",
|
||||
systemImage: "clock.badge.xmark",
|
||||
message: "Identity proofs and security events will appear here."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
NotificationList(
|
||||
notifications: model.notifications,
|
||||
compactLayout: compactLayout,
|
||||
onMarkRead: { notification in
|
||||
Task { await model.markNotificationRead(notification) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationsPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
AppSectionCard(title: "Delivery", compactLayout: compactLayout) {
|
||||
NotificationPermissionSummary(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Alerts", compactLayout: compactLayout) {
|
||||
if model.notifications.isEmpty {
|
||||
EmptyStateCopy(
|
||||
title: "No alerts yet",
|
||||
systemImage: "bell.slash",
|
||||
message: "New passport and identity-proof alerts will accumulate here."
|
||||
)
|
||||
} else {
|
||||
NotificationList(
|
||||
notifications: model.notifications,
|
||||
compactLayout: compactLayout,
|
||||
onMarkRead: { notification in
|
||||
Task { await model.markNotificationRead(notification) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if let profile = model.profile, let session = model.session {
|
||||
AccountHero(profile: profile, session: session, compactLayout: compactLayout)
|
||||
|
||||
AppSectionCard(title: "Session", compactLayout: compactLayout) {
|
||||
AccountFactsGrid(profile: profile, session: session, compactLayout: compactLayout)
|
||||
}
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Pairing payload", compactLayout: compactLayout) {
|
||||
AppTextSurface(text: model.suggestedPairingPayload, monospaced: true)
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Actions", compactLayout: compactLayout) {
|
||||
Button(role: .destructive) {
|
||||
model.signOut()
|
||||
} label: {
|
||||
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OverviewHero: View {
|
||||
let profile: MemberProfile
|
||||
let session: AuthSession
|
||||
let pendingCount: Int
|
||||
let unreadCount: Int
|
||||
let compactLayout: Bool
|
||||
|
||||
private var detailColumns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
|
||||
}
|
||||
|
||||
private var metricColumns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 16), count: 3)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "Digital passport", tone: dashboardAccent)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(profile.name)
|
||||
.font(.system(size: compactLayout ? 30 : 38, weight: .bold, design: .rounded))
|
||||
.lineLimit(2)
|
||||
|
||||
Text("\(profile.handle) • \(profile.organization)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: "Passport active", tone: dashboardAccent)
|
||||
AppStatusTag(title: session.pairingTransport.title, tone: dashboardGold)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
LazyVGrid(columns: detailColumns, alignment: .leading, spacing: 16) {
|
||||
AppKeyValue(label: "Device", value: session.deviceName)
|
||||
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
|
||||
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
LazyVGrid(columns: metricColumns, alignment: .leading, spacing: 16) {
|
||||
AppMetric(title: "Pending", value: "\(pendingCount)")
|
||||
AppMetric(title: "Alerts", value: "\(unreadCount)")
|
||||
AppMetric(title: "Devices", value: "\(profile.deviceCount)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationPermissionSummary: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: model.notificationPermission.systemImage)
|
||||
.font(.headline)
|
||||
.foregroundStyle(dashboardAccent)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(model.notificationPermission.title)
|
||||
.font(.headline)
|
||||
Text(model.notificationPermission.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if compactLayout {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
permissionButtons
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
permissionButtons
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var permissionButtons: some View {
|
||||
Button {
|
||||
Task { await model.requestNotificationAccess() }
|
||||
} label: {
|
||||
Label("Enable notifications", systemImage: "bell.and.waves.left.and.right.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
Task { await model.sendTestNotification() }
|
||||
} label: {
|
||||
Label("Send test alert", systemImage: "paperplane.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountHero: View {
|
||||
let profile: MemberProfile
|
||||
let session: AuthSession
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "Account", tone: dashboardAccent)
|
||||
|
||||
Text(profile.name)
|
||||
.font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded))
|
||||
.lineLimit(2)
|
||||
|
||||
Text(profile.handle)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Active client: \(session.deviceName)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountFactsGrid: View {
|
||||
let profile: MemberProfile
|
||||
let session: AuthSession
|
||||
let compactLayout: Bool
|
||||
|
||||
private var columns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, alignment: .leading, spacing: 16) {
|
||||
AppKeyValue(label: "Organization", value: profile.organization)
|
||||
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
|
||||
AppKeyValue(label: "Linked At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
AppKeyValue(label: "Method", value: session.pairingTransport.title)
|
||||
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
|
||||
AppKeyValue(label: "Recovery", value: profile.recoverySummary)
|
||||
if let signedGPSPosition = session.signedGPSPosition {
|
||||
AppKeyValue(
|
||||
label: "Signed GPS",
|
||||
value: "\(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)",
|
||||
monospaced: true
|
||||
)
|
||||
}
|
||||
AppKeyValue(label: "Trusted Devices", value: "\(profile.deviceCount)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyStateCopy: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView(
|
||||
title,
|
||||
systemImage: systemImage,
|
||||
description: Text(message)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private let dashboardAccent = AppTheme.accent
|
||||
private let dashboardGold = AppTheme.warmAccent
|
||||
let dashboardAccent = AppTheme.accent
|
||||
let dashboardGold = AppTheme.warmAccent
|
||||
|
||||
private extension View {
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func inlineNavigationTitleOnIOS() -> some View {
|
||||
#if os(iOS)
|
||||
@@ -123,7 +121,7 @@ private struct DashboardToolbar: ToolbarContent {
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationBellFrameKey: PreferenceKey {
|
||||
struct NotificationBellFrameKey: PreferenceKey {
|
||||
static var defaultValue: CGRect? = nil
|
||||
|
||||
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
|
||||
@@ -356,850 +354,3 @@ private struct SidebarStatusCard: View {
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
private struct OverviewPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if let profile = model.profile, let session = model.session {
|
||||
OverviewHero(
|
||||
profile: profile,
|
||||
session: session,
|
||||
pendingCount: model.pendingRequests.count,
|
||||
unreadCount: model.unreadNotificationCount,
|
||||
compactLayout: compactLayout
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RequestsPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
let onOpenRequest: (ApprovalRequest) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if model.requests.isEmpty {
|
||||
AppPanel(compactLayout: compactLayout) {
|
||||
EmptyStateCopy(
|
||||
title: "No checks waiting",
|
||||
systemImage: "checkmark.circle",
|
||||
message: "Identity proof requests from sites and devices appear here."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
RequestList(
|
||||
requests: model.requests,
|
||||
compactLayout: compactLayout,
|
||||
activeRequestID: model.activeRequestID,
|
||||
onApprove: { request in
|
||||
Task { await model.approve(request) }
|
||||
},
|
||||
onReject: { request in
|
||||
Task { await model.reject(request) }
|
||||
},
|
||||
onOpenRequest: onOpenRequest
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActivityPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if model.notifications.isEmpty {
|
||||
AppPanel(compactLayout: compactLayout) {
|
||||
EmptyStateCopy(
|
||||
title: "No proof activity yet",
|
||||
systemImage: "clock.badge.xmark",
|
||||
message: "Identity proofs and security events will appear here."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
NotificationList(
|
||||
notifications: model.notifications,
|
||||
compactLayout: compactLayout,
|
||||
onMarkRead: { notification in
|
||||
Task { await model.markNotificationRead(notification) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationsPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
AppSectionCard(title: "Delivery", compactLayout: compactLayout) {
|
||||
NotificationPermissionSummary(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Alerts", compactLayout: compactLayout) {
|
||||
if model.notifications.isEmpty {
|
||||
EmptyStateCopy(
|
||||
title: "No alerts yet",
|
||||
systemImage: "bell.slash",
|
||||
message: "New passport and identity-proof alerts will accumulate here."
|
||||
)
|
||||
} else {
|
||||
NotificationList(
|
||||
notifications: model.notifications,
|
||||
compactLayout: compactLayout,
|
||||
onMarkRead: { notification in
|
||||
Task { await model.markNotificationRead(notification) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if let profile = model.profile, let session = model.session {
|
||||
AccountHero(profile: profile, session: session, compactLayout: compactLayout)
|
||||
|
||||
AppSectionCard(title: "Session", compactLayout: compactLayout) {
|
||||
AccountFactsGrid(profile: profile, session: session, compactLayout: compactLayout)
|
||||
}
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Pairing payload", compactLayout: compactLayout) {
|
||||
AppTextSurface(text: model.suggestedPairingPayload, monospaced: true)
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Actions", compactLayout: compactLayout) {
|
||||
Button(role: .destructive) {
|
||||
model.signOut()
|
||||
} label: {
|
||||
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OverviewHero: View {
|
||||
let profile: MemberProfile
|
||||
let session: AuthSession
|
||||
let pendingCount: Int
|
||||
let unreadCount: Int
|
||||
let compactLayout: Bool
|
||||
|
||||
private var detailColumns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
|
||||
}
|
||||
|
||||
private var metricColumns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 16), count: 3)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "Digital passport", tone: dashboardAccent)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(profile.name)
|
||||
.font(.system(size: compactLayout ? 30 : 38, weight: .bold, design: .rounded))
|
||||
.lineLimit(2)
|
||||
|
||||
Text("\(profile.handle) • \(profile.organization)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: "Passport active", tone: dashboardAccent)
|
||||
AppStatusTag(title: session.pairingTransport.title, tone: dashboardGold)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
LazyVGrid(columns: detailColumns, alignment: .leading, spacing: 16) {
|
||||
AppKeyValue(label: "Device", value: session.deviceName)
|
||||
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
|
||||
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
LazyVGrid(columns: metricColumns, alignment: .leading, spacing: 16) {
|
||||
AppMetric(title: "Pending", value: "\(pendingCount)")
|
||||
AppMetric(title: "Alerts", value: "\(unreadCount)")
|
||||
AppMetric(title: "Devices", value: "\(profile.deviceCount)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationPermissionSummary: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: model.notificationPermission.systemImage)
|
||||
.font(.headline)
|
||||
.foregroundStyle(dashboardAccent)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(model.notificationPermission.title)
|
||||
.font(.headline)
|
||||
Text(model.notificationPermission.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if compactLayout {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
permissionButtons
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
permissionButtons
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var permissionButtons: some View {
|
||||
Button {
|
||||
Task { await model.requestNotificationAccess() }
|
||||
} label: {
|
||||
Label("Enable notifications", systemImage: "bell.and.waves.left.and.right.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
Task { await model.sendTestNotification() }
|
||||
} label: {
|
||||
Label("Send test alert", systemImage: "paperplane.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountHero: View {
|
||||
let profile: MemberProfile
|
||||
let session: AuthSession
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "Account", tone: dashboardAccent)
|
||||
|
||||
Text(profile.name)
|
||||
.font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded))
|
||||
.lineLimit(2)
|
||||
|
||||
Text(profile.handle)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Active client: \(session.deviceName)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountFactsGrid: View {
|
||||
let profile: MemberProfile
|
||||
let session: AuthSession
|
||||
let compactLayout: Bool
|
||||
|
||||
private var columns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, alignment: .leading, spacing: 16) {
|
||||
AppKeyValue(label: "Organization", value: profile.organization)
|
||||
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
|
||||
AppKeyValue(label: "Linked At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
AppKeyValue(label: "Method", value: session.pairingTransport.title)
|
||||
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
|
||||
AppKeyValue(label: "Recovery", value: profile.recoverySummary)
|
||||
if let signedGPSPosition = session.signedGPSPosition {
|
||||
AppKeyValue(
|
||||
label: "Signed GPS",
|
||||
value: "\(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)",
|
||||
monospaced: true
|
||||
)
|
||||
}
|
||||
AppKeyValue(label: "Trusted Devices", value: "\(profile.deviceCount)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RequestList: View {
|
||||
let requests: [ApprovalRequest]
|
||||
let compactLayout: Bool
|
||||
let activeRequestID: ApprovalRequest.ID?
|
||||
let onApprove: ((ApprovalRequest) -> Void)?
|
||||
let onReject: ((ApprovalRequest) -> Void)?
|
||||
let onOpenRequest: (ApprovalRequest) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
ForEach(requests) { request in
|
||||
RequestCard(
|
||||
request: request,
|
||||
compactLayout: compactLayout,
|
||||
isBusy: activeRequestID == request.id,
|
||||
onApprove: onApprove == nil ? nil : { onApprove?(request) },
|
||||
onReject: onReject == nil ? nil : { onReject?(request) },
|
||||
onOpenRequest: { onOpenRequest(request) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RequestCard: View {
|
||||
let request: ApprovalRequest
|
||||
let compactLayout: Bool
|
||||
let isBusy: Bool
|
||||
let onApprove: (() -> Void)?
|
||||
let onReject: (() -> Void)?
|
||||
let onOpenRequest: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: request.kind.systemImage)
|
||||
.font(.headline)
|
||||
.foregroundStyle(requestAccent)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(request.title)
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Text(request.source)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
AppStatusTag(title: request.status.title, tone: statusTone)
|
||||
}
|
||||
|
||||
Text(request.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
|
||||
Text(request.scopeSummary)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
Text(request.createdAt, style: .relative)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !request.scopes.isEmpty {
|
||||
Text("Proof details: \(request.scopes.joined(separator: ", "))")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
controls
|
||||
}
|
||||
.padding(compactLayout ? 18 : 20)
|
||||
.appSurface(radius: 24)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var controls: some View {
|
||||
if compactLayout {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
reviewButton
|
||||
decisionButtons
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
reviewButton
|
||||
Spacer(minLength: 0)
|
||||
decisionButtons
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reviewButton: some View {
|
||||
Button {
|
||||
onOpenRequest()
|
||||
} label: {
|
||||
Label("Review proof", systemImage: "arrow.up.forward.app")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var decisionButtons: some View {
|
||||
if request.status == .pending, let onApprove, let onReject {
|
||||
Button {
|
||||
onApprove()
|
||||
} label: {
|
||||
if isBusy {
|
||||
ProgressView()
|
||||
} else {
|
||||
Label("Verify", systemImage: "checkmark.circle.fill")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isBusy)
|
||||
|
||||
Button(role: .destructive) {
|
||||
onReject()
|
||||
} label: {
|
||||
Label("Decline", systemImage: "xmark.circle.fill")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusTone: Color {
|
||||
switch request.status {
|
||||
case .pending:
|
||||
.orange
|
||||
case .approved:
|
||||
.green
|
||||
case .rejected:
|
||||
.red
|
||||
}
|
||||
}
|
||||
|
||||
private var requestAccent: Color {
|
||||
switch request.status {
|
||||
case .approved:
|
||||
.green
|
||||
case .rejected:
|
||||
.red
|
||||
case .pending:
|
||||
request.risk == .routine ? dashboardAccent : .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationList: View {
|
||||
let notifications: [AppNotification]
|
||||
let compactLayout: Bool
|
||||
let onMarkRead: (AppNotification) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
ForEach(notifications) { notification in
|
||||
NotificationCard(
|
||||
notification: notification,
|
||||
compactLayout: compactLayout,
|
||||
onMarkRead: { onMarkRead(notification) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationCard: View {
|
||||
let notification: AppNotification
|
||||
let compactLayout: Bool
|
||||
let onMarkRead: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: notification.kind.systemImage)
|
||||
.font(.headline)
|
||||
.foregroundStyle(accentColor)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(notification.title)
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: notification.kind.title, tone: accentColor)
|
||||
if notification.isUnread {
|
||||
AppStatusTag(title: "Unread", tone: .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Text(notification.message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if compactLayout {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
timestamp
|
||||
if notification.isUnread {
|
||||
markReadButton
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
timestamp
|
||||
Spacer(minLength: 0)
|
||||
if notification.isUnread {
|
||||
markReadButton
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(compactLayout ? 18 : 20)
|
||||
.appSurface(radius: 24)
|
||||
}
|
||||
|
||||
private var timestamp: some View {
|
||||
Text(notification.sentAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var markReadButton: some View {
|
||||
Button {
|
||||
onMarkRead()
|
||||
} label: {
|
||||
Label("Mark read", systemImage: "checkmark")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
private var accentColor: Color {
|
||||
switch notification.kind {
|
||||
case .approval:
|
||||
.green
|
||||
case .security:
|
||||
.orange
|
||||
case .system:
|
||||
.blue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationBellButton: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
model.isNotificationCenterPresented = true
|
||||
} label: {
|
||||
Image(systemName: imageName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(iconTone)
|
||||
.frame(width: 28, height: 28, alignment: .center)
|
||||
.background(alignment: .center) {
|
||||
#if os(iOS)
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Notifications")
|
||||
}
|
||||
|
||||
private var imageName: String {
|
||||
#if os(iOS)
|
||||
model.unreadNotificationCount == 0 ? "bell" : "bell.fill"
|
||||
#else
|
||||
model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill"
|
||||
#endif
|
||||
}
|
||||
|
||||
private var iconTone: some ShapeStyle {
|
||||
model.unreadNotificationCount == 0 ? Color.primary : dashboardAccent
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationCenterSheet: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
AppScrollScreen(
|
||||
compactLayout: compactLayout,
|
||||
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
|
||||
) {
|
||||
NotificationsPanel(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
.navigationTitle("Notifications")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents(compactLayout ? [.large] : [.medium, .large])
|
||||
#endif
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct RequestDetailSheet: View {
|
||||
let request: ApprovalRequest
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
AppScrollScreen(
|
||||
compactLayout: true,
|
||||
bottomPadding: AppLayout.compactBottomDockPadding
|
||||
) {
|
||||
RequestDetailHero(request: request)
|
||||
|
||||
AppSectionCard(title: "Summary", compactLayout: true) {
|
||||
AppKeyValue(label: "Source", value: request.source)
|
||||
AppKeyValue(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||
AppKeyValue(label: "Risk", value: request.risk.summary)
|
||||
AppKeyValue(label: "Type", value: request.kind.title)
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Proof details", compactLayout: true) {
|
||||
if request.scopes.isEmpty {
|
||||
Text("No explicit proof details were provided by the mock backend.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(request.scopes.joined(separator: "\n"))
|
||||
.font(.body.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Guidance", compactLayout: true) {
|
||||
Text(request.trustDetail)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(request.risk.guidance)
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
if request.status == .pending {
|
||||
AppSectionCard(title: "Actions", compactLayout: true) {
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
Task {
|
||||
await model.approve(request)
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
if model.activeRequestID == request.id {
|
||||
ProgressView()
|
||||
} else {
|
||||
Label("Verify identity", systemImage: "checkmark.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(model.activeRequestID == request.id)
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await model.reject(request)
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
Label("Decline", systemImage: "xmark.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(model.activeRequestID == request.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Review Proof")
|
||||
.inlineNavigationTitleOnIOS()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RequestDetailHero: View {
|
||||
let request: ApprovalRequest
|
||||
|
||||
private var accent: Color {
|
||||
switch request.status {
|
||||
case .approved:
|
||||
.green
|
||||
case .rejected:
|
||||
.red
|
||||
case .pending:
|
||||
request.risk == .routine ? dashboardAccent : .orange
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: true, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: request.kind.title, tone: accent)
|
||||
|
||||
Text(request.title)
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.lineLimit(3)
|
||||
|
||||
Text(request.subtitle)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: request.status.title, tone: accent)
|
||||
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OneTimePasscodeSheet: View {
|
||||
let session: AuthSession
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
TimelineView(.periodic(from: .now, by: 1)) { context in
|
||||
let code = passcode(at: context.date)
|
||||
let secondsRemaining = renewalCountdown(at: context.date)
|
||||
|
||||
AppScrollScreen(compactLayout: compactLayout) {
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "One-time passcode", tone: dashboardGold)
|
||||
|
||||
Text("OTP")
|
||||
.font(.system(size: compactLayout ? 32 : 40, weight: .bold, design: .rounded))
|
||||
|
||||
Text("Share this code only with the site or device asking you to prove that it is really you.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(code)
|
||||
.font(.system(size: compactLayout ? 42 : 54, weight: .bold, design: .rounded).monospacedDigit())
|
||||
.tracking(compactLayout ? 4 : 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, compactLayout ? 16 : 20)
|
||||
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: "Renews in \(secondsRemaining)s", tone: dashboardGold)
|
||||
AppStatusTag(title: session.originHost, tone: dashboardAccent)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
AppKeyValue(label: "Client", value: session.deviceName)
|
||||
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("OTP")
|
||||
.inlineNavigationTitleOnIOS()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private func passcode(at date: Date) -> String {
|
||||
let timeSlot = Int(date.timeIntervalSince1970 / 30)
|
||||
let digest = SHA256.hash(data: Data("\(session.pairingCode)|\(timeSlot)".utf8))
|
||||
let value = digest.prefix(4).reduce(UInt32(0)) { partialResult, byte in
|
||||
(partialResult << 8) | UInt32(byte)
|
||||
}
|
||||
|
||||
return String(format: "%06d", locale: Locale(identifier: "en_US_POSIX"), Int(value % 1_000_000))
|
||||
}
|
||||
|
||||
private func renewalCountdown(at date: Date) -> Int {
|
||||
let elapsed = Int(date.timeIntervalSince1970) % 30
|
||||
return elapsed == 0 ? 30 : 30 - elapsed
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyStateCopy: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView(
|
||||
title,
|
||||
systemImage: systemImage,
|
||||
description: Text(message)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
188
Sources/Features/Home/HomeSheets.swift
Normal file
188
Sources/Features/Home/HomeSheets.swift
Normal file
@@ -0,0 +1,188 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RequestDetailSheet: View {
|
||||
let request: ApprovalRequest
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
AppScrollScreen(
|
||||
compactLayout: true,
|
||||
bottomPadding: AppLayout.compactBottomDockPadding
|
||||
) {
|
||||
RequestDetailHero(request: request)
|
||||
|
||||
AppSectionCard(title: "Summary", compactLayout: true) {
|
||||
AppKeyValue(label: "Source", value: request.source)
|
||||
AppKeyValue(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||
AppKeyValue(label: "Risk", value: request.risk.summary)
|
||||
AppKeyValue(label: "Type", value: request.kind.title)
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Proof details", compactLayout: true) {
|
||||
if request.scopes.isEmpty {
|
||||
Text("No explicit proof details were provided by the mock backend.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(request.scopes.joined(separator: "\n"))
|
||||
.font(.body.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Guidance", compactLayout: true) {
|
||||
Text(request.trustDetail)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(request.risk.guidance)
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
if request.status == .pending {
|
||||
AppSectionCard(title: "Actions", compactLayout: true) {
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
Task {
|
||||
await model.approve(request)
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
if model.activeRequestID == request.id {
|
||||
ProgressView()
|
||||
} else {
|
||||
Label("Verify identity", systemImage: "checkmark.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(model.activeRequestID == request.id)
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await model.reject(request)
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
Label("Decline", systemImage: "xmark.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(model.activeRequestID == request.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Review Proof")
|
||||
.inlineNavigationTitleOnIOS()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RequestDetailHero: View {
|
||||
let request: ApprovalRequest
|
||||
|
||||
private var accent: Color {
|
||||
switch request.status {
|
||||
case .approved:
|
||||
.green
|
||||
case .rejected:
|
||||
.red
|
||||
case .pending:
|
||||
request.risk == .routine ? dashboardAccent : .orange
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: true, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: request.kind.title, tone: accent)
|
||||
|
||||
Text(request.title)
|
||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||
.lineLimit(3)
|
||||
|
||||
Text(request.subtitle)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: request.status.title, tone: accent)
|
||||
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OneTimePasscodeSheet: View {
|
||||
let session: AuthSession
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
TimelineView(.periodic(from: .now, by: 1)) { context in
|
||||
let code = OneTimePasscodeGenerator.code(for: session.pairingCode, at: context.date)
|
||||
let secondsRemaining = OneTimePasscodeGenerator.renewalCountdown(at: context.date)
|
||||
|
||||
AppScrollScreen(compactLayout: compactLayout) {
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "One-time passcode", tone: dashboardGold)
|
||||
|
||||
Text("OTP")
|
||||
.font(.system(size: compactLayout ? 32 : 40, weight: .bold, design: .rounded))
|
||||
|
||||
Text("Share this code only with the site or device asking you to prove that it is really you.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(code)
|
||||
.font(.system(size: compactLayout ? 42 : 54, weight: .bold, design: .rounded).monospacedDigit())
|
||||
.tracking(compactLayout ? 4 : 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, compactLayout ? 16 : 20)
|
||||
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: "Renews in \(secondsRemaining)s", tone: dashboardGold)
|
||||
AppStatusTag(title: session.originHost, tone: dashboardAccent)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
AppKeyValue(label: "Client", value: session.deviceName)
|
||||
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("OTP")
|
||||
.inlineNavigationTitleOnIOS()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
249
Tests/AppViewModelTests.swift
Normal file
249
Tests/AppViewModelTests.swift
Normal file
@@ -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 {}
|
||||
}
|
||||
23
Tests/OneTimePasscodeGeneratorTests.swift
Normal file
23
Tests/OneTimePasscodeGeneratorTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
28
Tests/PairingPayloadParserTests.swift
Normal file
28
Tests/PairingPayloadParserTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user