This commit is contained in:
@@ -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 */; };
|
B10000000000000000000011 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000E /* Assets.xcassets */; };
|
||||||
B10000000000000000000012 /* AppComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000F /* AppComponents.swift */; };
|
B10000000000000000000012 /* AppComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000F /* AppComponents.swift */; };
|
||||||
B10000000000000000000013 /* 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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -36,6 +50,13 @@
|
|||||||
remoteGlobalIDString = B50000000000000000000002;
|
remoteGlobalIDString = B50000000000000000000002;
|
||||||
remoteInfo = IDPGlobalWatch;
|
remoteInfo = IDPGlobalWatch;
|
||||||
};
|
};
|
||||||
|
B90000000000000000000003 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = B60000000000000000000001 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = B50000000000000000000001;
|
||||||
|
remoteInfo = IDPGlobal;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -68,6 +89,18 @@
|
|||||||
B2000000000000000000000D /* NFCPairingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCPairingView.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -85,6 +118,14 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
B30000000000000000000009 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
B10000000000000000000021 /* XCTest.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -102,6 +143,7 @@
|
|||||||
B2000000000000000000000E /* Assets.xcassets */,
|
B2000000000000000000000E /* Assets.xcassets */,
|
||||||
B40000000000000000000003 /* Sources */,
|
B40000000000000000000003 /* Sources */,
|
||||||
B4000000000000000000000C /* WatchApp */,
|
B4000000000000000000000C /* WatchApp */,
|
||||||
|
B4000000000000000000000F /* Tests */,
|
||||||
);
|
);
|
||||||
name = IDPGlobal;
|
name = IDPGlobal;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -119,6 +161,7 @@
|
|||||||
B40000000000000000000004 /* App */ = {
|
B40000000000000000000004 /* App */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
B20000000000000000000010 /* AppTheme.swift */,
|
||||||
B2000000000000000000000F /* AppComponents.swift */,
|
B2000000000000000000000F /* AppComponents.swift */,
|
||||||
B20000000000000000000001 /* IDPGlobalApp.swift */,
|
B20000000000000000000001 /* IDPGlobalApp.swift */,
|
||||||
B20000000000000000000002 /* AppViewModel.swift */,
|
B20000000000000000000002 /* AppViewModel.swift */,
|
||||||
@@ -146,8 +189,11 @@
|
|||||||
B40000000000000000000007 /* Services */ = {
|
B40000000000000000000007 /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
B20000000000000000000011 /* AppStateStore.swift */,
|
||||||
B20000000000000000000004 /* MockIDPService.swift */,
|
B20000000000000000000004 /* MockIDPService.swift */,
|
||||||
|
B20000000000000000000012 /* OneTimePasscodeGenerator.swift */,
|
||||||
B20000000000000000000005 /* NotificationCoordinator.swift */,
|
B20000000000000000000005 /* NotificationCoordinator.swift */,
|
||||||
|
B20000000000000000000013 /* PairingPayloadParser.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -166,6 +212,7 @@
|
|||||||
children = (
|
children = (
|
||||||
B20000000000000000000009 /* IDPGlobal.app */,
|
B20000000000000000000009 /* IDPGlobal.app */,
|
||||||
B2000000000000000000000A /* IDPGlobalWatch.app */,
|
B2000000000000000000000A /* IDPGlobalWatch.app */,
|
||||||
|
B2000000000000000000001A /* IDPGlobalTests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -183,7 +230,10 @@
|
|||||||
B4000000000000000000000B /* Home */ = {
|
B4000000000000000000000B /* Home */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
B20000000000000000000015 /* HomeCards.swift */,
|
||||||
|
B20000000000000000000014 /* HomePanels.swift */,
|
||||||
B20000000000000000000008 /* HomeRootView.swift */,
|
B20000000000000000000008 /* HomeRootView.swift */,
|
||||||
|
B20000000000000000000016 /* HomeSheets.swift */,
|
||||||
);
|
);
|
||||||
path = Home;
|
path = Home;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -213,6 +263,16 @@
|
|||||||
path = Features;
|
path = Features;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
B4000000000000000000000F /* Tests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B20000000000000000000019 /* AppViewModelTests.swift */,
|
||||||
|
B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */,
|
||||||
|
B20000000000000000000017 /* PairingPayloadParserTests.swift */,
|
||||||
|
);
|
||||||
|
path = Tests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -252,6 +312,24 @@
|
|||||||
productReference = B2000000000000000000000A /* IDPGlobalWatch.app */;
|
productReference = B2000000000000000000000A /* IDPGlobalWatch.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -268,6 +346,10 @@
|
|||||||
B50000000000000000000002 = {
|
B50000000000000000000002 = {
|
||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
};
|
};
|
||||||
|
B50000000000000000000003 = {
|
||||||
|
CreatedOnToolsVersion = 26.0;
|
||||||
|
TestTargetID = B50000000000000000000001;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */;
|
buildConfigurationList = B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */;
|
||||||
@@ -285,6 +367,7 @@
|
|||||||
targets = (
|
targets = (
|
||||||
B50000000000000000000001 /* IDPGlobal */,
|
B50000000000000000000001 /* IDPGlobal */,
|
||||||
B50000000000000000000002 /* IDPGlobalWatch */,
|
B50000000000000000000002 /* IDPGlobalWatch */,
|
||||||
|
B50000000000000000000003 /* IDPGlobalTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -305,6 +388,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
B3000000000000000000000A /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -312,15 +402,22 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
B10000000000000000000015 /* AppStateStore.swift in Sources */,
|
||||||
B10000000000000000000012 /* AppComponents.swift in Sources */,
|
B10000000000000000000012 /* AppComponents.swift in Sources */,
|
||||||
|
B10000000000000000000014 /* AppTheme.swift in Sources */,
|
||||||
B10000000000000000000002 /* AppViewModel.swift in Sources */,
|
B10000000000000000000002 /* AppViewModel.swift in Sources */,
|
||||||
|
B10000000000000000000019 /* HomeCards.swift in Sources */,
|
||||||
|
B10000000000000000000018 /* HomePanels.swift in Sources */,
|
||||||
B10000000000000000000008 /* HomeRootView.swift in Sources */,
|
B10000000000000000000008 /* HomeRootView.swift in Sources */,
|
||||||
|
B1000000000000000000001A /* HomeSheets.swift in Sources */,
|
||||||
B10000000000000000000001 /* IDPGlobalApp.swift in Sources */,
|
B10000000000000000000001 /* IDPGlobalApp.swift in Sources */,
|
||||||
B10000000000000000000006 /* LoginRootView.swift in Sources */,
|
B10000000000000000000006 /* LoginRootView.swift in Sources */,
|
||||||
B10000000000000000000004 /* MockIDPService.swift in Sources */,
|
B10000000000000000000004 /* MockIDPService.swift in Sources */,
|
||||||
B10000000000000000000010 /* NFCPairingView.swift in Sources */,
|
B10000000000000000000010 /* NFCPairingView.swift in Sources */,
|
||||||
B10000000000000000000005 /* NotificationCoordinator.swift in Sources */,
|
B10000000000000000000005 /* NotificationCoordinator.swift in Sources */,
|
||||||
|
B10000000000000000000016 /* OneTimePasscodeGenerator.swift in Sources */,
|
||||||
B10000000000000000000003 /* AppModels.swift in Sources */,
|
B10000000000000000000003 /* AppModels.swift in Sources */,
|
||||||
|
B10000000000000000000017 /* PairingPayloadParser.swift in Sources */,
|
||||||
B10000000000000000000007 /* QRScannerView.swift in Sources */,
|
B10000000000000000000007 /* QRScannerView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -329,16 +426,29 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
B1000000000000000000001C /* AppStateStore.swift in Sources */,
|
||||||
B10000000000000000000013 /* AppComponents.swift in Sources */,
|
B10000000000000000000013 /* AppComponents.swift in Sources */,
|
||||||
|
B1000000000000000000001B /* AppTheme.swift in Sources */,
|
||||||
B10000000000000000000009 /* AppViewModel.swift in Sources */,
|
B10000000000000000000009 /* AppViewModel.swift in Sources */,
|
||||||
B1000000000000000000000A /* AppModels.swift in Sources */,
|
B1000000000000000000000A /* AppModels.swift in Sources */,
|
||||||
B1000000000000000000000D /* IDPGlobalWatchApp.swift in Sources */,
|
B1000000000000000000000D /* IDPGlobalWatchApp.swift in Sources */,
|
||||||
B1000000000000000000000B /* MockIDPService.swift in Sources */,
|
B1000000000000000000000B /* MockIDPService.swift in Sources */,
|
||||||
B1000000000000000000000C /* NotificationCoordinator.swift in Sources */,
|
B1000000000000000000000C /* NotificationCoordinator.swift in Sources */,
|
||||||
|
B1000000000000000000001D /* PairingPayloadParser.swift in Sources */,
|
||||||
B1000000000000000000000E /* WatchRootView.swift in Sources */,
|
B1000000000000000000000E /* WatchRootView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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 */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
@@ -348,6 +458,11 @@
|
|||||||
target = B50000000000000000000002 /* IDPGlobalWatch */;
|
target = B50000000000000000000002 /* IDPGlobalWatch */;
|
||||||
targetProxy = B90000000000000000000001 /* PBXContainerItemProxy */;
|
targetProxy = B90000000000000000000001 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
B90000000000000000000004 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = B50000000000000000000001 /* IDPGlobal */;
|
||||||
|
targetProxy = B90000000000000000000003 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -415,6 +530,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "idp.global";
|
INFOPLIST_KEY_CFBundleDisplayName = "idp.global";
|
||||||
@@ -435,6 +551,7 @@
|
|||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_OBSERVATION_ENABLED = YES;
|
SWIFT_OBSERVATION_ENABLED = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
@@ -532,6 +649,46 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -562,6 +719,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
B70000000000000000000004 /* Build configuration list for PBXNativeTarget "IDPGlobalTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
B80000000000000000000007 /* Debug */,
|
||||||
|
B80000000000000000000008 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = B60000000000000000000001 /* Project object */;
|
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
|
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 {
|
struct AppSectionTitle: View {
|
||||||
let title: String
|
let title: String
|
||||||
|
|||||||
@@ -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 var hasBootstrapped = false
|
||||||
private let service: IDPServicing
|
private let service: IDPServicing
|
||||||
private let notificationCoordinator: NotificationCoordinating
|
private let notificationCoordinator: NotificationCoordinating
|
||||||
|
private let appStateStore: AppStateStoring
|
||||||
private let launchArguments: [String]
|
private let launchArguments: [String]
|
||||||
|
|
||||||
private var preferredLaunchSection: AppSection? {
|
private var preferredLaunchSection: AppSection? {
|
||||||
@@ -40,10 +41,12 @@ final class AppViewModel: ObservableObject {
|
|||||||
init(
|
init(
|
||||||
service: IDPServicing = MockIDPService(),
|
service: IDPServicing = MockIDPService(),
|
||||||
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
|
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
|
||||||
|
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
|
||||||
launchArguments: [String] = ProcessInfo.processInfo.arguments
|
launchArguments: [String] = ProcessInfo.processInfo.arguments
|
||||||
) {
|
) {
|
||||||
self.service = service
|
self.service = service
|
||||||
self.notificationCoordinator = notificationCoordinator
|
self.notificationCoordinator = notificationCoordinator
|
||||||
|
self.appStateStore = appStateStore
|
||||||
self.launchArguments = launchArguments
|
self.launchArguments = launchArguments
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,14 +82,17 @@ final class AppViewModel: ObservableObject {
|
|||||||
guard !hasBootstrapped else { return }
|
guard !hasBootstrapped else { return }
|
||||||
hasBootstrapped = true
|
hasBootstrapped = true
|
||||||
|
|
||||||
|
restorePersistedState()
|
||||||
|
|
||||||
isBootstrapping = true
|
isBootstrapping = true
|
||||||
defer { isBootstrapping = false }
|
defer { isBootstrapping = false }
|
||||||
|
|
||||||
|
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let bootstrap = try await service.bootstrap()
|
let bootstrap = try await service.bootstrap()
|
||||||
suggestedPairingPayload = bootstrap.suggestedPairingPayload
|
suggestedPairingPayload = bootstrap.suggestedPairingPayload
|
||||||
manualPairingPayload = bootstrap.suggestedPairingPayload
|
manualPairingPayload = session?.pairingCode ?? bootstrap.suggestedPairingPayload
|
||||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
|
||||||
|
|
||||||
if launchArguments.contains("--mock-auto-pair"),
|
if launchArguments.contains("--mock-auto-pair"),
|
||||||
session == nil {
|
session == nil {
|
||||||
@@ -97,9 +103,11 @@ final class AppViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if session == nil {
|
||||||
errorMessage = "Unable to prepare the app."
|
errorMessage = "Unable to prepare the app."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func signInWithManualPayload() async {
|
func signInWithManualPayload() async {
|
||||||
await signIn(with: manualPairingPayload, transport: .manual)
|
await signIn(with: manualPairingPayload, transport: .manual)
|
||||||
@@ -144,6 +152,7 @@ final class AppViewModel: ObservableObject {
|
|||||||
let result = try await service.signIn(with: normalizedRequest)
|
let result = try await service.signIn(with: normalizedRequest)
|
||||||
session = result.session
|
session = result.session
|
||||||
apply(snapshot: result.snapshot)
|
apply(snapshot: result.snapshot)
|
||||||
|
persistCurrentState()
|
||||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||||
selectedSection = .overview
|
selectedSection = .overview
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
@@ -200,6 +209,7 @@ final class AppViewModel: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let snapshot = try await service.identify(with: normalizedRequest)
|
let snapshot = try await service.identify(with: normalizedRequest)
|
||||||
apply(snapshot: snapshot)
|
apply(snapshot: snapshot)
|
||||||
|
persistCurrentState()
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
isScannerPresented = false
|
isScannerPresented = false
|
||||||
} catch let error as AppError {
|
} catch let error as AppError {
|
||||||
@@ -218,6 +228,7 @@ final class AppViewModel: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let snapshot = try await service.refreshDashboard()
|
let snapshot = try await service.refreshDashboard()
|
||||||
apply(snapshot: snapshot)
|
apply(snapshot: snapshot)
|
||||||
|
persistCurrentState()
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Unable to refresh the dashboard."
|
errorMessage = "Unable to refresh the dashboard."
|
||||||
@@ -238,6 +249,7 @@ final class AppViewModel: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let snapshot = try await service.simulateIncomingRequest()
|
let snapshot = try await service.simulateIncomingRequest()
|
||||||
apply(snapshot: snapshot)
|
apply(snapshot: snapshot)
|
||||||
|
persistCurrentState()
|
||||||
selectedSection = .requests
|
selectedSection = .requests
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
@@ -271,6 +283,7 @@ final class AppViewModel: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let snapshot = try await service.markNotificationRead(id: notification.id)
|
let snapshot = try await service.markNotificationRead(id: notification.id)
|
||||||
apply(snapshot: snapshot)
|
apply(snapshot: snapshot)
|
||||||
|
persistCurrentState()
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Unable to update the notification."
|
errorMessage = "Unable to update the notification."
|
||||||
@@ -278,6 +291,7 @@ final class AppViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func signOut() {
|
func signOut() {
|
||||||
|
appStateStore.clear()
|
||||||
session = nil
|
session = nil
|
||||||
profile = nil
|
profile = nil
|
||||||
requests = []
|
requests = []
|
||||||
@@ -298,12 +312,45 @@ final class AppViewModel: ObservableObject {
|
|||||||
? try await service.approveRequest(id: request.id)
|
? try await service.approveRequest(id: request.id)
|
||||||
: try await service.rejectRequest(id: request.id)
|
: try await service.rejectRequest(id: request.id)
|
||||||
apply(snapshot: snapshot)
|
apply(snapshot: snapshot)
|
||||||
|
persistCurrentState()
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Unable to update the identity check."
|
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) {
|
private func apply(snapshot: DashboardSnapshot) {
|
||||||
profile = snapshot.profile
|
profile = snapshot.profile
|
||||||
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
|
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum AppSection: String, CaseIterable, Identifiable, Hashable {
|
enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable {
|
||||||
case overview
|
case overview
|
||||||
case requests
|
case requests
|
||||||
case activity
|
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 unknown
|
||||||
case allowed
|
case allowed
|
||||||
case provisional
|
case provisional
|
||||||
@@ -72,7 +72,7 @@ struct BootstrapContext {
|
|||||||
let suggestedPairingPayload: String
|
let suggestedPairingPayload: String
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PairingTransport: String, Hashable {
|
enum PairingTransport: String, Hashable, Codable {
|
||||||
case qr
|
case qr
|
||||||
case nfc
|
case nfc
|
||||||
case manual
|
case manual
|
||||||
@@ -98,7 +98,7 @@ struct PairingAuthenticationRequest: Hashable {
|
|||||||
let signedGPSPosition: SignedGPSPosition?
|
let signedGPSPosition: SignedGPSPosition?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SignedGPSPosition: Hashable {
|
struct SignedGPSPosition: Hashable, Codable {
|
||||||
let latitude: Double
|
let latitude: Double
|
||||||
let longitude: Double
|
let longitude: Double
|
||||||
let horizontalAccuracyMeters: Double
|
let horizontalAccuracyMeters: Double
|
||||||
@@ -185,7 +185,7 @@ struct SignInResult {
|
|||||||
let snapshot: DashboardSnapshot
|
let snapshot: DashboardSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MemberProfile: Identifiable, Hashable {
|
struct MemberProfile: Identifiable, Hashable, Codable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let name: String
|
let name: String
|
||||||
let handle: 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 id: UUID
|
||||||
let deviceName: String
|
let deviceName: String
|
||||||
let originHost: 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 signIn
|
||||||
case accessGrant
|
case accessGrant
|
||||||
case elevatedAction
|
case elevatedAction
|
||||||
@@ -263,7 +263,7 @@ enum ApprovalRequestKind: String, CaseIterable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ApprovalRisk: String, Hashable {
|
enum ApprovalRisk: String, Hashable, Codable {
|
||||||
case routine
|
case routine
|
||||||
case elevated
|
case elevated
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ enum ApprovalRisk: String, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ApprovalStatus: String, Hashable {
|
enum ApprovalStatus: String, Hashable, Codable {
|
||||||
case pending
|
case pending
|
||||||
case approved
|
case approved
|
||||||
case rejected
|
case rejected
|
||||||
@@ -315,7 +315,7 @@ enum ApprovalStatus: String, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ApprovalRequest: Identifiable, Hashable {
|
struct ApprovalRequest: Identifiable, Hashable, Codable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let title: String
|
let title: String
|
||||||
let subtitle: String
|
let subtitle: String
|
||||||
@@ -382,7 +382,7 @@ struct ApprovalRequest: Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AppNotificationKind: String, Hashable {
|
enum AppNotificationKind: String, Hashable, Codable {
|
||||||
case approval
|
case approval
|
||||||
case security
|
case security
|
||||||
case system
|
case system
|
||||||
@@ -415,7 +415,7 @@ enum AppNotificationKind: String, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AppNotification: Identifiable, Hashable {
|
struct AppNotification: Identifiable, Hashable, Codable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let title: String
|
let title: String
|
||||||
let message: String
|
let message: String
|
||||||
@@ -440,7 +440,7 @@ struct AppNotification: Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AppError: LocalizedError {
|
enum AppError: LocalizedError, Equatable {
|
||||||
case invalidPairingPayload
|
case invalidPairingPayload
|
||||||
case missingSignedGPSPosition
|
case missingSignedGPSPosition
|
||||||
case invalidSignedGPSPosition
|
case invalidSignedGPSPosition
|
||||||
|
|||||||
@@ -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 await Task.sleep(for: .milliseconds(180))
|
||||||
|
|
||||||
try validateSignedGPSPosition(in: request)
|
try validateSignedGPSPosition(in: request)
|
||||||
let context = try parsePayloadContext(from: request.pairingPayload)
|
let context = try PairingPayloadParser.parse(request.pairingPayload)
|
||||||
notifications.insert(
|
notifications.insert(
|
||||||
AppNotification(
|
AppNotification(
|
||||||
title: "Identity proof completed",
|
title: "Identity proof completed",
|
||||||
@@ -186,7 +186,7 @@ actor MockIDPService: IDPServicing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func parseSession(from request: PairingAuthenticationRequest) throws -> AuthSession {
|
private func parseSession(from request: PairingAuthenticationRequest) throws -> AuthSession {
|
||||||
let context = try parsePayloadContext(from: request.pairingPayload)
|
let context = try PairingPayloadParser.parse(request.pairingPayload)
|
||||||
|
|
||||||
return AuthSession(
|
return AuthSession(
|
||||||
deviceName: context.deviceName,
|
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 {
|
private func pairingMessage(for session: AuthSession) -> String {
|
||||||
let transportSummary: String
|
let transportSummary: String
|
||||||
switch session.pairingTransport {
|
switch session.pairingTransport {
|
||||||
@@ -246,7 +219,7 @@ actor MockIDPService: IDPServicing {
|
|||||||
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost)."
|
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 {
|
if let signedGPSPosition {
|
||||||
return "A signed GPS proof was sent for \(context.deviceName) on \(context.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
|
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)."
|
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] {
|
private static func seedRequests() -> [ApprovalRequest] {
|
||||||
[
|
[
|
||||||
ApprovalRequest(
|
ApprovalRequest(
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum OneTimePasscodeGenerator {
|
||||||
|
static func code(for pairingCode: String, at date: Date) -> String {
|
||||||
|
let timeSlot = Int(date.timeIntervalSince1970 / 30)
|
||||||
|
let digest = SHA256.hash(data: Data("\(pairingCode)|\(timeSlot)".utf8))
|
||||||
|
let value = digest.prefix(4).reduce(UInt32(0)) { partialResult, byte in
|
||||||
|
(partialResult << 8) | UInt32(byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(format: "%06d", locale: Locale(identifier: "en_US_POSIX"), Int(value % 1_000_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func renewalCountdown(at date: Date) -> Int {
|
||||||
|
let elapsed = Int(date.timeIntervalSince1970) % 30
|
||||||
|
return elapsed == 0 ? 30 : 30 - elapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PairingPayloadContext: Equatable {
|
||||||
|
let deviceName: String
|
||||||
|
let originHost: String
|
||||||
|
let tokenPreview: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PairingPayloadParser {
|
||||||
|
static func parse(_ payload: String) throws -> PairingPayloadContext {
|
||||||
|
let trimmedPayload = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
if let components = URLComponents(string: trimmedPayload),
|
||||||
|
components.scheme == "idp.global",
|
||||||
|
components.host == "pair" {
|
||||||
|
let queryItems = components.queryItems ?? []
|
||||||
|
let token = queryItems.first(where: { $0.name == "token" })?.value ?? "demo-token"
|
||||||
|
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
|
||||||
|
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
|
||||||
|
|
||||||
|
return PairingPayloadContext(
|
||||||
|
deviceName: device,
|
||||||
|
originHost: origin,
|
||||||
|
tokenPreview: String(token.suffix(6))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmedPayload.contains("token") || trimmedPayload.contains("pair") {
|
||||||
|
return PairingPayloadContext(
|
||||||
|
deviceName: "Manual Session",
|
||||||
|
originHost: "code.foss.global",
|
||||||
|
tokenPreview: String(trimmedPayload.suffix(6))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw AppError.invalidPairingPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
import SwiftUI
|
||||||
|
|
||||||
private let dashboardAccent = AppTheme.accent
|
let dashboardAccent = AppTheme.accent
|
||||||
private let dashboardGold = AppTheme.warmAccent
|
let dashboardGold = AppTheme.warmAccent
|
||||||
|
|
||||||
private extension View {
|
extension View {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func inlineNavigationTitleOnIOS() -> some View {
|
func inlineNavigationTitleOnIOS() -> some View {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -123,7 +121,7 @@ private struct DashboardToolbar: ToolbarContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct NotificationBellFrameKey: PreferenceKey {
|
struct NotificationBellFrameKey: PreferenceKey {
|
||||||
static var defaultValue: CGRect? = nil
|
static var defaultValue: CGRect? = nil
|
||||||
|
|
||||||
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
|
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
|
||||||
@@ -356,850 +354,3 @@ private struct SidebarStatusCard: View {
|
|||||||
.padding(.vertical, 6)
|
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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