Move the app payload under swift/ while keeping git, package.json, and .smartconfig.json at the repo root. This standardizes the Swift app setup so build, test, run, and watch workflows match the other repos.
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "iphone-20@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" },
|
||||
{ "filename" : "iphone-20@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" },
|
||||
{ "filename" : "iphone-29@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" },
|
||||
{ "filename" : "iphone-29@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" },
|
||||
{ "filename" : "iphone-40@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" },
|
||||
{ "filename" : "iphone-40@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" },
|
||||
{ "filename" : "iphone-60@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" },
|
||||
{ "filename" : "iphone-60@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" },
|
||||
{ "filename" : "ipad-20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" },
|
||||
{ "filename" : "ipad-20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" },
|
||||
{ "filename" : "ipad-29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" },
|
||||
{ "filename" : "ipad-29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" },
|
||||
{ "filename" : "ipad-40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" },
|
||||
{ "filename" : "ipad-40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" },
|
||||
{ "filename" : "ipad-76@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" },
|
||||
{ "filename" : "ipad-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },
|
||||
{ "filename" : "ipad-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" },
|
||||
{ "filename" : "ios-marketing-1024@1x.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" },
|
||||
{ "filename" : "mac-16@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
|
||||
{ "filename" : "mac-16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
|
||||
{ "filename" : "mac-32@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
|
||||
{ "filename" : "mac-32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" },
|
||||
{ "filename" : "mac-128@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" },
|
||||
{ "filename" : "mac-128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" },
|
||||
{ "filename" : "mac-256@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
|
||||
{ "filename" : "mac-256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
|
||||
{ "filename" : "mac-512@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
|
||||
{ "filename" : "mac-512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 1006 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 776 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||
<array>
|
||||
<string>NDEF</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,734 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
B10000000000000000000001 /* IDPGlobalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* IDPGlobalApp.swift */; };
|
||||
B10000000000000000000002 /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002 /* AppViewModel.swift */; };
|
||||
B10000000000000000000003 /* AppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000003 /* AppModels.swift */; };
|
||||
B10000000000000000000004 /* MockIDPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004 /* MockIDPService.swift */; };
|
||||
B10000000000000000000005 /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000005 /* NotificationCoordinator.swift */; };
|
||||
B10000000000000000000006 /* LoginRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000006 /* LoginRootView.swift */; };
|
||||
B10000000000000000000007 /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000007 /* QRScannerView.swift */; };
|
||||
B10000000000000000000008 /* HomeRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000008 /* HomeRootView.swift */; };
|
||||
B10000000000000000000009 /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002 /* AppViewModel.swift */; };
|
||||
B1000000000000000000000A /* AppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000003 /* AppModels.swift */; };
|
||||
B1000000000000000000000B /* MockIDPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004 /* MockIDPService.swift */; };
|
||||
B1000000000000000000000C /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000005 /* NotificationCoordinator.swift */; };
|
||||
B1000000000000000000000D /* IDPGlobalWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000B /* IDPGlobalWatchApp.swift */; };
|
||||
B1000000000000000000000E /* WatchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000C /* WatchRootView.swift */; };
|
||||
B1000000000000000000000F /* IDPGlobalWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000A /* IDPGlobalWatch.app */; platformFilter = ios; };
|
||||
B10000000000000000000010 /* NFCPairingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000D /* NFCPairingView.swift */; };
|
||||
B10000000000000000000011 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000E /* Assets.xcassets */; };
|
||||
B10000000000000000000012 /* AppComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000F /* AppComponents.swift */; };
|
||||
B10000000000000000000013 /* AppComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000F /* AppComponents.swift */; };
|
||||
B10000000000000000000014 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000010 /* AppTheme.swift */; };
|
||||
B10000000000000000000015 /* AppStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000011 /* AppStateStore.swift */; };
|
||||
B10000000000000000000016 /* OneTimePasscodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000012 /* OneTimePasscodeGenerator.swift */; };
|
||||
B10000000000000000000017 /* PairingPayloadParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000013 /* PairingPayloadParser.swift */; };
|
||||
B10000000000000000000018 /* HomePanels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000014 /* HomePanels.swift */; };
|
||||
B10000000000000000000019 /* HomeCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000015 /* HomeCards.swift */; };
|
||||
B1000000000000000000001A /* HomeSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000016 /* HomeSheets.swift */; };
|
||||
B1000000000000000000001B /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000010 /* AppTheme.swift */; };
|
||||
B1000000000000000000001C /* AppStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000011 /* AppStateStore.swift */; };
|
||||
B1000000000000000000001D /* PairingPayloadParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000013 /* PairingPayloadParser.swift */; };
|
||||
B1000000000000000000001E /* PairingPayloadParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000017 /* PairingPayloadParserTests.swift */; };
|
||||
B1000000000000000000001F /* OneTimePasscodeGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */; };
|
||||
B10000000000000000000020 /* AppViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000019 /* AppViewModelTests.swift */; };
|
||||
B10000000000000000000021 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B2000000000000000000001B /* XCTest.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
B90000000000000000000001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = B60000000000000000000001 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = B50000000000000000000002;
|
||||
remoteInfo = IDPGlobalWatch;
|
||||
};
|
||||
B90000000000000000000003 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = B60000000000000000000001 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = B50000000000000000000001;
|
||||
remoteInfo = IDPGlobal;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
B30000000000000000000004 /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
B1000000000000000000000F /* IDPGlobalWatch.app in Embed Watch Content */,
|
||||
);
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
B20000000000000000000001 /* IDPGlobalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDPGlobalApp.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000002 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000003 /* AppModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModels.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000004 /* MockIDPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIDPService.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000005 /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000006 /* LoginRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginRootView.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000007 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000008 /* HomeRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeRootView.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000009 /* IDPGlobal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IDPGlobal.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B2000000000000000000000A /* IDPGlobalWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IDPGlobalWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B2000000000000000000000B /* IDPGlobalWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDPGlobalWatchApp.swift; sourceTree = "<group>"; };
|
||||
B2000000000000000000000C /* WatchRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchRootView.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>"; };
|
||||
B2000000000000000000000F /* AppComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppComponents.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000010 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000011 /* AppStateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateStore.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000012 /* OneTimePasscodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneTimePasscodeGenerator.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000013 /* PairingPayloadParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingPayloadParser.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000014 /* HomePanels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePanels.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000015 /* HomeCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCards.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000016 /* HomeSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSheets.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000017 /* PairingPayloadParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingPayloadParserTests.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneTimePasscodeGeneratorTests.swift; sourceTree = "<group>"; };
|
||||
B20000000000000000000019 /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = "<group>"; };
|
||||
B2000000000000000000001A /* IDPGlobalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IDPGlobalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B2000000000000000000001B /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
B30000000000000000000001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B30000000000000000000005 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B30000000000000000000009 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B10000000000000000000021 /* XCTest.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
B40000000000000000000001 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B40000000000000000000002 /* IDPGlobal */,
|
||||
B40000000000000000000009 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B40000000000000000000002 /* IDPGlobal */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B2000000000000000000000E /* Assets.xcassets */,
|
||||
B40000000000000000000003 /* Sources */,
|
||||
B4000000000000000000000C /* WatchApp */,
|
||||
B4000000000000000000000F /* Tests */,
|
||||
);
|
||||
name = IDPGlobal;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B40000000000000000000003 /* Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B40000000000000000000004 /* App */,
|
||||
B40000000000000000000005 /* Core */,
|
||||
B40000000000000000000008 /* Features */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B40000000000000000000004 /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B20000000000000000000010 /* AppTheme.swift */,
|
||||
B2000000000000000000000F /* AppComponents.swift */,
|
||||
B20000000000000000000001 /* IDPGlobalApp.swift */,
|
||||
B20000000000000000000002 /* AppViewModel.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B40000000000000000000005 /* Core */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B40000000000000000000006 /* Models */,
|
||||
B40000000000000000000007 /* Services */,
|
||||
);
|
||||
path = Core;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B40000000000000000000006 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B20000000000000000000003 /* AppModels.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B40000000000000000000007 /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B20000000000000000000011 /* AppStateStore.swift */,
|
||||
B20000000000000000000004 /* MockIDPService.swift */,
|
||||
B20000000000000000000012 /* OneTimePasscodeGenerator.swift */,
|
||||
B20000000000000000000005 /* NotificationCoordinator.swift */,
|
||||
B20000000000000000000013 /* PairingPayloadParser.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B40000000000000000000008 /* Features */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B4000000000000000000000A /* Auth */,
|
||||
B4000000000000000000000B /* Home */,
|
||||
);
|
||||
path = Features;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B40000000000000000000009 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B20000000000000000000009 /* IDPGlobal.app */,
|
||||
B2000000000000000000000A /* IDPGlobalWatch.app */,
|
||||
B2000000000000000000001A /* IDPGlobalTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4000000000000000000000A /* Auth */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B20000000000000000000006 /* LoginRootView.swift */,
|
||||
B2000000000000000000000D /* NFCPairingView.swift */,
|
||||
B20000000000000000000007 /* QRScannerView.swift */,
|
||||
);
|
||||
path = Auth;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4000000000000000000000B /* Home */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B20000000000000000000015 /* HomeCards.swift */,
|
||||
B20000000000000000000014 /* HomePanels.swift */,
|
||||
B20000000000000000000008 /* HomeRootView.swift */,
|
||||
B20000000000000000000016 /* HomeSheets.swift */,
|
||||
);
|
||||
path = Home;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4000000000000000000000C /* WatchApp */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B4000000000000000000000D /* App */,
|
||||
B4000000000000000000000E /* Features */,
|
||||
);
|
||||
path = WatchApp;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4000000000000000000000D /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B2000000000000000000000B /* IDPGlobalWatchApp.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4000000000000000000000E /* Features */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B2000000000000000000000C /* WatchRootView.swift */,
|
||||
);
|
||||
path = Features;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B4000000000000000000000F /* Tests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B20000000000000000000019 /* AppViewModelTests.swift */,
|
||||
B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */,
|
||||
B20000000000000000000017 /* PairingPayloadParserTests.swift */,
|
||||
);
|
||||
path = Tests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
B50000000000000000000001 /* IDPGlobal */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = B70000000000000000000002 /* Build configuration list for PBXNativeTarget "IDPGlobal" */;
|
||||
buildPhases = (
|
||||
B30000000000000000000002 /* Sources */,
|
||||
B30000000000000000000001 /* Frameworks */,
|
||||
B30000000000000000000003 /* Resources */,
|
||||
B30000000000000000000004 /* Embed Watch Content */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
B90000000000000000000002 /* PBXTargetDependency */,
|
||||
);
|
||||
name = IDPGlobal;
|
||||
productName = IDPGlobal;
|
||||
productReference = B20000000000000000000009 /* IDPGlobal.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
B50000000000000000000002 /* IDPGlobalWatch */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = B70000000000000000000003 /* Build configuration list for PBXNativeTarget "IDPGlobalWatch" */;
|
||||
buildPhases = (
|
||||
B30000000000000000000007 /* Sources */,
|
||||
B30000000000000000000005 /* Frameworks */,
|
||||
B30000000000000000000006 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = IDPGlobalWatch;
|
||||
productName = IDPGlobalWatch;
|
||||
productReference = B2000000000000000000000A /* IDPGlobalWatch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
B50000000000000000000003 /* IDPGlobalTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = B70000000000000000000004 /* Build configuration list for PBXNativeTarget "IDPGlobalTests" */;
|
||||
buildPhases = (
|
||||
B30000000000000000000008 /* Sources */,
|
||||
B30000000000000000000009 /* Frameworks */,
|
||||
B3000000000000000000000A /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
B90000000000000000000004 /* PBXTargetDependency */,
|
||||
);
|
||||
name = IDPGlobalTests;
|
||||
productName = IDPGlobalTests;
|
||||
productReference = B2000000000000000000001A /* IDPGlobalTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
B60000000000000000000001 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2600;
|
||||
LastUpgradeCheck = 2600;
|
||||
TargetAttributes = {
|
||||
B50000000000000000000001 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
B50000000000000000000002 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
B50000000000000000000003 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
TestTargetID = B50000000000000000000001;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = B40000000000000000000001;
|
||||
productRefGroup = B40000000000000000000009 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
B50000000000000000000001 /* IDPGlobal */,
|
||||
B50000000000000000000002 /* IDPGlobalWatch */,
|
||||
B50000000000000000000003 /* IDPGlobalTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
B30000000000000000000003 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B10000000000000000000011 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B30000000000000000000006 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B3000000000000000000000A /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
B30000000000000000000002 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B10000000000000000000015 /* AppStateStore.swift in Sources */,
|
||||
B10000000000000000000012 /* AppComponents.swift in Sources */,
|
||||
B10000000000000000000014 /* AppTheme.swift in Sources */,
|
||||
B10000000000000000000002 /* AppViewModel.swift in Sources */,
|
||||
B10000000000000000000019 /* HomeCards.swift in Sources */,
|
||||
B10000000000000000000018 /* HomePanels.swift in Sources */,
|
||||
B10000000000000000000008 /* HomeRootView.swift in Sources */,
|
||||
B1000000000000000000001A /* HomeSheets.swift in Sources */,
|
||||
B10000000000000000000001 /* IDPGlobalApp.swift in Sources */,
|
||||
B10000000000000000000006 /* LoginRootView.swift in Sources */,
|
||||
B10000000000000000000004 /* MockIDPService.swift in Sources */,
|
||||
B10000000000000000000010 /* NFCPairingView.swift in Sources */,
|
||||
B10000000000000000000005 /* NotificationCoordinator.swift in Sources */,
|
||||
B10000000000000000000016 /* OneTimePasscodeGenerator.swift in Sources */,
|
||||
B10000000000000000000003 /* AppModels.swift in Sources */,
|
||||
B10000000000000000000017 /* PairingPayloadParser.swift in Sources */,
|
||||
B10000000000000000000007 /* QRScannerView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B30000000000000000000007 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B1000000000000000000001C /* AppStateStore.swift in Sources */,
|
||||
B10000000000000000000013 /* AppComponents.swift in Sources */,
|
||||
B1000000000000000000001B /* AppTheme.swift in Sources */,
|
||||
B10000000000000000000009 /* AppViewModel.swift in Sources */,
|
||||
B1000000000000000000000A /* AppModels.swift in Sources */,
|
||||
B1000000000000000000000D /* IDPGlobalWatchApp.swift in Sources */,
|
||||
B1000000000000000000000B /* MockIDPService.swift in Sources */,
|
||||
B1000000000000000000000C /* NotificationCoordinator.swift in Sources */,
|
||||
B1000000000000000000001D /* PairingPayloadParser.swift in Sources */,
|
||||
B1000000000000000000000E /* WatchRootView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B30000000000000000000008 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B10000000000000000000020 /* AppViewModelTests.swift in Sources */,
|
||||
B1000000000000000000001F /* OneTimePasscodeGeneratorTests.swift in Sources */,
|
||||
B1000000000000000000001E /* PairingPayloadParserTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
B90000000000000000000002 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
platformFilter = ios;
|
||||
target = B50000000000000000000002 /* IDPGlobalWatch */;
|
||||
targetProxy = B90000000000000000000001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
B90000000000000000000004 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = B50000000000000000000001 /* IDPGlobal */;
|
||||
targetProxy = B90000000000000000000003 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
B80000000000000000000001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
SDKROOT = auto;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B80000000000000000000002 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
SDKROOT = auto;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B80000000000000000000003 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = IDPGlobal.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "idp.global";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "Scan pairing QR codes from the idp.global web portal.";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Attach a signed GPS position to NFC authentication before binding this device.";
|
||||
INFOPLIST_KEY_NFCReaderUsageDescription = "Read an idp.global pairing tag to bind this device.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBSERVATION_ENABLED = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B80000000000000000000004 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = IDPGlobal.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "idp.global";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "Scan pairing QR codes from the idp.global web portal.";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Attach a signed GPS position to NFC authentication before binding this device.";
|
||||
INFOPLIST_KEY_NFCReaderUsageDescription = "Read an idp.global pairing tag to bind this device.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBSERVATION_ENABLED = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B80000000000000000000005 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "idp.global Watch";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = global.idp.app;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBSERVATION_ENABLED = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B80000000000000000000006 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "idp.global Watch";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = global.idp.app;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBSERVATION_ENABLED = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B80000000000000000000007 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = macosx;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal";
|
||||
TEST_TARGET_NAME = IDPGlobal;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B80000000000000000000008 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = macosx;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal";
|
||||
TEST_TARGET_NAME = IDPGlobal;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B80000000000000000000001 /* Debug */,
|
||||
B80000000000000000000002 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
B70000000000000000000002 /* Build configuration list for PBXNativeTarget "IDPGlobal" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B80000000000000000000003 /* Debug */,
|
||||
B80000000000000000000004 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
B70000000000000000000003 /* Build configuration list for PBXNativeTarget "IDPGlobalWatch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B80000000000000000000005 /* Debug */,
|
||||
B80000000000000000000006 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
B70000000000000000000004 /* Build configuration list for PBXNativeTarget "IDPGlobalTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B80000000000000000000007 /* Debug */,
|
||||
B80000000000000000000008 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = B60000000000000000000001 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B50000000000000000000001"
|
||||
BuildableName = "IDPGlobal.app"
|
||||
BlueprintName = "IDPGlobal"
|
||||
ReferencedContainer = "container:IDPGlobal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B50000000000000000000001"
|
||||
BuildableName = "IDPGlobal.app"
|
||||
BlueprintName = "IDPGlobal"
|
||||
ReferencedContainer = "container:IDPGlobal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B50000000000000000000003"
|
||||
BuildableName = "IDPGlobalTests.xctest"
|
||||
BlueprintName = "IDPGlobalTests"
|
||||
ReferencedContainer = "container:IDPGlobal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B50000000000000000000001"
|
||||
BuildableName = "IDPGlobal.app"
|
||||
BlueprintName = "IDPGlobal"
|
||||
ReferencedContainer = "container:IDPGlobal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B50000000000000000000001"
|
||||
BuildableName = "IDPGlobal.app"
|
||||
BlueprintName = "IDPGlobal"
|
||||
ReferencedContainer = "container:IDPGlobal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,233 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppSectionTitle: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppNotice: View {
|
||||
let message: String
|
||||
var tone: Color = AppTheme.accent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.footnote.weight(.bold))
|
||||
.foregroundStyle(tone)
|
||||
Text(message)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(tone.opacity(0.08), in: Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppStatusTag: View {
|
||||
let title: String
|
||||
var tone: Color = AppTheme.accent
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(tone.opacity(0.12), in: Capsule())
|
||||
.foregroundStyle(tone)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppKeyValue: View {
|
||||
let label: String
|
||||
let value: String
|
||||
var monospaced: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label.uppercased())
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(monospaced ? .subheadline.monospaced() : .subheadline.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppMetric: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title.uppercased())
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(value)
|
||||
.font(.title3.weight(.bold))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppTextSurface: View {
|
||||
let text: String
|
||||
var monospaced: Bool = false
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
#if os(watchOS)
|
||||
Text(text)
|
||||
.font(monospaced ? .body.monospaced() : .body)
|
||||
#else
|
||||
Text(text)
|
||||
.font(monospaced ? .body.monospaced() : .body)
|
||||
.textSelection(.enabled)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct AppTextEditorField: View {
|
||||
@Binding var text: String
|
||||
var minHeight: CGFloat = 120
|
||||
var monospaced: Bool = true
|
||||
|
||||
var body: some View {
|
||||
editor
|
||||
.frame(minHeight: minHeight)
|
||||
.background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(AppTheme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var editor: some View {
|
||||
#if os(watchOS)
|
||||
Text(text)
|
||||
.font(monospaced ? .body.monospaced() : .body)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
#else
|
||||
TextEditor(text: $text)
|
||||
.font(monospaced ? .body.monospaced() : .body)
|
||||
.scrollContentBackground(.hidden)
|
||||
.autocorrectionDisabled()
|
||||
.padding(14)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct AppActionRow: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
let systemImage: String
|
||||
var tone: Color = AppTheme.accent
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(tone)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.footnote.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppActionTile: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
var tone: Color = AppTheme.accent
|
||||
var isBusy: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .center) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(tone.opacity(0.10))
|
||||
.frame(width: 38, height: 38)
|
||||
|
||||
if isBusy {
|
||||
ProgressView()
|
||||
.tint(tone)
|
||||
} else {
|
||||
Image(systemName: systemImage)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(tone)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, minHeight: 92, alignment: .topLeading)
|
||||
.appSurface(radius: 22)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AppViewModel: ObservableObject {
|
||||
@Published var suggestedPairingPayload = ""
|
||||
@Published var manualPairingPayload = ""
|
||||
@Published var session: AuthSession?
|
||||
@Published var profile: MemberProfile?
|
||||
@Published var requests: [ApprovalRequest] = []
|
||||
@Published var notifications: [AppNotification] = []
|
||||
@Published var notificationPermission: NotificationPermissionState = .unknown
|
||||
@Published var selectedSection: AppSection = .overview
|
||||
@Published var isBootstrapping = false
|
||||
@Published var isAuthenticating = false
|
||||
@Published var isIdentifying = false
|
||||
@Published var isRefreshing = false
|
||||
@Published var isNotificationCenterPresented = false
|
||||
@Published var activeRequestID: ApprovalRequest.ID?
|
||||
@Published var isScannerPresented = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private var hasBootstrapped = false
|
||||
private let service: IDPServicing
|
||||
private let notificationCoordinator: NotificationCoordinating
|
||||
private let appStateStore: AppStateStoring
|
||||
private let launchArguments: [String]
|
||||
|
||||
private var preferredLaunchSection: AppSection? {
|
||||
guard let argument = launchArguments.first(where: { $0.hasPrefix("--mock-section=") }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let rawValue = String(argument.dropFirst("--mock-section=".count))
|
||||
if rawValue == "notifications" {
|
||||
return .activity
|
||||
}
|
||||
return AppSection(rawValue: rawValue)
|
||||
}
|
||||
|
||||
init(
|
||||
service: IDPServicing = MockIDPService(),
|
||||
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
|
||||
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
|
||||
launchArguments: [String] = ProcessInfo.processInfo.arguments
|
||||
) {
|
||||
self.service = service
|
||||
self.notificationCoordinator = notificationCoordinator
|
||||
self.appStateStore = appStateStore
|
||||
self.launchArguments = launchArguments
|
||||
}
|
||||
|
||||
var pendingRequests: [ApprovalRequest] {
|
||||
requests
|
||||
.filter { $0.status == .pending }
|
||||
.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
var handledRequests: [ApprovalRequest] {
|
||||
requests
|
||||
.filter { $0.status != .pending }
|
||||
.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
var unreadNotificationCount: Int {
|
||||
notifications.filter(\.isUnread).count
|
||||
}
|
||||
|
||||
var elevatedPendingCount: Int {
|
||||
pendingRequests.filter { $0.risk == .elevated }.count
|
||||
}
|
||||
|
||||
var latestNotification: AppNotification? {
|
||||
notifications.first
|
||||
}
|
||||
|
||||
var pairedDeviceSummary: String {
|
||||
session?.deviceName ?? "No active device"
|
||||
}
|
||||
|
||||
func bootstrap() async {
|
||||
guard !hasBootstrapped else { return }
|
||||
hasBootstrapped = true
|
||||
|
||||
restorePersistedState()
|
||||
|
||||
isBootstrapping = true
|
||||
defer { isBootstrapping = false }
|
||||
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
|
||||
do {
|
||||
let bootstrap = try await service.bootstrap()
|
||||
suggestedPairingPayload = bootstrap.suggestedPairingPayload
|
||||
manualPairingPayload = session?.pairingCode ?? bootstrap.suggestedPairingPayload
|
||||
|
||||
if launchArguments.contains("--mock-auto-pair"),
|
||||
session == nil {
|
||||
await signIn(with: bootstrap.suggestedPairingPayload, transport: .preview)
|
||||
|
||||
if let preferredLaunchSection {
|
||||
selectedSection = preferredLaunchSection
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if session == nil {
|
||||
errorMessage = "Unable to prepare the app."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func signInWithManualPayload() async {
|
||||
await signIn(with: manualPairingPayload, transport: .manual)
|
||||
}
|
||||
|
||||
func signInWithSuggestedPayload() async {
|
||||
manualPairingPayload = suggestedPairingPayload
|
||||
await signIn(with: suggestedPairingPayload, transport: .preview)
|
||||
}
|
||||
|
||||
func signIn(
|
||||
with payload: String,
|
||||
transport: PairingTransport = .manual,
|
||||
signedGPSPosition: SignedGPSPosition? = nil
|
||||
) async {
|
||||
await signIn(
|
||||
with: PairingAuthenticationRequest(
|
||||
pairingPayload: payload,
|
||||
transport: transport,
|
||||
signedGPSPosition: signedGPSPosition
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func signIn(with request: PairingAuthenticationRequest) async {
|
||||
let trimmed = request.pairingPayload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
errorMessage = "Paste or scan a pairing payload first."
|
||||
return
|
||||
}
|
||||
|
||||
let normalizedRequest = PairingAuthenticationRequest(
|
||||
pairingPayload: trimmed,
|
||||
transport: request.transport,
|
||||
signedGPSPosition: request.signedGPSPosition
|
||||
)
|
||||
|
||||
isAuthenticating = true
|
||||
defer { isAuthenticating = false }
|
||||
|
||||
do {
|
||||
let result = try await service.signIn(with: normalizedRequest)
|
||||
session = result.session
|
||||
apply(snapshot: result.snapshot)
|
||||
persistCurrentState()
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
selectedSection = .overview
|
||||
errorMessage = nil
|
||||
isScannerPresented = false
|
||||
} catch let error as AppError {
|
||||
errorMessage = error.errorDescription
|
||||
} catch {
|
||||
errorMessage = "Unable to complete sign-in."
|
||||
}
|
||||
}
|
||||
|
||||
func identifyWithNFC(_ request: PairingAuthenticationRequest) async {
|
||||
guard session != nil else {
|
||||
errorMessage = "Set up this passport before proving your identity with NFC."
|
||||
return
|
||||
}
|
||||
|
||||
await submitIdentityProof(
|
||||
payload: request.pairingPayload,
|
||||
transport: .nfc,
|
||||
signedGPSPosition: request.signedGPSPosition
|
||||
)
|
||||
}
|
||||
|
||||
func identifyWithPayload(_ payload: String, transport: PairingTransport = .qr) async {
|
||||
guard session != nil else {
|
||||
errorMessage = "Set up this passport before proving your identity."
|
||||
return
|
||||
}
|
||||
|
||||
await submitIdentityProof(payload: payload, transport: transport)
|
||||
}
|
||||
|
||||
private func submitIdentityProof(
|
||||
payload: String,
|
||||
transport: PairingTransport,
|
||||
signedGPSPosition: SignedGPSPosition? = nil
|
||||
) async {
|
||||
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
errorMessage = "The provided idp.global payload was empty."
|
||||
return
|
||||
}
|
||||
|
||||
let normalizedRequest = PairingAuthenticationRequest(
|
||||
pairingPayload: trimmed,
|
||||
transport: transport,
|
||||
signedGPSPosition: signedGPSPosition
|
||||
)
|
||||
|
||||
isIdentifying = true
|
||||
defer { isIdentifying = false }
|
||||
|
||||
do {
|
||||
let snapshot = try await service.identify(with: normalizedRequest)
|
||||
apply(snapshot: snapshot)
|
||||
persistCurrentState()
|
||||
errorMessage = nil
|
||||
isScannerPresented = false
|
||||
} catch let error as AppError {
|
||||
errorMessage = error.errorDescription
|
||||
} catch {
|
||||
errorMessage = "Unable to complete identity proof."
|
||||
}
|
||||
}
|
||||
|
||||
func refreshDashboard() async {
|
||||
guard session != nil else { return }
|
||||
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
|
||||
do {
|
||||
let snapshot = try await service.refreshDashboard()
|
||||
apply(snapshot: snapshot)
|
||||
persistCurrentState()
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to refresh the dashboard."
|
||||
}
|
||||
}
|
||||
|
||||
func approve(_ request: ApprovalRequest) async {
|
||||
await mutateRequest(request, approve: true)
|
||||
}
|
||||
|
||||
func reject(_ request: ApprovalRequest) async {
|
||||
await mutateRequest(request, approve: false)
|
||||
}
|
||||
|
||||
func simulateIncomingRequest() async {
|
||||
guard session != nil else { return }
|
||||
|
||||
do {
|
||||
let snapshot = try await service.simulateIncomingRequest()
|
||||
apply(snapshot: snapshot)
|
||||
persistCurrentState()
|
||||
selectedSection = .requests
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to create a mock identity check right now."
|
||||
}
|
||||
}
|
||||
|
||||
func requestNotificationAccess() async {
|
||||
do {
|
||||
notificationPermission = try await notificationCoordinator.requestAuthorization()
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to update notification permission."
|
||||
}
|
||||
}
|
||||
|
||||
func sendTestNotification() async {
|
||||
do {
|
||||
try await notificationCoordinator.scheduleTestNotification(
|
||||
title: "idp.global identity proof requested",
|
||||
body: "A mock identity proof request is waiting in the app."
|
||||
)
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to schedule a test notification."
|
||||
}
|
||||
}
|
||||
|
||||
func markNotificationRead(_ notification: AppNotification) async {
|
||||
do {
|
||||
let snapshot = try await service.markNotificationRead(id: notification.id)
|
||||
apply(snapshot: snapshot)
|
||||
persistCurrentState()
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to update the notification."
|
||||
}
|
||||
}
|
||||
|
||||
func signOut() {
|
||||
appStateStore.clear()
|
||||
session = nil
|
||||
profile = nil
|
||||
requests = []
|
||||
notifications = []
|
||||
selectedSection = .overview
|
||||
manualPairingPayload = suggestedPairingPayload
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
|
||||
guard session != nil else { return }
|
||||
|
||||
activeRequestID = request.id
|
||||
defer { activeRequestID = nil }
|
||||
|
||||
do {
|
||||
let snapshot = approve
|
||||
? try await service.approveRequest(id: request.id)
|
||||
: try await service.rejectRequest(id: request.id)
|
||||
apply(snapshot: snapshot)
|
||||
persistCurrentState()
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to update the identity check."
|
||||
}
|
||||
}
|
||||
|
||||
private func restorePersistedState() {
|
||||
guard let state = appStateStore.load() else {
|
||||
return
|
||||
}
|
||||
|
||||
session = state.session
|
||||
manualPairingPayload = state.session.pairingCode
|
||||
apply(
|
||||
snapshot: DashboardSnapshot(
|
||||
profile: state.profile,
|
||||
requests: state.requests,
|
||||
notifications: state.notifications
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func persistCurrentState() {
|
||||
guard let session, let profile else {
|
||||
appStateStore.clear()
|
||||
return
|
||||
}
|
||||
|
||||
appStateStore.save(
|
||||
PersistedAppState(
|
||||
session: session,
|
||||
profile: profile,
|
||||
requests: requests,
|
||||
notifications: notifications
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func apply(snapshot: DashboardSnapshot) {
|
||||
profile = snapshot.profile
|
||||
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
|
||||
notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct IDPGlobalApp: App {
|
||||
@StateObject private var model = AppViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView(model: model)
|
||||
.tint(AppTheme.accent)
|
||||
.task {
|
||||
await model.bootstrap()
|
||||
}
|
||||
.alert("Something went wrong", isPresented: errorPresented) {
|
||||
Button("OK") {
|
||||
model.errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.defaultSize(width: 1380, height: 920)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var errorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
model.errorMessage = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RootView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.session == nil {
|
||||
LoginRootView(model: model)
|
||||
} else {
|
||||
HomeRootView(model: model)
|
||||
}
|
||||
}
|
||||
.background {
|
||||
AppBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable {
|
||||
case overview
|
||||
case requests
|
||||
case activity
|
||||
case account
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .overview: "Passport"
|
||||
case .requests: "Requests"
|
||||
case .activity: "Activity"
|
||||
case .account: "Account"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .overview: "person.crop.square.fill"
|
||||
case .requests: "checklist.checked"
|
||||
case .activity: "clock.arrow.trianglehead.counterclockwise.rotate.90"
|
||||
case .account: "person.crop.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NotificationPermissionState: String, CaseIterable, Identifiable, Codable {
|
||||
case unknown
|
||||
case allowed
|
||||
case provisional
|
||||
case denied
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .unknown: "Not Asked Yet"
|
||||
case .allowed: "Enabled"
|
||||
case .provisional: "Delivered Quietly"
|
||||
case .denied: "Disabled"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .unknown: "bell"
|
||||
case .allowed: "bell.badge.fill"
|
||||
case .provisional: "bell.badge"
|
||||
case .denied: "bell.slash.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .unknown:
|
||||
"The app has not asked for notification delivery yet."
|
||||
case .allowed:
|
||||
"Identity proof alerts can break through immediately when a check arrives."
|
||||
case .provisional:
|
||||
"Identity proof alerts can be delivered quietly until the user promotes them."
|
||||
case .denied:
|
||||
"Identity proof events stay in-app until the user re-enables notifications."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BootstrapContext {
|
||||
let suggestedPairingPayload: String
|
||||
}
|
||||
|
||||
enum PairingTransport: String, Hashable, Codable {
|
||||
case qr
|
||||
case nfc
|
||||
case manual
|
||||
case preview
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .qr:
|
||||
"QR"
|
||||
case .nfc:
|
||||
"NFC"
|
||||
case .manual:
|
||||
"Manual"
|
||||
case .preview:
|
||||
"Preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PairingAuthenticationRequest: Hashable {
|
||||
let pairingPayload: String
|
||||
let transport: PairingTransport
|
||||
let signedGPSPosition: SignedGPSPosition?
|
||||
}
|
||||
|
||||
struct SignedGPSPosition: Hashable, Codable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let horizontalAccuracyMeters: Double
|
||||
let capturedAt: Date
|
||||
let signatureBase64: String
|
||||
let publicKeyBase64: String
|
||||
|
||||
init(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
horizontalAccuracyMeters: Double,
|
||||
capturedAt: Date,
|
||||
signatureBase64: String = "",
|
||||
publicKeyBase64: String = ""
|
||||
) {
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.horizontalAccuracyMeters = horizontalAccuracyMeters
|
||||
self.capturedAt = capturedAt
|
||||
self.signatureBase64 = signatureBase64
|
||||
self.publicKeyBase64 = publicKeyBase64
|
||||
}
|
||||
|
||||
var coordinateSummary: String {
|
||||
"\(Self.normalized(latitude, precision: 5)), \(Self.normalized(longitude, precision: 5))"
|
||||
}
|
||||
|
||||
var accuracySummary: String {
|
||||
"±\(Int(horizontalAccuracyMeters.rounded())) m"
|
||||
}
|
||||
|
||||
func signingPayload(for pairingPayload: String) -> Data {
|
||||
let lines = [
|
||||
"payload=\(pairingPayload)",
|
||||
"latitude=\(Self.normalized(latitude, precision: 6))",
|
||||
"longitude=\(Self.normalized(longitude, precision: 6))",
|
||||
"accuracy=\(Self.normalized(horizontalAccuracyMeters, precision: 2))",
|
||||
"captured_at=\(Self.timestampFormatter.string(from: capturedAt))"
|
||||
]
|
||||
return Data(lines.joined(separator: "\n").utf8)
|
||||
}
|
||||
|
||||
func verified(for pairingPayload: String) -> Bool {
|
||||
guard let signatureData = Data(base64Encoded: signatureBase64),
|
||||
let publicKeyData = Data(base64Encoded: publicKeyBase64),
|
||||
let publicKey = try? P256.Signing.PublicKey(x963Representation: publicKeyData),
|
||||
let signature = try? P256.Signing.ECDSASignature(derRepresentation: signatureData) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return publicKey.isValidSignature(signature, for: signingPayload(for: pairingPayload))
|
||||
}
|
||||
|
||||
func signed(signatureData: Data, publicKeyData: Data) -> SignedGPSPosition {
|
||||
SignedGPSPosition(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
horizontalAccuracyMeters: horizontalAccuracyMeters,
|
||||
capturedAt: capturedAt,
|
||||
signatureBase64: signatureData.base64EncodedString(),
|
||||
publicKeyBase64: publicKeyData.base64EncodedString()
|
||||
)
|
||||
}
|
||||
|
||||
private static let timestampFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static func normalized(_ value: Double, precision: Int) -> String {
|
||||
String(format: "%.\(precision)f", locale: Locale(identifier: "en_US_POSIX"), value)
|
||||
}
|
||||
}
|
||||
|
||||
struct DashboardSnapshot {
|
||||
let profile: MemberProfile
|
||||
let requests: [ApprovalRequest]
|
||||
let notifications: [AppNotification]
|
||||
}
|
||||
|
||||
struct SignInResult {
|
||||
let session: AuthSession
|
||||
let snapshot: DashboardSnapshot
|
||||
}
|
||||
|
||||
struct MemberProfile: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let handle: String
|
||||
let organization: String
|
||||
let deviceCount: Int
|
||||
let recoverySummary: String
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
handle: String,
|
||||
organization: String,
|
||||
deviceCount: Int,
|
||||
recoverySummary: String
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.handle = handle
|
||||
self.organization = organization
|
||||
self.deviceCount = deviceCount
|
||||
self.recoverySummary = recoverySummary
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthSession: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let deviceName: String
|
||||
let originHost: String
|
||||
let pairedAt: Date
|
||||
let tokenPreview: String
|
||||
let pairingCode: String
|
||||
let pairingTransport: PairingTransport
|
||||
let signedGPSPosition: SignedGPSPosition?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
deviceName: String,
|
||||
originHost: String,
|
||||
pairedAt: Date,
|
||||
tokenPreview: String,
|
||||
pairingCode: String,
|
||||
pairingTransport: PairingTransport = .manual,
|
||||
signedGPSPosition: SignedGPSPosition? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.deviceName = deviceName
|
||||
self.originHost = originHost
|
||||
self.pairedAt = pairedAt
|
||||
self.tokenPreview = tokenPreview
|
||||
self.pairingCode = pairingCode
|
||||
self.pairingTransport = pairingTransport
|
||||
self.signedGPSPosition = signedGPSPosition
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalRequestKind: String, CaseIterable, Hashable, Codable {
|
||||
case signIn
|
||||
case accessGrant
|
||||
case elevatedAction
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .signIn: "Identity Check"
|
||||
case .accessGrant: "Strong Proof"
|
||||
case .elevatedAction: "Sensitive Proof"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .signIn: "qrcode.viewfinder"
|
||||
case .accessGrant: "person.badge.shield.checkmark.fill"
|
||||
case .elevatedAction: "shield.checkered"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalRisk: String, Hashable, Codable {
|
||||
case routine
|
||||
case elevated
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .routine: "Routine"
|
||||
case .elevated: "Elevated"
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .routine:
|
||||
"A familiar identity proof for a normal sign-in or check."
|
||||
case .elevated:
|
||||
"A higher-assurance identity proof for a sensitive check."
|
||||
}
|
||||
}
|
||||
|
||||
var guidance: String {
|
||||
switch self {
|
||||
case .routine:
|
||||
"Review the origin and continue only if it matches the proof you started."
|
||||
case .elevated:
|
||||
"Only continue if you initiated this proof and trust the origin asking for it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalStatus: String, Hashable, Codable {
|
||||
case pending
|
||||
case approved
|
||||
case rejected
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pending: "Pending"
|
||||
case .approved: "Verified"
|
||||
case .rejected: "Declined"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .pending: "clock.badge"
|
||||
case .approved: "checkmark.circle.fill"
|
||||
case .rejected: "xmark.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ApprovalRequest: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let source: String
|
||||
let createdAt: Date
|
||||
let kind: ApprovalRequestKind
|
||||
let risk: ApprovalRisk
|
||||
let scopes: [String]
|
||||
var status: ApprovalStatus
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
title: String,
|
||||
subtitle: String,
|
||||
source: String,
|
||||
createdAt: Date,
|
||||
kind: ApprovalRequestKind,
|
||||
risk: ApprovalRisk,
|
||||
scopes: [String],
|
||||
status: ApprovalStatus
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.source = source
|
||||
self.createdAt = createdAt
|
||||
self.kind = kind
|
||||
self.risk = risk
|
||||
self.scopes = scopes
|
||||
self.status = status
|
||||
}
|
||||
|
||||
var scopeSummary: String {
|
||||
if scopes.isEmpty {
|
||||
return "No proof details listed"
|
||||
}
|
||||
|
||||
let suffix = scopes.count == 1 ? "" : "s"
|
||||
return "\(scopes.count) proof detail\(suffix)"
|
||||
}
|
||||
|
||||
var trustHeadline: String {
|
||||
switch (kind, risk) {
|
||||
case (.signIn, .routine):
|
||||
"Standard identity proof"
|
||||
case (.signIn, .elevated):
|
||||
"High-assurance sign-in proof"
|
||||
case (.accessGrant, _):
|
||||
"Cross-device identity proof"
|
||||
case (.elevatedAction, _):
|
||||
"Sensitive identity proof"
|
||||
}
|
||||
}
|
||||
|
||||
var trustDetail: String {
|
||||
switch kind {
|
||||
case .signIn:
|
||||
"This request proves that the person at the browser, CLI, or device is really you."
|
||||
case .accessGrant:
|
||||
"This request asks for a stronger proof so the relying party can trust the session with higher confidence."
|
||||
case .elevatedAction:
|
||||
"This request asks for the highest confidence proof before continuing with a sensitive flow."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppNotificationKind: String, Hashable, Codable {
|
||||
case approval
|
||||
case security
|
||||
case system
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .approval: "Proof"
|
||||
case .security: "Security"
|
||||
case .system: "System"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .approval: "checkmark.seal.fill"
|
||||
case .security: "shield.fill"
|
||||
case .system: "sparkles"
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self {
|
||||
case .approval:
|
||||
"Identity proof activity"
|
||||
case .security:
|
||||
"Passport and security posture updates"
|
||||
case .system:
|
||||
"Product and environment status messages"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppNotification: Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let message: String
|
||||
let sentAt: Date
|
||||
let kind: AppNotificationKind
|
||||
var isUnread: Bool
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
title: String,
|
||||
message: String,
|
||||
sentAt: Date,
|
||||
kind: AppNotificationKind,
|
||||
isUnread: Bool
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.sentAt = sentAt
|
||||
self.kind = kind
|
||||
self.isUnread = isUnread
|
||||
}
|
||||
}
|
||||
|
||||
enum AppError: LocalizedError, Equatable {
|
||||
case invalidPairingPayload
|
||||
case missingSignedGPSPosition
|
||||
case invalidSignedGPSPosition
|
||||
case locationPermissionDenied
|
||||
case locationUnavailable
|
||||
case requestNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidPairingPayload:
|
||||
"That idp.global payload is not valid for this action."
|
||||
case .missingSignedGPSPosition:
|
||||
"Tap NFC requires a signed GPS position."
|
||||
case .invalidSignedGPSPosition:
|
||||
"The signed GPS position attached to this NFC proof could not be verified."
|
||||
case .locationPermissionDenied:
|
||||
"Location access is required so Tap NFC can attach a signed GPS position."
|
||||
case .locationUnavailable:
|
||||
"Unable to determine the current GPS position for Tap NFC."
|
||||
case .requestNotFound:
|
||||
"The selected identity check could not be found."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import Foundation
|
||||
|
||||
protocol IDPServicing {
|
||||
func bootstrap() async throws -> BootstrapContext
|
||||
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult
|
||||
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot
|
||||
func refreshDashboard() async throws -> DashboardSnapshot
|
||||
func approveRequest(id: UUID) async throws -> DashboardSnapshot
|
||||
func rejectRequest(id: UUID) async throws -> DashboardSnapshot
|
||||
func simulateIncomingRequest() async throws -> DashboardSnapshot
|
||||
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot
|
||||
}
|
||||
|
||||
actor MockIDPService: IDPServicing {
|
||||
private let profile = MemberProfile(
|
||||
name: "Phil Kunz",
|
||||
handle: "phil@idp.global",
|
||||
organization: "idp.global",
|
||||
deviceCount: 4,
|
||||
recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified."
|
||||
)
|
||||
|
||||
private var requests: [ApprovalRequest] = []
|
||||
private var notifications: [AppNotification] = []
|
||||
|
||||
init() {
|
||||
requests = Self.seedRequests()
|
||||
notifications = Self.seedNotifications()
|
||||
}
|
||||
|
||||
func bootstrap() async throws -> BootstrapContext {
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
return BootstrapContext(
|
||||
suggestedPairingPayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
|
||||
)
|
||||
}
|
||||
|
||||
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
|
||||
try await Task.sleep(for: .milliseconds(260))
|
||||
|
||||
try validateSignedGPSPosition(in: request)
|
||||
let session = try parseSession(from: request)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Passport activated",
|
||||
message: pairingMessage(for: session),
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return SignInResult(
|
||||
session: session,
|
||||
snapshot: snapshot()
|
||||
)
|
||||
}
|
||||
|
||||
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(180))
|
||||
|
||||
try validateSignedGPSPosition(in: request)
|
||||
let context = try PairingPayloadParser.parse(request.pairingPayload)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Identity proof completed",
|
||||
message: identificationMessage(for: context, signedGPSPosition: request.signedGPSPosition),
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func refreshDashboard() async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(180))
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func approveRequest(id: UUID) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
|
||||
guard let index = requests.firstIndex(where: { $0.id == id }) else {
|
||||
throw AppError.requestNotFound
|
||||
}
|
||||
|
||||
requests[index].status = .approved
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Identity verified",
|
||||
message: "\(requests[index].title) was completed for \(requests[index].source).",
|
||||
sentAt: .now,
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func rejectRequest(id: UUID) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
|
||||
guard let index = requests.firstIndex(where: { $0.id == id }) else {
|
||||
throw AppError.requestNotFound
|
||||
}
|
||||
|
||||
requests[index].status = .rejected
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Identity proof declined",
|
||||
message: "\(requests[index].title) was declined before the session could continue.",
|
||||
sentAt: .now,
|
||||
kind: .security,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func simulateIncomingRequest() async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
|
||||
let syntheticRequest = ApprovalRequest(
|
||||
title: "Prove identity for web sign-in",
|
||||
subtitle: "A browser session is asking this passport to prove that it is really you.",
|
||||
source: "auth.idp.global",
|
||||
createdAt: .now,
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["proof:basic", "client:web", "method:qr"],
|
||||
status: .pending
|
||||
)
|
||||
|
||||
requests.insert(syntheticRequest, at: 0)
|
||||
notifications.insert(
|
||||
AppNotification(
|
||||
title: "Fresh identity proof request",
|
||||
message: "A new relying party is waiting for your identity proof.",
|
||||
sentAt: .now,
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot {
|
||||
try await Task.sleep(for: .milliseconds(80))
|
||||
|
||||
guard let index = notifications.firstIndex(where: { $0.id == id }) else {
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
notifications[index].isUnread = false
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
private func snapshot() -> DashboardSnapshot {
|
||||
DashboardSnapshot(
|
||||
profile: profile,
|
||||
requests: requests,
|
||||
notifications: notifications
|
||||
)
|
||||
}
|
||||
|
||||
private func validateSignedGPSPosition(in request: PairingAuthenticationRequest) throws {
|
||||
if request.transport == .nfc,
|
||||
request.signedGPSPosition == nil {
|
||||
throw AppError.missingSignedGPSPosition
|
||||
}
|
||||
|
||||
if let signedGPSPosition = request.signedGPSPosition,
|
||||
!signedGPSPosition.verified(for: request.pairingPayload) {
|
||||
throw AppError.invalidSignedGPSPosition
|
||||
}
|
||||
}
|
||||
|
||||
private func parseSession(from request: PairingAuthenticationRequest) throws -> AuthSession {
|
||||
let context = try PairingPayloadParser.parse(request.pairingPayload)
|
||||
|
||||
return AuthSession(
|
||||
deviceName: context.deviceName,
|
||||
originHost: context.originHost,
|
||||
pairedAt: .now,
|
||||
tokenPreview: context.tokenPreview,
|
||||
pairingCode: request.pairingPayload,
|
||||
pairingTransport: request.transport,
|
||||
signedGPSPosition: request.signedGPSPosition
|
||||
)
|
||||
}
|
||||
|
||||
private func pairingMessage(for session: AuthSession) -> String {
|
||||
let transportSummary: String
|
||||
switch session.pairingTransport {
|
||||
case .qr:
|
||||
transportSummary = "activated via QR"
|
||||
case .nfc:
|
||||
transportSummary = "activated via NFC with a signed GPS position"
|
||||
case .manual:
|
||||
transportSummary = "activated via manual payload"
|
||||
case .preview:
|
||||
transportSummary = "activated via preview payload"
|
||||
}
|
||||
|
||||
if let signedGPSPosition = session.signedGPSPosition {
|
||||
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
|
||||
}
|
||||
|
||||
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost)."
|
||||
}
|
||||
|
||||
private func identificationMessage(for context: PairingPayloadContext, signedGPSPosition: SignedGPSPosition?) -> String {
|
||||
if let signedGPSPosition {
|
||||
return "A signed GPS proof was sent for \(context.deviceName) on \(context.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
|
||||
}
|
||||
|
||||
return "An identity proof was completed for \(context.deviceName) on \(context.originHost)."
|
||||
}
|
||||
|
||||
private static func seedRequests() -> [ApprovalRequest] {
|
||||
[
|
||||
ApprovalRequest(
|
||||
title: "Prove identity for Safari sign-in",
|
||||
subtitle: "The portal wants this passport to prove that the browser session is really you.",
|
||||
source: "code.foss.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 12),
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["proof:basic", "client:web", "origin:trusted"],
|
||||
status: .pending
|
||||
),
|
||||
ApprovalRequest(
|
||||
title: "Prove identity for workstation unlock",
|
||||
subtitle: "Your secure workspace is asking for a stronger proof before it unlocks.",
|
||||
source: "berlin-mbp.idp.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 42),
|
||||
kind: .elevatedAction,
|
||||
risk: .elevated,
|
||||
scopes: ["proof:high", "client:desktop", "presence:required"],
|
||||
status: .pending
|
||||
),
|
||||
ApprovalRequest(
|
||||
title: "Prove identity for CLI session",
|
||||
subtitle: "The CLI session asked for proof earlier and was completed from this passport.",
|
||||
source: "cli.idp.global",
|
||||
createdAt: .now.addingTimeInterval(-60 * 180),
|
||||
kind: .signIn,
|
||||
risk: .routine,
|
||||
scopes: ["proof:basic", "client:cli"],
|
||||
status: .approved
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private static func seedNotifications() -> [AppNotification] {
|
||||
[
|
||||
AppNotification(
|
||||
title: "Two identity checks are waiting",
|
||||
message: "One routine web proof and one stronger workstation proof are waiting for this passport.",
|
||||
sentAt: .now.addingTimeInterval(-60 * 8),
|
||||
kind: .approval,
|
||||
isUnread: true
|
||||
),
|
||||
AppNotification(
|
||||
title: "Recovery health check passed",
|
||||
message: "Backup recovery channels were verified in the last 24 hours.",
|
||||
sentAt: .now.addingTimeInterval(-60 * 95),
|
||||
kind: .system,
|
||||
isUnread: false
|
||||
),
|
||||
AppNotification(
|
||||
title: "Passport quiet hours active",
|
||||
message: "Routine identity checks will be delivered silently until the morning.",
|
||||
sentAt: .now.addingTimeInterval(-60 * 220),
|
||||
kind: .security,
|
||||
isUnread: false
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
protocol NotificationCoordinating {
|
||||
func authorizationStatus() async -> NotificationPermissionState
|
||||
func requestAuthorization() async throws -> NotificationPermissionState
|
||||
func scheduleTestNotification(title: String, body: String) async throws
|
||||
}
|
||||
|
||||
final class NotificationCoordinator: NotificationCoordinating {
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
|
||||
func authorizationStatus() async -> NotificationPermissionState {
|
||||
let settings = await center.notificationSettings()
|
||||
return NotificationPermissionState(settings.authorizationStatus)
|
||||
}
|
||||
|
||||
func requestAuthorization() async throws -> NotificationPermissionState {
|
||||
_ = try await center.requestAuthorization(options: [.alert, .badge, .sound])
|
||||
return await authorizationStatus()
|
||||
}
|
||||
|
||||
func scheduleTestNotification(title: String, body: String) async throws {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: UUID().uuidString,
|
||||
content: content,
|
||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false)
|
||||
)
|
||||
|
||||
try await center.add(request)
|
||||
}
|
||||
}
|
||||
|
||||
private extension NotificationPermissionState {
|
||||
init(_ status: UNAuthorizationStatus) {
|
||||
switch status {
|
||||
case .authorized:
|
||||
self = .allowed
|
||||
case .provisional, .ephemeral:
|
||||
self = .provisional
|
||||
case .denied:
|
||||
self = .denied
|
||||
case .notDetermined:
|
||||
self = .unknown
|
||||
@unknown default:
|
||||
self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,193 @@
|
||||
import SwiftUI
|
||||
|
||||
private let loginAccent = AppTheme.accent
|
||||
|
||||
struct LoginRootView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
AppScrollScreen(compactLayout: compactLayout) {
|
||||
LoginHeroPanel(model: model, compactLayout: compactLayout)
|
||||
PairingConsoleCard(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
.sheet(isPresented: $model.isScannerPresented) {
|
||||
QRScannerSheet(
|
||||
seededPayload: model.suggestedPairingPayload,
|
||||
title: "Scan linking QR",
|
||||
description: "Use the camera to scan the QR code from the web flow that activates this device as your passport.",
|
||||
navigationTitle: "Scan Linking QR",
|
||||
onCodeScanned: { payload in
|
||||
model.manualPairingPayload = payload
|
||||
Task {
|
||||
await model.signIn(with: payload, transport: .qr)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoginHeroPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "Secure passport setup", tone: loginAccent)
|
||||
|
||||
Text("Turn this device into a passport for your idp.global identity")
|
||||
.font(.system(size: compactLayout ? 28 : 36, weight: .bold, design: .rounded))
|
||||
.lineLimit(3)
|
||||
|
||||
Text("Scan a linking QR code or paste a payload to activate this device as your passport for identity proofs and security alerts.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
LoginFeatureRow(icon: "qrcode.viewfinder", title: "Scan a QR code from the web flow")
|
||||
LoginFeatureRow(icon: "doc.text.viewfinder", title: "Paste a payload when you already have one")
|
||||
LoginFeatureRow(icon: "iphone.gen3", title: "Handle identity checks and alerts here")
|
||||
}
|
||||
|
||||
if model.isBootstrapping {
|
||||
ProgressView("Preparing preview passport...")
|
||||
.tint(loginAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoginFeatureRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(loginAccent)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PairingConsoleCard: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
AppSectionCard(title: "Set up passport", compactLayout: compactLayout) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Link payload")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
AppTextEditorField(
|
||||
text: $model.manualPairingPayload,
|
||||
minHeight: compactLayout ? 132 : 150
|
||||
)
|
||||
}
|
||||
|
||||
if model.isAuthenticating {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Activating this passport...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text("NFC, QR, and OTP proof methods become available after this passport is active.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
primaryButtons
|
||||
secondaryButtons
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
primaryButtons
|
||||
}
|
||||
|
||||
secondaryButtons
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var primaryButtons: some View {
|
||||
Button {
|
||||
model.isScannerPresented = true
|
||||
} label: {
|
||||
Label("Scan QR", systemImage: "qrcode.viewfinder")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var secondaryButtons: some View {
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
usePayloadButton
|
||||
previewPayloadButton
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
usePayloadButton
|
||||
previewPayloadButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var usePayloadButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithManualPayload()
|
||||
}
|
||||
} label: {
|
||||
if model.isAuthenticating {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label("Link with payload", systemImage: "arrow.right.circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
.disabled(model.isAuthenticating)
|
||||
}
|
||||
|
||||
private var previewPayloadButton: some View {
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithSuggestedPayload()
|
||||
}
|
||||
} label: {
|
||||
Label("Use preview passport", systemImage: "wand.and.stars")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(CoreLocation) && canImport(CoreNFC) && canImport(CryptoKit) && os(iOS)
|
||||
import CoreLocation
|
||||
import CoreNFC
|
||||
import CryptoKit
|
||||
|
||||
@MainActor
|
||||
final class NFCIdentifyReader: NSObject, ObservableObject, @preconcurrency NFCNDEFReaderSessionDelegate {
|
||||
@Published private(set) var helperText: String
|
||||
@Published private(set) var isScanning = false
|
||||
@Published private(set) var isSupported: Bool
|
||||
|
||||
var onAuthenticationRequestDetected: ((PairingAuthenticationRequest) -> Void)?
|
||||
var onError: ((String) -> Void)?
|
||||
|
||||
private let signedGPSPositionProvider = SignedGPSPositionProvider()
|
||||
private var session: NFCNDEFReaderSession?
|
||||
private var isPreparingLocationProof = false
|
||||
|
||||
override init() {
|
||||
let supported = NFCNDEFReaderSession.readingAvailable
|
||||
_helperText = Published(initialValue: supported ? NFCIdentifyReader.idleHelperText : NFCIdentifyReader.unavailableHelperText)
|
||||
_isSupported = Published(initialValue: supported)
|
||||
super.init()
|
||||
}
|
||||
|
||||
func beginScanning() {
|
||||
refreshAvailability()
|
||||
|
||||
guard isSupported else {
|
||||
onError?(Self.unavailableErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
guard !isScanning else { return }
|
||||
|
||||
isScanning = true
|
||||
isPreparingLocationProof = false
|
||||
helperText = Self.scanningHelperText
|
||||
|
||||
let session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: true)
|
||||
session.alertMessage = "Hold your iPhone near the idp.global tag. A signed GPS position will be attached to this NFC identify action."
|
||||
self.session = session
|
||||
session.begin()
|
||||
}
|
||||
|
||||
func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
|
||||
DispatchQueue.main.async {
|
||||
self.helperText = Self.scanningHelperText
|
||||
}
|
||||
}
|
||||
|
||||
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
|
||||
guard let payload = extractPayload(from: messages) else {
|
||||
session.invalidate()
|
||||
DispatchQueue.main.async {
|
||||
self.finishScanning()
|
||||
self.onError?(Self.invalidTagMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isPreparingLocationProof = true
|
||||
self.helperText = Self.signingLocationHelperText
|
||||
|
||||
Task { @MainActor in
|
||||
await self.completeAuthentication(for: payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
|
||||
let nsError = error as NSError
|
||||
let ignoredCodes = [200, 204] // User canceled, first tag read.
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.session = nil
|
||||
}
|
||||
|
||||
guard !(nsError.domain == NFCErrorDomain && ignoredCodes.contains(nsError.code)) else {
|
||||
if !isPreparingLocationProof {
|
||||
DispatchQueue.main.async {
|
||||
self.finishScanning()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.finishScanning()
|
||||
self.onError?(Self.failureMessage(for: nsError))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func completeAuthentication(for payload: String) async {
|
||||
do {
|
||||
let signedGPSPosition = try await signedGPSPositionProvider.currentSignedGPSPosition(for: payload)
|
||||
let request = PairingAuthenticationRequest(
|
||||
pairingPayload: payload,
|
||||
transport: .nfc,
|
||||
signedGPSPosition: signedGPSPosition
|
||||
)
|
||||
finishScanning()
|
||||
onAuthenticationRequestDetected?(request)
|
||||
} catch let error as AppError {
|
||||
finishScanning()
|
||||
onError?(error.errorDescription ?? Self.gpsSigningFailureMessage)
|
||||
} catch {
|
||||
finishScanning()
|
||||
onError?(Self.gpsSigningFailureMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishScanning() {
|
||||
session = nil
|
||||
isPreparingLocationProof = false
|
||||
isScanning = false
|
||||
refreshAvailability()
|
||||
}
|
||||
|
||||
private func refreshAvailability() {
|
||||
let available = NFCNDEFReaderSession.readingAvailable
|
||||
isSupported = available
|
||||
if !isScanning {
|
||||
helperText = available ? Self.idleHelperText : Self.unavailableHelperText
|
||||
}
|
||||
}
|
||||
|
||||
private func extractPayload(from messages: [NFCNDEFMessage]) -> String? {
|
||||
for message in messages {
|
||||
for record in message.records {
|
||||
if let url = record.wellKnownTypeURIPayload() {
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
let (text, _) = record.wellKnownTypeTextPayload()
|
||||
if let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if let fallback = String(data: record.payload, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!fallback.isEmpty {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func failureMessage(for error: NSError) -> String {
|
||||
if error.domain == NFCErrorDomain && error.code == 2 {
|
||||
return "NFC identify is not permitted in this build. Check the NFC entitlement and privacy description."
|
||||
}
|
||||
|
||||
return "NFC identify could not be completed on this device."
|
||||
}
|
||||
|
||||
private static let idleHelperText = "Tap to identify with an NFC tag on supported iPhone hardware. A signed GPS position will be attached automatically."
|
||||
private static let scanningHelperText = "Hold the top of your iPhone near the NFC tag until it is identified."
|
||||
private static let signingLocationHelperText = "Tag detected. Capturing and signing the current GPS position for NFC identify."
|
||||
private static let unavailableHelperText = "NFC identify is unavailable on this device."
|
||||
private static let unavailableErrorMessage = "Tap to identify requires supported iPhone hardware with NFC enabled."
|
||||
private static let invalidTagMessage = "The NFC tag did not contain a usable idp.global payload."
|
||||
private static let gpsSigningFailureMessage = "The NFC tag was read, but the signed GPS position could not be attached."
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SignedGPSPositionProvider: NSObject, @preconcurrency CLLocationManagerDelegate {
|
||||
private var manager: CLLocationManager?
|
||||
private var authorizationContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Error>?
|
||||
|
||||
func currentSignedGPSPosition(for pairingPayload: String) async throws -> SignedGPSPosition {
|
||||
let location = try await currentLocation()
|
||||
return try sign(location: location, pairingPayload: pairingPayload)
|
||||
}
|
||||
|
||||
private func currentLocation() async throws -> CLLocation {
|
||||
let manager = CLLocationManager()
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||
manager.distanceFilter = kCLDistanceFilterNone
|
||||
self.manager = manager
|
||||
|
||||
switch manager.authorizationStatus {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
break
|
||||
case .notDetermined:
|
||||
let status = await requestAuthorization(using: manager)
|
||||
guard status == .authorizedAlways || status == .authorizedWhenInUse else {
|
||||
throw AppError.locationPermissionDenied
|
||||
}
|
||||
case .denied, .restricted:
|
||||
throw AppError.locationPermissionDenied
|
||||
@unknown default:
|
||||
throw AppError.locationUnavailable
|
||||
}
|
||||
|
||||
return try await requestLocation(using: manager)
|
||||
}
|
||||
|
||||
private func requestAuthorization(using manager: CLLocationManager) async -> CLAuthorizationStatus {
|
||||
manager.requestWhenInUseAuthorization()
|
||||
return await withCheckedContinuation { continuation in
|
||||
authorizationContinuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLocation(using manager: CLLocationManager) async throws -> CLLocation {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
locationContinuation = continuation
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func sign(location: CLLocation, pairingPayload: String) throws -> SignedGPSPosition {
|
||||
let isFresh = abs(location.timestamp.timeIntervalSinceNow) <= 120
|
||||
guard location.horizontalAccuracy >= 0,
|
||||
isFresh else {
|
||||
throw AppError.locationUnavailable
|
||||
}
|
||||
|
||||
let unsignedPosition = SignedGPSPosition(
|
||||
latitude: location.coordinate.latitude,
|
||||
longitude: location.coordinate.longitude,
|
||||
horizontalAccuracyMeters: location.horizontalAccuracy,
|
||||
capturedAt: location.timestamp
|
||||
)
|
||||
|
||||
let privateKey = P256.Signing.PrivateKey()
|
||||
let signature = try privateKey.signature(for: unsignedPosition.signingPayload(for: pairingPayload))
|
||||
return unsignedPosition.signed(
|
||||
signatureData: signature.derRepresentation,
|
||||
publicKeyData: privateKey.publicKey.x963Representation
|
||||
)
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
guard let continuation = authorizationContinuation else { return }
|
||||
|
||||
let status = manager.authorizationStatus
|
||||
guard status != .notDetermined else { return }
|
||||
|
||||
authorizationContinuation = nil
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let continuation = locationContinuation,
|
||||
let location = locations.last else {
|
||||
return
|
||||
}
|
||||
|
||||
authorizationContinuation = nil
|
||||
locationContinuation = nil
|
||||
self.manager = nil
|
||||
continuation.resume(returning: location)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
guard let continuation = locationContinuation else { return }
|
||||
|
||||
authorizationContinuation = nil
|
||||
locationContinuation = nil
|
||||
self.manager = nil
|
||||
|
||||
if let locationError = error as? CLError,
|
||||
locationError.code == .denied {
|
||||
continuation.resume(throwing: AppError.locationPermissionDenied)
|
||||
return
|
||||
}
|
||||
|
||||
continuation.resume(throwing: AppError.locationUnavailable)
|
||||
}
|
||||
}
|
||||
#else
|
||||
@MainActor
|
||||
final class NFCIdentifyReader: NSObject, ObservableObject {
|
||||
@Published private(set) var helperText = "NFC identify with a signed GPS position is available on supported iPhone hardware only."
|
||||
@Published private(set) var isScanning = false
|
||||
@Published private(set) var isSupported = false
|
||||
|
||||
var onAuthenticationRequestDetected: ((PairingAuthenticationRequest) -> Void)?
|
||||
var onError: ((String) -> Void)?
|
||||
|
||||
func beginScanning() {
|
||||
onError?("Tap to identify requires supported iPhone hardware with NFC and location access enabled.")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,418 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct QRScannerSheet: View {
|
||||
let seededPayload: String
|
||||
let title: String
|
||||
let description: String
|
||||
let navigationTitleText: String
|
||||
let onCodeScanned: (String) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var manualFallback = ""
|
||||
|
||||
init(
|
||||
seededPayload: String,
|
||||
title: String = "Scan QR",
|
||||
description: String = "Use the camera to scan an idp.global QR challenge.",
|
||||
navigationTitle: String = "Scan QR",
|
||||
onCodeScanned: @escaping (String) -> Void
|
||||
) {
|
||||
self.seededPayload = seededPayload
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.navigationTitleText = navigationTitle
|
||||
self.onCodeScanned = onCodeScanned
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
AppScrollScreen(compactLayout: compactLayout) {
|
||||
AppSectionCard(title: title, compactLayout: compactLayout) {
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
LiveQRScannerView(onCodeScanned: onCodeScanned)
|
||||
.frame(minHeight: 340)
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Manual fallback", compactLayout: compactLayout) {
|
||||
AppTextEditorField(text: $manualFallback, minHeight: 120)
|
||||
|
||||
if compactLayout {
|
||||
VStack(spacing: 12) {
|
||||
useFallbackButton
|
||||
useSeededButton
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
useFallbackButton
|
||||
useSeededButton
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(navigationTitleText)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
manualFallback = seededPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private var useFallbackButton: some View {
|
||||
Button {
|
||||
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
onCodeScanned(chosen.isEmpty ? seededPayload : chosen)
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Use payload", systemImage: "arrow.up.forward.square")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
private var useSeededButton: some View {
|
||||
Button {
|
||||
manualFallback = seededPayload
|
||||
} label: {
|
||||
Label("Reset sample", systemImage: "wand.and.rays")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiveQRScannerView: View {
|
||||
let onCodeScanned: (String) -> Void
|
||||
|
||||
@StateObject private var scanner = QRScannerViewModel()
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
Group {
|
||||
if scanner.isPreviewAvailable {
|
||||
ScannerPreview(session: scanner.captureSession)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.fill(Color.black.opacity(0.86))
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Image(systemName: "video.slash.fill")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("Live camera preview unavailable")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(scanner.statusMessage)
|
||||
.foregroundStyle(.white.opacity(0.78))
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.22), lineWidth: 1.5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Camera Scanner")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(scanner.statusMessage)
|
||||
.foregroundStyle(.white.opacity(0.84))
|
||||
}
|
||||
.padding(22)
|
||||
|
||||
ScanFrameOverlay()
|
||||
.padding(40)
|
||||
}
|
||||
.task {
|
||||
scanner.onCodeScanned = { payload in
|
||||
onCodeScanned(payload)
|
||||
}
|
||||
await scanner.start()
|
||||
}
|
||||
.onDisappear {
|
||||
scanner.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ScanFrameOverlay: View {
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let size = min(geometry.size.width, geometry.size.height) * 0.5
|
||||
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.82), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
|
||||
.frame(width: size, height: size)
|
||||
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||
@Published var isPreviewAvailable = false
|
||||
@Published var statusMessage = "Point the camera at the QR code from the idp.global web portal."
|
||||
|
||||
let captureSession = AVCaptureSession()
|
||||
|
||||
var onCodeScanned: ((String) -> Void)?
|
||||
|
||||
private let queue = DispatchQueue(label: "global.idp.qrscanner")
|
||||
private var isConfigured = false
|
||||
private var hasDeliveredCode = false
|
||||
|
||||
func start() async {
|
||||
#if os(iOS) && targetEnvironment(simulator)
|
||||
await MainActor.run {
|
||||
isPreviewAvailable = false
|
||||
statusMessage = "The iOS simulator has no live camera feed. Use the seeded payload below."
|
||||
}
|
||||
#else
|
||||
#endif
|
||||
|
||||
#if !(os(iOS) && targetEnvironment(simulator))
|
||||
let authorization = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
switch authorization {
|
||||
case .authorized:
|
||||
await configureIfNeeded()
|
||||
startRunning()
|
||||
case .notDetermined:
|
||||
let granted = await requestCameraAccess()
|
||||
await MainActor.run {
|
||||
self.statusMessage = granted
|
||||
? "Point the camera at the QR code from the idp.global web portal."
|
||||
: "Camera access was denied. Use the fallback payload below."
|
||||
}
|
||||
guard granted else { return }
|
||||
await configureIfNeeded()
|
||||
startRunning()
|
||||
case .denied, .restricted:
|
||||
await MainActor.run {
|
||||
isPreviewAvailable = false
|
||||
statusMessage = "Camera access is unavailable. Use the fallback payload below."
|
||||
}
|
||||
@unknown default:
|
||||
await MainActor.run {
|
||||
isPreviewAvailable = false
|
||||
statusMessage = "Camera access could not be initialized on this device."
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.async {
|
||||
if self.captureSession.isRunning {
|
||||
self.captureSession.stopRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func metadataOutput(
|
||||
_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
guard !hasDeliveredCode,
|
||||
let readable = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
readable.type == .qr,
|
||||
let payload = readable.stringValue else {
|
||||
return
|
||||
}
|
||||
|
||||
hasDeliveredCode = true
|
||||
stop()
|
||||
|
||||
#if os(iOS)
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async { [onCodeScanned] in
|
||||
onCodeScanned?(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestCameraAccess() async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureIfNeeded() async {
|
||||
guard !isConfigured else {
|
||||
await MainActor.run {
|
||||
self.isPreviewAvailable = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
queue.async {
|
||||
self.captureSession.beginConfiguration()
|
||||
defer {
|
||||
self.captureSession.commitConfiguration()
|
||||
continuation.resume()
|
||||
}
|
||||
|
||||
guard let device = AVCaptureDevice.default(for: .video) else {
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let input = try? AVCaptureDeviceInput(device: device) else {
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard self.captureSession.canAddInput(input) else {
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "No compatible camera was found. Use the fallback payload below."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.captureSession.addInput(input)
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard self.captureSession.canAddOutput(output) else {
|
||||
self.captureSession.removeInput(input)
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "Unable to configure QR metadata scanning on this device."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.captureSession.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||
|
||||
let supportedTypes = output.availableMetadataObjectTypes
|
||||
guard supportedTypes.contains(.qr) else {
|
||||
self.captureSession.removeOutput(output)
|
||||
self.captureSession.removeInput(input)
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = false
|
||||
self.statusMessage = "This camera does not support QR metadata scanning. Use the fallback payload below."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
output.metadataObjectTypes = [.qr]
|
||||
self.isConfigured = true
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isPreviewAvailable = true
|
||||
self.statusMessage = "Point the camera at the QR code from the idp.global web portal."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startRunning() {
|
||||
queue.async {
|
||||
guard !self.captureSession.isRunning else { return }
|
||||
self.hasDeliveredCode = false
|
||||
self.captureSession.startRunning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension QRScannerViewModel: @unchecked Sendable {}
|
||||
|
||||
#if os(iOS)
|
||||
private struct ScannerPreview: UIViewRepresentable {
|
||||
let session: AVCaptureSession
|
||||
|
||||
func makeUIView(context: Context) -> ScannerPreviewUIView {
|
||||
let view = ScannerPreviewUIView()
|
||||
view.previewLayer.session = session
|
||||
view.previewLayer.videoGravity = .resizeAspectFill
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: ScannerPreviewUIView, context: Context) {
|
||||
uiView.previewLayer.session = session
|
||||
}
|
||||
}
|
||||
|
||||
private final class ScannerPreviewUIView: UIView {
|
||||
override class var layerClass: AnyClass {
|
||||
AVCaptureVideoPreviewLayer.self
|
||||
}
|
||||
|
||||
var previewLayer: AVCaptureVideoPreviewLayer {
|
||||
layer as! AVCaptureVideoPreviewLayer
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
private struct ScannerPreview: NSViewRepresentable {
|
||||
let session: AVCaptureSession
|
||||
|
||||
func makeNSView(context: Context) -> ScannerPreviewNSView {
|
||||
let view = ScannerPreviewNSView()
|
||||
view.attach(session: session)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: ScannerPreviewNSView, context: Context) {
|
||||
nsView.attach(session: session)
|
||||
}
|
||||
}
|
||||
|
||||
private final class ScannerPreviewNSView: NSView {
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
wantsLayer = true
|
||||
}
|
||||
|
||||
func attach(session: AVCaptureSession) {
|
||||
let layer = previewLayer ?? AVCaptureVideoPreviewLayer(session: session)
|
||||
layer.session = session
|
||||
layer.videoGravity = .resizeAspectFill
|
||||
self.layer = layer
|
||||
previewLayer = layer
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import SwiftUI
|
||||
|
||||
let dashboardAccent = AppTheme.accent
|
||||
let dashboardGold = AppTheme.warmAccent
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func inlineNavigationTitleOnIOS() -> some View {
|
||||
#if os(iOS)
|
||||
navigationBarTitleDisplayMode(.inline)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func cleanTabBarOnIOS() -> some View {
|
||||
#if os(iOS)
|
||||
toolbarBackground(.visible, for: .tabBar)
|
||||
.toolbarBackground(AppTheme.chromeFill, for: .tabBar)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct HomeRootView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@State private var notificationBellFrame: CGRect?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if usesCompactNavigation {
|
||||
CompactHomeContainer(model: model)
|
||||
} else {
|
||||
RegularHomeContainer(model: model)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(NotificationBellFrameKey.self) { notificationBellFrame = $0 }
|
||||
.overlay(alignment: .topLeading) {
|
||||
if usesCompactNavigation {
|
||||
NotificationBellBadgeOverlay(
|
||||
unreadCount: model.unreadNotificationCount,
|
||||
bellFrame: notificationBellFrame
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $model.isNotificationCenterPresented) {
|
||||
NotificationCenterSheet(model: model)
|
||||
}
|
||||
}
|
||||
|
||||
private var usesCompactNavigation: Bool {
|
||||
#if os(iOS)
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct CompactHomeContainer: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $model.selectedSection) {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
NavigationStack {
|
||||
HomeSectionScreen(model: model, section: section, compactLayout: compactLayout)
|
||||
.navigationTitle(section.title)
|
||||
.inlineNavigationTitleOnIOS()
|
||||
.toolbar {
|
||||
DashboardToolbar(model: model)
|
||||
}
|
||||
}
|
||||
.tag(section)
|
||||
.tabItem {
|
||||
Label(section.title, systemImage: section.systemImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
.cleanTabBarOnIOS()
|
||||
}
|
||||
|
||||
private var compactLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct RegularHomeContainer: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
Sidebar(model: model)
|
||||
.navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 320)
|
||||
} detail: {
|
||||
HomeSectionScreen(model: model, section: model.selectedSection, compactLayout: false)
|
||||
.navigationTitle(model.selectedSection.title)
|
||||
.toolbar {
|
||||
DashboardToolbar(model: model)
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DashboardToolbar: ToolbarContent {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some ToolbarContent {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
NotificationBellButton(model: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationBellFrameKey: PreferenceKey {
|
||||
static var defaultValue: CGRect? = nil
|
||||
|
||||
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
|
||||
value = nextValue() ?? value
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationBellBadgeOverlay: View {
|
||||
let unreadCount: Int
|
||||
let bellFrame: CGRect?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
if unreadCount > 0, let bellFrame {
|
||||
let rootFrame = proxy.frame(in: .global)
|
||||
|
||||
Text("\(min(unreadCount, 9))")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.padding(.horizontal, 3)
|
||||
.background(Color.orange, in: Capsule())
|
||||
.position(
|
||||
x: bellFrame.maxX - rootFrame.minX - 2,
|
||||
y: bellFrame.minY - rootFrame.minY + 2
|
||||
)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeSectionScreen: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let section: AppSection
|
||||
let compactLayout: Bool
|
||||
|
||||
@State private var focusedRequest: ApprovalRequest?
|
||||
@State private var isOTPPresented = false
|
||||
@StateObject private var identifyReader = NFCIdentifyReader()
|
||||
|
||||
var body: some View {
|
||||
AppScrollScreen(
|
||||
compactLayout: compactLayout,
|
||||
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
|
||||
) {
|
||||
HomeTopActions(
|
||||
model: model,
|
||||
identifyReader: identifyReader,
|
||||
onScanQR: { model.isScannerPresented = true },
|
||||
onShowOTP: { isOTPPresented = true }
|
||||
)
|
||||
|
||||
switch section {
|
||||
case .overview:
|
||||
OverviewPanel(model: model, compactLayout: compactLayout)
|
||||
case .requests:
|
||||
RequestsPanel(model: model, compactLayout: compactLayout, onOpenRequest: { focusedRequest = $0 })
|
||||
case .activity:
|
||||
ActivityPanel(model: model, compactLayout: compactLayout)
|
||||
case .account:
|
||||
AccountPanel(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
identifyReader.onAuthenticationRequestDetected = { request in
|
||||
Task {
|
||||
await model.identifyWithNFC(request)
|
||||
}
|
||||
}
|
||||
|
||||
identifyReader.onError = { message in
|
||||
model.errorMessage = message
|
||||
}
|
||||
}
|
||||
.sheet(item: $focusedRequest) { request in
|
||||
RequestDetailSheet(request: request, model: model)
|
||||
}
|
||||
.sheet(isPresented: $model.isScannerPresented) {
|
||||
QRScannerSheet(
|
||||
seededPayload: model.session?.pairingCode ?? model.suggestedPairingPayload,
|
||||
title: "Scan proof QR",
|
||||
description: "Use the camera to scan an idp.global QR challenge from the site or device asking you to prove that it is really you.",
|
||||
navigationTitle: "Scan Proof QR",
|
||||
onCodeScanned: { payload in
|
||||
Task {
|
||||
await model.identifyWithPayload(payload, transport: .qr)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $isOTPPresented) {
|
||||
if let session = model.session {
|
||||
OneTimePasscodeSheet(session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeTopActions: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
@ObservedObject var identifyReader: NFCIdentifyReader
|
||||
let onScanQR: () -> Void
|
||||
let onShowOTP: () -> Void
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, spacing: 12) {
|
||||
identifyButton
|
||||
qrButton
|
||||
otpButton
|
||||
}
|
||||
}
|
||||
|
||||
private var columns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 12), count: 3)
|
||||
}
|
||||
|
||||
private var identifyButton: some View {
|
||||
Button {
|
||||
identifyReader.beginScanning()
|
||||
} label: {
|
||||
AppActionTile(
|
||||
title: identifyReader.isScanning ? "Scanning NFC" : "Tap NFC",
|
||||
systemImage: "dot.radiowaves.left.and.right",
|
||||
tone: dashboardAccent,
|
||||
isBusy: identifyReader.isScanning || model.isIdentifying
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(identifyReader.isScanning || !identifyReader.isSupported || model.isIdentifying)
|
||||
}
|
||||
|
||||
private var qrButton: some View {
|
||||
Button {
|
||||
onScanQR()
|
||||
} label: {
|
||||
AppActionTile(
|
||||
title: "Scan QR",
|
||||
systemImage: "qrcode.viewfinder",
|
||||
tone: dashboardAccent
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var otpButton: some View {
|
||||
Button {
|
||||
onShowOTP()
|
||||
} label: {
|
||||
AppActionTile(
|
||||
title: "OTP",
|
||||
systemImage: "number.square.fill",
|
||||
tone: dashboardGold
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Sidebar: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
SidebarStatusCard(
|
||||
profile: model.profile,
|
||||
pendingCount: model.pendingRequests.count,
|
||||
unreadCount: model.unreadNotificationCount
|
||||
)
|
||||
}
|
||||
|
||||
Section("Workspace") {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
Button {
|
||||
model.selectedSection = section
|
||||
} label: {
|
||||
HStack {
|
||||
Label(section.title, systemImage: section.systemImage)
|
||||
Spacer()
|
||||
if badgeCount(for: section) > 0 {
|
||||
AppStatusTag(title: "\(badgeCount(for: section))", tone: dashboardAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(
|
||||
model.selectedSection == section
|
||||
? dashboardAccent.opacity(0.10)
|
||||
: Color.clear
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("idp.global")
|
||||
}
|
||||
|
||||
private func badgeCount(for section: AppSection) -> Int {
|
||||
switch section {
|
||||
case .overview:
|
||||
0
|
||||
case .requests:
|
||||
model.pendingRequests.count
|
||||
case .activity:
|
||||
model.unreadNotificationCount
|
||||
case .account:
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarStatusCard: View {
|
||||
let profile: MemberProfile?
|
||||
let pendingCount: Int
|
||||
let unreadCount: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Digital Passport")
|
||||
.font(.headline)
|
||||
|
||||
Text(profile?.handle ?? "No passport active")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: "\(pendingCount) pending", tone: dashboardAccent)
|
||||
AppStatusTag(title: "\(unreadCount) unread", tone: dashboardGold)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct IDPGlobalWatchApp: App {
|
||||
@StateObject private var model = AppViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchRootView(model: model)
|
||||
.task {
|
||||
await model.bootstrap()
|
||||
}
|
||||
.alert("Something went wrong", isPresented: errorPresented) {
|
||||
Button("OK") {
|
||||
model.errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var errorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
model.errorMessage = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private let watchAccent = AppTheme.accent
|
||||
private let watchGold = AppTheme.warmAccent
|
||||
|
||||
struct WatchRootView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if model.session == nil {
|
||||
WatchPairingView(model: model)
|
||||
} else {
|
||||
WatchDashboardView(model: model)
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.tint(watchAccent)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchPairingView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AppBadge(title: "Preview passport", tone: watchAccent)
|
||||
|
||||
Text("Prove identity from your wrist")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Link this watch to the preview passport so identity checks and alerts stay visible on your wrist.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: "Wrist-ready", tone: watchAccent)
|
||||
AppStatusTag(title: "Proof focus", tone: watchGold)
|
||||
}
|
||||
}
|
||||
.watchCard()
|
||||
|
||||
if model.isBootstrapping {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.tint(watchAccent)
|
||||
Text("Preparing preview passport...")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.watchCard()
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await model.signInWithSuggestedPayload()
|
||||
}
|
||||
} label: {
|
||||
if model.isAuthenticating {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label("Link Preview Passport", systemImage: "applewatch")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(watchAccent)
|
||||
.disabled(model.isBootstrapping || model.suggestedPairingPayload.isEmpty || model.isAuthenticating)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("What this watch does")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
WatchSetupFeatureRow(
|
||||
systemImage: "checkmark.shield",
|
||||
title: "Review identity checks",
|
||||
subtitle: "See pending proof prompts quickly."
|
||||
)
|
||||
|
||||
WatchSetupFeatureRow(
|
||||
systemImage: "bell.badge",
|
||||
title: "Surface important alerts",
|
||||
subtitle: "Keep passport activity visible at a glance."
|
||||
)
|
||||
|
||||
WatchSetupFeatureRow(
|
||||
systemImage: "iphone.radiowaves.left.and.right",
|
||||
title: "Stay in sync with the phone preview",
|
||||
subtitle: "Use the same mocked passport context."
|
||||
)
|
||||
}
|
||||
.watchCard()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
.navigationTitle("Link Watch")
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchSetupFeatureRow: View {
|
||||
let systemImage: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(watchAccent)
|
||||
.frame(width: 18, height: 18)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func watchCard() -> some View {
|
||||
padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchDashboardView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
WatchPassportCard(model: model)
|
||||
.watchCard()
|
||||
|
||||
WatchSectionHeader(
|
||||
title: "Pending",
|
||||
detail: model.pendingRequests.isEmpty ? nil : "\(model.pendingRequests.count)"
|
||||
)
|
||||
|
||||
if model.pendingRequests.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("No checks waiting.")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("New identity checks will appear here when a site or device asks you to prove it is really you.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
|
||||
Button("Seed Identity Check") {
|
||||
Task {
|
||||
await model.simulateIncomingRequest()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(watchAccent)
|
||||
}
|
||||
.watchCard()
|
||||
} else {
|
||||
ForEach(model.pendingRequests) { request in
|
||||
NavigationLink {
|
||||
WatchRequestDetailView(model: model, requestID: request.id)
|
||||
} label: {
|
||||
WatchRequestRow(request: request)
|
||||
.watchCard()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
WatchSectionHeader(title: "Activity")
|
||||
|
||||
if model.notifications.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("No recent alerts.")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Passport activity and security events will show up here.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
}
|
||||
.watchCard()
|
||||
} else {
|
||||
ForEach(model.notifications.prefix(3)) { notification in
|
||||
NavigationLink {
|
||||
WatchNotificationDetailView(model: model, notificationID: notification.id)
|
||||
} label: {
|
||||
WatchNotificationRow(notification: notification)
|
||||
.watchCard()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
WatchSectionHeader(title: "Actions")
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Button("Refresh") {
|
||||
Task {
|
||||
await model.refreshDashboard()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(watchAccent)
|
||||
.disabled(model.isRefreshing)
|
||||
|
||||
Button("Send Test Alert") {
|
||||
Task {
|
||||
await model.sendTestNotification()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
if model.notificationPermission == .unknown || model.notificationPermission == .denied {
|
||||
Button("Enable Alerts") {
|
||||
Task {
|
||||
await model.requestNotificationAccess()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Button("Sign Out", role: .destructive) {
|
||||
model.signOut()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.watchCard()
|
||||
|
||||
if let profile = model.profile {
|
||||
WatchSectionHeader(title: "Identity")
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(profile.handle)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(profile.organization)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
|
||||
Text("Notifications: \(model.notificationPermission.title)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
}
|
||||
.watchCard()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
.navigationTitle("Passport")
|
||||
.refreshable {
|
||||
await model.refreshDashboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchSectionHeader: View {
|
||||
let title: String
|
||||
var detail: String? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if let detail, !detail.isEmpty {
|
||||
Text(detail)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchPassportCard: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AppBadge(title: "Passport active", tone: watchAccent)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.profile?.name ?? "Preview Session")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
Text(model.pairedDeviceSummary)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
if let session = model.session {
|
||||
Text("Via \(session.pairingTransport.title)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
WatchMetricPill(title: "Pending", value: "\(model.pendingRequests.count)", accent: watchAccent)
|
||||
WatchMetricPill(title: "Unread", value: "\(model.unreadNotificationCount)", accent: watchGold)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchMetricPill: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let accent: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(value)
|
||||
.font(.headline.monospacedDigit())
|
||||
.foregroundStyle(.white)
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchRequestRow: View {
|
||||
let request: ApprovalRequest
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text(request.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
Image(systemName: request.risk == .elevated ? "exclamationmark.shield.fill" : "checkmark.shield.fill")
|
||||
.foregroundStyle(request.risk == .elevated ? .orange : watchAccent)
|
||||
}
|
||||
|
||||
Text(request.source)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? watchAccent : .orange)
|
||||
AppStatusTag(title: request.status.title, tone: request.status == .pending ? .orange : watchAccent)
|
||||
}
|
||||
|
||||
Text(request.createdAt.watchRelativeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchNotificationRow: View {
|
||||
let notification: AppNotification
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text(notification.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Spacer(minLength: 6)
|
||||
|
||||
if notification.isUnread {
|
||||
Circle()
|
||||
.fill(watchAccent)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
|
||||
Text(notification.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
.lineLimit(2)
|
||||
|
||||
Text(notification.sentAt.watchRelativeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchRequestDetailView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let requestID: ApprovalRequest.ID
|
||||
|
||||
private var request: ApprovalRequest? {
|
||||
model.requests.first(where: { $0.id == requestID })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let request {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
detailHeader(
|
||||
title: request.title,
|
||||
subtitle: request.source,
|
||||
badge: request.status.title
|
||||
)
|
||||
|
||||
Text(request.subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Trust Summary")
|
||||
.font(.headline)
|
||||
Text(request.trustHeadline)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(request.trustDetail)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(request.risk.guidance)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
|
||||
if !request.scopes.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Scopes")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(request.scopes, id: \.self) { scope in
|
||||
Label(scope, systemImage: "checkmark.seal.fill")
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if request.status == .pending {
|
||||
if model.activeRequestID == request.id {
|
||||
ProgressView("Updating proof...")
|
||||
} else {
|
||||
Button("Verify") {
|
||||
Task {
|
||||
await model.approve(request)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Decline", role: .destructive) {
|
||||
Task {
|
||||
await model.reject(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
} else {
|
||||
Text("This request is no longer available.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Identity Check")
|
||||
}
|
||||
|
||||
private func detailHeader(title: String, subtitle: String, badge: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(badge)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(watchAccent.opacity(0.14), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchNotificationDetailView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let notificationID: AppNotification.ID
|
||||
|
||||
private var notification: AppNotification? {
|
||||
model.notifications.first(where: { $0.id == notificationID })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let notification {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(notification.title)
|
||||
.font(.headline)
|
||||
Text(notification.kind.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(watchAccent)
|
||||
Text(notification.sentAt.watchRelativeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(notification.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Alert posture")
|
||||
.font(.headline)
|
||||
Text(model.notificationPermission.summary)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
|
||||
if notification.isUnread {
|
||||
Button("Mark Read") {
|
||||
Task {
|
||||
await model.markNotificationRead(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
} else {
|
||||
Text("This activity item has already been cleared.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Activity")
|
||||
}
|
||||
}
|
||||
|
||||
private extension Date {
|
||||
var watchRelativeString: String {
|
||||
WatchFormatters.relative.localizedString(for: self, relativeTo: .now)
|
||||
}
|
||||
}
|
||||
|
||||
private enum WatchFormatters {
|
||||
static let relative: RelativeDateTimeFormatter = {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||