Refocus app around identity proof flows

This commit is contained in:
2026-04-18 01:05:22 +02:00
parent d195037eb6
commit ea6b45388f
45 changed files with 2784 additions and 3159 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.DS_Store
agentcomms/
build/
DerivedData/
xcuserdata/

View File

@@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

10
IDPGlobal.entitlements Normal file
View File

@@ -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>

View File

@@ -15,8 +15,43 @@
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 */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
B90000000000000000000001 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = B60000000000000000000001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = B50000000000000000000002;
remoteInfo = IDPGlobalWatch;
};
/* 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>"; };
@@ -27,6 +62,12 @@
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>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -37,6 +78,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B30000000000000000000005 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -51,7 +99,9 @@
B40000000000000000000002 /* IDPGlobal */ = {
isa = PBXGroup;
children = (
B2000000000000000000000E /* Assets.xcassets */,
B40000000000000000000003 /* Sources */,
B4000000000000000000000C /* WatchApp */,
);
name = IDPGlobal;
sourceTree = "<group>";
@@ -69,6 +119,7 @@
B40000000000000000000004 /* App */ = {
isa = PBXGroup;
children = (
B2000000000000000000000F /* AppComponents.swift */,
B20000000000000000000001 /* IDPGlobalApp.swift */,
B20000000000000000000002 /* AppViewModel.swift */,
);
@@ -114,6 +165,7 @@
isa = PBXGroup;
children = (
B20000000000000000000009 /* IDPGlobal.app */,
B2000000000000000000000A /* IDPGlobalWatch.app */,
);
name = Products;
sourceTree = "<group>";
@@ -122,6 +174,7 @@
isa = PBXGroup;
children = (
B20000000000000000000006 /* LoginRootView.swift */,
B2000000000000000000000D /* NFCPairingView.swift */,
B20000000000000000000007 /* QRScannerView.swift */,
);
path = Auth;
@@ -135,6 +188,31 @@
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>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -145,14 +223,33 @@
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 = IDPGlobal;
productName = IDPGlobal;
productReference = B20000000000000000000009 /* IDPGlobal.app */;
name = IDPGlobalWatch;
productName = IDPGlobalWatch;
productReference = B2000000000000000000000A /* IDPGlobalWatch.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@@ -168,6 +265,9 @@
B50000000000000000000001 = {
CreatedOnToolsVersion = 26.0;
};
B50000000000000000000002 = {
CreatedOnToolsVersion = 26.0;
};
};
};
buildConfigurationList = B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */;
@@ -184,12 +284,21 @@
projectRoot = "";
targets = (
B50000000000000000000001 /* IDPGlobal */,
B50000000000000000000002 /* IDPGlobalWatch */,
);
};
/* 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 = (
@@ -203,19 +312,44 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B10000000000000000000012 /* AppComponents.swift in Sources */,
B10000000000000000000002 /* AppViewModel.swift in Sources */,
B10000000000000000000008 /* HomeRootView.swift in Sources */,
B10000000000000000000001 /* IDPGlobalApp.swift in Sources */,
B10000000000000000000006 /* LoginRootView.swift in Sources */,
B10000000000000000000004 /* MockIDPService.swift in Sources */,
B10000000000000000000010 /* NFCPairingView.swift in Sources */,
B10000000000000000000005 /* NotificationCoordinator.swift in Sources */,
B10000000000000000000003 /* AppModels.swift in Sources */,
B10000000000000000000007 /* QRScannerView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B30000000000000000000007 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B10000000000000000000013 /* AppComponents.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 */,
B1000000000000000000000E /* WatchRootView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
B90000000000000000000002 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
platformFilter = ios;
target = B50000000000000000000002 /* IDPGlobalWatch */;
targetProxy = B90000000000000000000001 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
B80000000000000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
@@ -275,7 +409,9 @@
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 = "";
@@ -284,6 +420,8 @@
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 = (
@@ -305,7 +443,9 @@
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 = "";
@@ -314,6 +454,8 @@
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 = (
@@ -332,6 +474,64 @@
};
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;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -353,6 +553,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
B70000000000000000000003 /* Build configuration list for PBXNativeTarget "IDPGlobalWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B80000000000000000000005 /* Debug */,
B80000000000000000000006 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = B60000000000000000000001 /* Project object */;

103
README.md
View File

@@ -1,95 +1,40 @@
# idp.global Swift App
Multiplatform SwiftUI companion app for `idp.global` across iPhone, iPad, and Mac.
Multiplatform SwiftUI scaffold for the personal `idp.global` companion app on iPhone, iPad, Mac, and Apple Watch.
The current build is a polished preview backed by a mock service layer. It already walks through the core product flow:
## Included in this first pass
- bind a device to an account with a QR payload
- review and approve identity or access requests
- track recent security and system events
- manage notification permissions and send a local test alert
- QR and NFC-based device pairing flows with a seeded preview payload fallback
- NFC authentication now attaches a signed GPS position on supported iPhone hardware
- Mocked approval inbox for accepting or rejecting identity requests
- Notification center with local notification permission flow and a test notification trigger
- Apple Watch companion target with a compact approval-first dashboard and request detail flow
- Shared app state and mock backend boundary so a real API can be connected later
## Current Product Surface
## Open the project
After pairing, the app opens into a passport-style dashboard with four sections:
1. Open [IDPGlobal.xcodeproj](/Users/philkunz/gitea/idp.global-swiftapp/IDPGlobal/IDPGlobal.xcodeproj).
2. Build the `IDPGlobal` scheme for:
- `My Mac`
- an iPad simulator
- an iPhone simulator
3. Build the `IDPGlobalWatch` scheme for an Apple Watch simulator when you want to verify the companion experience.
- `Passport`: digital identity summary, trust context, and quick actions
- `Requests`: approval queue with elevated-risk guidance and inline review
- `Activity`: timeline of pairing, approval, and system events
- `Account`: member profile, trusted-device context, and recovery summary
## Mock QR payload
The layout adapts by platform:
- iPhone uses a compact tab-based container
- iPad and Mac use a split-view workspace with richer side-by-side review
## Pairing Flow
The sign-in flow supports:
- live QR scanning through the camera
- manual payload paste for testing
- a seeded preview payload while the real backend is still being wired up
Seeded payload on first launch:
The app seeds this pairing payload on first launch:
`idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP`
## Mocked Preview Behavior
You can paste it manually, scan it as a QR code, or use the preview pairing action while the backend is still mocked.
The app currently runs against `MockIDPService`, which seeds:
For NFC authentication, the app reads the pairing payload from the tag, captures the current device location, signs that GPS position, and submits both together.
- a paired member profile
- pending and handled approval requests
- recent notifications and security events
- simulated incoming requests from the toolbar
## Next integration step
This keeps the UI realistic while preserving a clean integration seam for the live backend later.
Replace `MockIDPService` with a live service that:
## Open And Run
1. Open `IDPGlobal.xcodeproj` in Xcode.
2. Build and run the `IDPGlobal` scheme on:
- `My Mac`
- an iPhone simulator
- an iPad simulator
You can also build from the command line:
```bash
xcodebuild -project IDPGlobal.xcodeproj -scheme IDPGlobal -configuration Debug -destination 'platform=macOS' build
```
## Useful Preview Launch Arguments
These launch arguments are already supported by the app model:
- `--mock-auto-pair`: automatically pair with the seeded preview payload on launch
- `--mock-section=overview`
- `--mock-section=requests`
- `--mock-section=activity`
- `--mock-section=account`
- `--mock-section=notifications`: opens the activity timeline using a notification-friendly alias
Example:
```text
--mock-auto-pair --mock-section=requests
```
## Project Structure
- `Sources/App`: app entry point and shared state in `AppViewModel`
- `Sources/Features/Auth`: first-run pairing flow and QR scanner UI
- `Sources/Features/Home`: passport dashboard, requests, activity, notifications, and account surfaces
- `Sources/Core/Models`: app-facing domain models
- `Sources/Core/Services`: mock backend boundary and local notification coordination
## Next Integration Step
Replace `MockIDPService` with a live implementation that:
- exchanges the QR payload for a real session
- loads profile, request, and activity data from the backend
- exchanges the pairing payload and signed NFC location proof for a session token
- loads approval requests and notifications from the backend
- posts approval decisions back to `idp.global`
- syncs notification state with server-side events
- syncs session and request state between iPhone and Apple Watch, likely through a shared backend session or WatchConnectivity bridge

View File

@@ -0,0 +1,409 @@
import SwiftUI
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.black.opacity(0.08)
static let shadow = Color.black.opacity(0.05)
static let cardFill = Color.white.opacity(0.96)
static let mutedFill = Color(red: 0.972, green: 0.976, blue: 0.970)
}
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: [
Color(red: 0.975, green: 0.978, blue: 0.972),
Color.white
],
startPoint: .top,
endPoint: .bottom
)
.overlay(alignment: .top) {
Rectangle()
.fill(Color.black.opacity(0.02))
.frame(height: 160)
.blur(radius: 60)
.offset(y: -90)
}
.ignoresSafeArea()
}
}
struct AppScrollScreen<Content: View>: View {
let compactLayout: Bool
var bottomPadding: CGFloat? = nil
let content: () -> Content
init(
compactLayout: Bool,
bottomPadding: CGFloat? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.bottomPadding = bottomPadding
self.content = content
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
content()
}
.frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
.padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
.padding(.top, AppLayout.verticalPadding(for: compactLayout))
.padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
}
.scrollIndicators(.hidden)
}
}
struct AppPanel<Content: View>: View {
let compactLayout: Bool
let radius: CGFloat
let content: () -> Content
init(
compactLayout: Bool,
radius: CGFloat = AppLayout.cardRadius,
@ViewBuilder content: @escaping () -> Content
) {
self.compactLayout = compactLayout
self.radius = radius
self.content = content
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
content()
}
.padding(AppLayout.sectionPadding(for: compactLayout))
.frame(maxWidth: .infinity, alignment: .leading)
.appSurface(radius: radius)
}
}
struct AppBadge: View {
let title: String
var tone: Color = AppTheme.accent
var body: some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(tone)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(tone.opacity(0.10), in: Capsule())
}
}
struct AppSectionCard<Content: View>: View {
let title: String
var subtitle: String? = nil
let compactLayout: Bool
let content: () -> Content
init(
title: String,
subtitle: String? = nil,
compactLayout: Bool,
@ViewBuilder content: @escaping () -> Content
) {
self.title = title
self.subtitle = subtitle
self.compactLayout = compactLayout
self.content = content
}
var body: some View {
AppPanel(compactLayout: compactLayout) {
AppSectionTitle(title: title, subtitle: subtitle)
content()
}
}
}
struct AppSectionTitle: View {
let title: String
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)
}
}

View File

@@ -3,8 +3,8 @@ import Foundation
@MainActor
final class AppViewModel: ObservableObject {
@Published var suggestedQRCodePayload = ""
@Published var manualQRCodePayload = ""
@Published var suggestedPairingPayload = ""
@Published var manualPairingPayload = ""
@Published var session: AuthSession?
@Published var profile: MemberProfile?
@Published var requests: [ApprovalRequest] = []
@@ -13,11 +13,11 @@ final class AppViewModel: ObservableObject {
@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 bannerMessage: String?
@Published var errorMessage: String?
private var hasBootstrapped = false
@@ -84,13 +84,13 @@ final class AppViewModel: ObservableObject {
do {
let bootstrap = try await service.bootstrap()
suggestedQRCodePayload = bootstrap.suggestedQRCodePayload
manualQRCodePayload = bootstrap.suggestedQRCodePayload
suggestedPairingPayload = bootstrap.suggestedPairingPayload
manualPairingPayload = bootstrap.suggestedPairingPayload
notificationPermission = await notificationCoordinator.authorizationStatus()
if launchArguments.contains("--mock-auto-pair"),
session == nil {
await signIn(with: bootstrap.suggestedQRCodePayload)
await signIn(with: bootstrap.suggestedPairingPayload, transport: .preview)
if let preferredLaunchSection {
selectedSection = preferredLaunchSection
@@ -101,32 +101,52 @@ final class AppViewModel: ObservableObject {
}
}
func signInWithManualCode() async {
await signIn(with: manualQRCodePayload)
func signInWithManualPayload() async {
await signIn(with: manualPairingPayload, transport: .manual)
}
func signInWithSuggestedCode() async {
manualQRCodePayload = suggestedQRCodePayload
await signIn(with: suggestedQRCodePayload)
func signInWithSuggestedPayload() async {
manualPairingPayload = suggestedPairingPayload
await signIn(with: suggestedPairingPayload, transport: .preview)
}
func signIn(with payload: String) async {
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
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 QR payload first."
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(withQRCode: trimmed)
let result = try await service.signIn(with: normalizedRequest)
session = result.session
apply(snapshot: result.snapshot)
notificationPermission = await notificationCoordinator.authorizationStatus()
selectedSection = .overview
bannerMessage = "Paired with \(result.session.deviceName)."
errorMessage = nil
isScannerPresented = false
} catch let error as AppError {
errorMessage = error.errorDescription
@@ -135,6 +155,60 @@ final class AppViewModel: ObservableObject {
}
}
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)
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 }
@@ -144,6 +218,7 @@ final class AppViewModel: ObservableObject {
do {
let snapshot = try await service.refreshDashboard()
apply(snapshot: snapshot)
errorMessage = nil
} catch {
errorMessage = "Unable to refresh the dashboard."
}
@@ -164,18 +239,16 @@ final class AppViewModel: ObservableObject {
let snapshot = try await service.simulateIncomingRequest()
apply(snapshot: snapshot)
selectedSection = .requests
bannerMessage = "A new mock approval request arrived."
errorMessage = nil
} catch {
errorMessage = "Unable to seed a new request right now."
errorMessage = "Unable to create a mock identity check right now."
}
}
func requestNotificationAccess() async {
do {
notificationPermission = try await notificationCoordinator.requestAuthorization()
if notificationPermission == .allowed || notificationPermission == .provisional {
bannerMessage = "Notifications are ready on this device."
}
errorMessage = nil
} catch {
errorMessage = "Unable to update notification permission."
}
@@ -184,11 +257,11 @@ final class AppViewModel: ObservableObject {
func sendTestNotification() async {
do {
try await notificationCoordinator.scheduleTestNotification(
title: "idp.global approval pending",
body: "A mock request is waiting for approval in the app."
title: "idp.global identity proof requested",
body: "A mock identity proof request is waiting in the app."
)
bannerMessage = "A local test notification will appear in a few seconds."
notificationPermission = await notificationCoordinator.authorizationStatus()
errorMessage = nil
} catch {
errorMessage = "Unable to schedule a test notification."
}
@@ -198,6 +271,7 @@ final class AppViewModel: ObservableObject {
do {
let snapshot = try await service.markNotificationRead(id: notification.id)
apply(snapshot: snapshot)
errorMessage = nil
} catch {
errorMessage = "Unable to update the notification."
}
@@ -209,8 +283,8 @@ final class AppViewModel: ObservableObject {
requests = []
notifications = []
selectedSection = .overview
bannerMessage = nil
manualQRCodePayload = suggestedQRCodePayload
manualPairingPayload = suggestedPairingPayload
errorMessage = nil
}
private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
@@ -224,9 +298,9 @@ final class AppViewModel: ObservableObject {
? try await service.approveRequest(id: request.id)
: try await service.rejectRequest(id: request.id)
apply(snapshot: snapshot)
bannerMessage = approve ? "Request approved for \(request.source)." : "Request rejected for \(request.source)."
errorMessage = nil
} catch {
errorMessage = "Unable to update the request."
errorMessage = "Unable to update the identity check."
}
}

View File

@@ -7,7 +7,7 @@ struct IDPGlobalApp: App {
var body: some Scene {
WindowGroup {
RootView(model: model)
.tint(Color(red: 0.12, green: 0.40, blue: 0.31))
.tint(AppTheme.accent)
.task {
await model.bootstrap()
}
@@ -47,17 +47,8 @@ private struct RootView: View {
HomeRootView(model: model)
}
}
.background(
LinearGradient(
colors: [
Color(red: 0.96, green: 0.97, blue: 0.94),
Color(red: 0.89, green: 0.94, blue: 0.92),
Color(red: 0.94, green: 0.91, blue: 0.84)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.background {
AppBackground()
}
}
}

View File

@@ -1,3 +1,4 @@
import CryptoKit
import Foundation
enum AppSection: String, CaseIterable, Identifiable, Hashable {
@@ -58,17 +59,119 @@ enum NotificationPermissionState: String, CaseIterable, Identifiable {
case .unknown:
"The app has not asked for notification delivery yet."
case .allowed:
"Alerts can break through immediately when a request arrives."
"Identity proof alerts can break through immediately when a check arrives."
case .provisional:
"Notifications can be delivered quietly until the user promotes them."
"Identity proof alerts can be delivered quietly until the user promotes them."
case .denied:
"Approval events stay in-app until the user re-enables notifications."
"Identity proof events stay in-app until the user re-enables notifications."
}
}
}
struct BootstrapContext {
let suggestedQRCodePayload: String
let suggestedPairingPayload: String
}
enum PairingTransport: String, Hashable {
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 {
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 {
@@ -114,6 +217,8 @@ struct AuthSession: Identifiable, Hashable {
let pairedAt: Date
let tokenPreview: String
let pairingCode: String
let pairingTransport: PairingTransport
let signedGPSPosition: SignedGPSPosition?
init(
id: UUID = UUID(),
@@ -121,7 +226,9 @@ struct AuthSession: Identifiable, Hashable {
originHost: String,
pairedAt: Date,
tokenPreview: String,
pairingCode: String
pairingCode: String,
pairingTransport: PairingTransport = .manual,
signedGPSPosition: SignedGPSPosition? = nil
) {
self.id = id
self.deviceName = deviceName
@@ -129,6 +236,8 @@ struct AuthSession: Identifiable, Hashable {
self.pairedAt = pairedAt
self.tokenPreview = tokenPreview
self.pairingCode = pairingCode
self.pairingTransport = pairingTransport
self.signedGPSPosition = signedGPSPosition
}
}
@@ -139,17 +248,17 @@ enum ApprovalRequestKind: String, CaseIterable, Hashable {
var title: String {
switch self {
case .signIn: "Sign-In"
case .accessGrant: "Access Grant"
case .elevatedAction: "Elevated Action"
case .signIn: "Identity Check"
case .accessGrant: "Strong Proof"
case .elevatedAction: "Sensitive Proof"
}
}
var systemImage: String {
switch self {
case .signIn: "qrcode.viewfinder"
case .accessGrant: "key.fill"
case .elevatedAction: "shield.lefthalf.filled"
case .accessGrant: "person.badge.shield.checkmark.fill"
case .elevatedAction: "shield.checkered"
}
}
}
@@ -168,18 +277,18 @@ enum ApprovalRisk: String, Hashable {
var summary: String {
switch self {
case .routine:
"Routine access to profile or sign-in scopes."
"A familiar identity proof for a normal sign-in or check."
case .elevated:
"Sensitive access that can sign, publish, or unlock privileged actions."
"A higher-assurance identity proof for a sensitive check."
}
}
var guidance: String {
switch self {
case .routine:
"Review the origin and scope list, then approve if the session matches the device you expect."
"Review the origin and continue only if it matches the proof you started."
case .elevated:
"Treat this like a privileged operation. Verify the origin, the requested scopes, and whether the action is time-bound before approving."
"Only continue if you initiated this proof and trust the origin asking for it."
}
}
}
@@ -192,8 +301,8 @@ enum ApprovalStatus: String, Hashable {
var title: String {
switch self {
case .pending: "Pending"
case .approved: "Approved"
case .rejected: "Rejected"
case .approved: "Verified"
case .rejected: "Declined"
}
}
@@ -241,34 +350,34 @@ struct ApprovalRequest: Identifiable, Hashable {
var scopeSummary: String {
if scopes.isEmpty {
return "No scopes listed"
return "No proof details listed"
}
let suffix = scopes.count == 1 ? "" : "s"
return "\(scopes.count) requested scope\(suffix)"
return "\(scopes.count) proof detail\(suffix)"
}
var trustHeadline: String {
switch (kind, risk) {
case (.signIn, .routine):
"Low-friction sign-in request"
"Standard identity proof"
case (.signIn, .elevated):
"Privileged sign-in request"
"High-assurance sign-in proof"
case (.accessGrant, _):
"Token grant request"
"Cross-device identity proof"
case (.elevatedAction, _):
"Sensitive action request"
"Sensitive identity proof"
}
}
var trustDetail: String {
switch kind {
case .signIn:
"This request usually creates or refreshes a session token for a browser, CLI, or device."
"This request proves that the person at the browser, CLI, or device is really you."
case .accessGrant:
"This request issues scoped access for a service or automation that wants to act on your behalf."
"This request asks for a stronger proof so the relying party can trust the session with higher confidence."
case .elevatedAction:
"This request performs a privileged action such as signing, publishing, or creating short-lived credentials."
"This request asks for the highest confidence proof before continuing with a sensitive flow."
}
}
}
@@ -280,7 +389,7 @@ enum AppNotificationKind: String, Hashable {
var title: String {
switch self {
case .approval: "Approval"
case .approval: "Proof"
case .security: "Security"
case .system: "System"
}
@@ -297,9 +406,9 @@ enum AppNotificationKind: String, Hashable {
var summary: String {
switch self {
case .approval:
"Decision and approval activity"
"Identity proof activity"
case .security:
"Pairing and security posture updates"
"Passport and security posture updates"
case .system:
"Product and environment status messages"
}
@@ -332,15 +441,27 @@ struct AppNotification: Identifiable, Hashable {
}
enum AppError: LocalizedError {
case invalidQRCode
case invalidPairingPayload
case missingSignedGPSPosition
case invalidSignedGPSPosition
case locationPermissionDenied
case locationUnavailable
case requestNotFound
var errorDescription: String? {
switch self {
case .invalidQRCode:
"That QR payload is not valid for idp.global sign-in."
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 request could not be found."
"The selected identity check could not be found."
}
}
}

View File

@@ -2,7 +2,8 @@ import Foundation
protocol IDPServicing {
func bootstrap() async throws -> BootstrapContext
func signIn(withQRCode payload: String) async throws -> SignInResult
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
@@ -30,18 +31,19 @@ actor MockIDPService: IDPServicing {
func bootstrap() async throws -> BootstrapContext {
try await Task.sleep(for: .milliseconds(120))
return BootstrapContext(
suggestedQRCodePayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
suggestedPairingPayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
)
}
func signIn(withQRCode payload: String) async throws -> SignInResult {
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
try await Task.sleep(for: .milliseconds(260))
let session = try parseSession(from: payload)
try validateSignedGPSPosition(in: request)
let session = try parseSession(from: request)
notifications.insert(
AppNotification(
title: "New device paired",
message: "\(session.deviceName) completed a QR pairing against \(session.originHost).",
title: "Passport activated",
message: pairingMessage(for: session),
sentAt: .now,
kind: .security,
isUnread: true
@@ -55,6 +57,25 @@ actor MockIDPService: IDPServicing {
)
}
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
try await Task.sleep(for: .milliseconds(180))
try validateSignedGPSPosition(in: request)
let context = try parsePayloadContext(from: 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()
@@ -70,8 +91,8 @@ actor MockIDPService: IDPServicing {
requests[index].status = .approved
notifications.insert(
AppNotification(
title: "Request approved",
message: "\(requests[index].title) was approved for \(requests[index].source).",
title: "Identity verified",
message: "\(requests[index].title) was completed for \(requests[index].source).",
sentAt: .now,
kind: .approval,
isUnread: true
@@ -92,8 +113,8 @@ actor MockIDPService: IDPServicing {
requests[index].status = .rejected
notifications.insert(
AppNotification(
title: "Request rejected",
message: "\(requests[index].title) was rejected before token issuance.",
title: "Identity proof declined",
message: "\(requests[index].title) was declined before the session could continue.",
sentAt: .now,
kind: .security,
isUnread: true
@@ -108,21 +129,21 @@ actor MockIDPService: IDPServicing {
try await Task.sleep(for: .milliseconds(120))
let syntheticRequest = ApprovalRequest(
title: "Approve SSH certificate issue",
subtitle: "CI runner wants a short-lived signing certificate for a deployment pipeline.",
source: "deploy.idp.global",
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: .elevatedAction,
risk: .elevated,
scopes: ["sign:ssh", "ttl:10m", "environment:staging"],
kind: .signIn,
risk: .routine,
scopes: ["proof:basic", "client:web", "method:qr"],
status: .pending
)
requests.insert(syntheticRequest, at: 0)
notifications.insert(
AppNotification(
title: "Fresh approval request",
message: "A staging deployment is waiting for your approval.",
title: "Fresh identity proof request",
message: "A new relying party is waiting for your identity proof.",
sentAt: .now,
kind: .approval,
isUnread: true
@@ -152,7 +173,33 @@ actor MockIDPService: IDPServicing {
)
}
private func parseSession(from payload: String) throws -> AuthSession {
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 parsePayloadContext(from: 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 parsePayloadContext(from payload: String) throws -> PayloadContext {
if let components = URLComponents(string: payload),
components.scheme == "idp.global",
components.host == "pair" {
@@ -161,58 +208,88 @@ actor MockIDPService: IDPServicing {
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
return AuthSession(
return PayloadContext(
deviceName: device,
originHost: origin,
pairedAt: .now,
tokenPreview: String(token.suffix(6)),
pairingCode: payload
tokenPreview: String(token.suffix(6))
)
}
if payload.contains("token") || payload.contains("pair") {
return AuthSession(
deviceName: "Manual Pairing",
return PayloadContext(
deviceName: "Manual Session",
originHost: "code.foss.global",
pairedAt: .now,
tokenPreview: String(payload.suffix(6)),
pairingCode: payload
tokenPreview: String(payload.suffix(6))
)
}
throw AppError.invalidQRCode
throw AppError.invalidPairingPayload
}
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: PayloadContext, 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 struct PayloadContext {
let deviceName: String
let originHost: String
let tokenPreview: String
}
private static func seedRequests() -> [ApprovalRequest] {
[
ApprovalRequest(
title: "Approve Safari sign-in",
subtitle: "A browser session from Berlin wants an SSO token for the portal.",
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: ["openid", "profile", "groups:read"],
scopes: ["proof:basic", "client:web", "origin:trusted"],
status: .pending
),
ApprovalRequest(
title: "Grant package publish access",
subtitle: "The release bot is asking for a scoped publish token.",
source: "registry.foss.global",
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: .accessGrant,
kind: .elevatedAction,
risk: .elevated,
scopes: ["packages:write", "ttl:30m"],
scopes: ["proof:high", "client:desktop", "presence:required"],
status: .pending
),
ApprovalRequest(
title: "Approve CLI login",
subtitle: "A terminal session completed QR pairing earlier today.",
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: ["openid", "profile"],
scopes: ["proof:basic", "client:cli"],
status: .approved
)
]
@@ -221,8 +298,8 @@ actor MockIDPService: IDPServicing {
private static func seedNotifications() -> [AppNotification] {
[
AppNotification(
title: "Two requests are waiting",
message: "The queue includes one routine sign-in and one elevated access grant.",
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
@@ -235,8 +312,8 @@ actor MockIDPService: IDPServicing {
isUnread: false
),
AppNotification(
title: "Quiet hours active on mobile",
message: "Routine notifications will be delivered silently until the morning.",
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

View File

@@ -1,29 +1,26 @@
import SwiftUI
private let loginAccent = Color(red: 0.12, green: 0.40, blue: 0.31)
private let loginGold = Color(red: 0.90, green: 0.79, blue: 0.60)
private let loginAccent = AppTheme.accent
struct LoginRootView: View {
@ObservedObject var model: AppViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
ScrollView {
VStack(spacing: compactLayout ? 18 : 24) {
LoginHeroPanel(model: model, compactLayout: compactLayout)
PairingConsoleCard(model: model, compactLayout: compactLayout)
TrustFootprintCard(model: model, compactLayout: compactLayout)
}
.frame(maxWidth: 1040)
.padding(compactLayout ? 18 : 28)
AppScrollScreen(compactLayout: compactLayout) {
LoginHeroPanel(model: model, compactLayout: compactLayout)
PairingConsoleCard(model: model, compactLayout: compactLayout)
}
.sheet(isPresented: $model.isScannerPresented) {
QRScannerSheet(
seededPayload: model.suggestedQRCodePayload,
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.manualQRCodePayload = payload
model.manualPairingPayload = payload
Task {
await model.signIn(with: payload)
await model.signIn(with: payload, transport: .qr)
}
}
)
@@ -44,51 +41,49 @@ private struct LoginHeroPanel: View {
let compactLayout: Bool
var body: some View {
ZStack(alignment: .bottomLeading) {
RoundedRectangle(cornerRadius: 36, style: .continuous)
.fill(
LinearGradient(
colors: [
Color(red: 0.13, green: 0.22, blue: 0.19),
Color(red: 0.20, green: 0.41, blue: 0.33),
loginGold
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
AppBadge(title: "Secure passport setup", tone: loginAccent)
VStack(alignment: .leading, spacing: compactLayout ? 16 : 18) {
Text("Bind this device to your idp.global account")
.font(.system(size: compactLayout ? 32 : 44, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("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 the pairing QR from your account to turn this device into your approval and notification app.")
.font(compactLayout ? .body : .title3)
.foregroundStyle(.white.opacity(0.88))
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)
if compactLayout {
VStack(alignment: .leading, spacing: 10) {
HeroTag(title: "Account binding")
HeroTag(title: "QR pairing")
HeroTag(title: "iPhone, iPad, Mac")
}
} else {
HStack(spacing: 12) {
HeroTag(title: "Account binding")
HeroTag(title: "QR pairing")
HeroTag(title: "iPhone, iPad, Mac")
}
}
Divider()
if model.isBootstrapping {
ProgressView("Preparing preview pairing payload…")
.tint(.white)
}
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)
}
.padding(compactLayout ? 22 : 32)
}
.frame(minHeight: compactLayout ? 280 : 320)
}
}
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)
}
}
}
@@ -97,46 +92,41 @@ private struct PairingConsoleCard: View {
let compactLayout: Bool
var body: some View {
LoginCard(title: "Bind your account", subtitle: "Scan the QR code from your idp.global account or use the preview payload while backend wiring is still in progress.") {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Open your account pairing screen, then scan the QR code here.")
.font(.headline)
Text("If you are testing the preview build without the live backend yet, the seeded payload below will still bind the mock session.")
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)
}
}
TextEditor(text: $model.manualQRCodePayload)
.font(.body.monospaced())
.scrollContentBackground(.hidden)
.padding(16)
.frame(minHeight: compactLayout ? 130 : 150)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
Text("NFC, QR, and OTP proof methods become available after this passport is active.")
.font(.footnote)
.foregroundStyle(.secondary)
if model.isAuthenticating {
HStack(spacing: 10) {
ProgressView()
Text("Binding this device to your account…")
.foregroundStyle(.secondary)
}
if compactLayout {
VStack(spacing: 12) {
primaryButtons
secondaryButtons
}
Group {
if compactLayout {
VStack(spacing: 12) {
primaryButtons
secondaryButtons
}
} else {
VStack(spacing: 12) {
HStack(spacing: 12) {
primaryButtons
}
HStack(spacing: 12) {
secondaryButtons
}
}
} else {
VStack(spacing: 12) {
HStack(spacing: 12) {
primaryButtons
}
secondaryButtons
}
}
}
@@ -147,154 +137,57 @@ private struct PairingConsoleCard: View {
Button {
model.isScannerPresented = true
} label: {
Label("Bind With QR Code", systemImage: "qrcode.viewfinder")
Label("Scan QR", systemImage: "qrcode.viewfinder")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
Task {
await model.signInWithManualCode()
}
} label: {
if model.isAuthenticating {
ProgressView()
} else {
Label("Bind With Payload", systemImage: "arrow.right.circle.fill")
}
}
.buttonStyle(.bordered)
.disabled(model.isAuthenticating)
.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.signInWithSuggestedCode()
await model.signInWithManualPayload()
}
} label: {
Label("Use Preview QR", systemImage: "wand.and.stars")
if model.isAuthenticating {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Label("Link with payload", systemImage: "arrow.right.circle")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.bordered)
Text("This preview keeps the account-binding flow realistic while the live API is still being wired in.")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .trailing)
.controlSize(.large)
.disabled(model.isAuthenticating)
}
}
private struct TrustFootprintCard: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
LoginCard(title: "About this build", subtitle: "Keep the first-run screen simple, but still explain the trust context and preview status clearly.") {
VStack(alignment: .leading, spacing: 16) {
if compactLayout {
VStack(spacing: 12) {
trustFacts
}
} else {
HStack(alignment: .top, spacing: 12) {
trustFacts
}
}
VStack(alignment: .leading, spacing: 8) {
Text("Preview Pairing Payload")
.font(.headline)
Text(model.suggestedQRCodePayload.isEmpty ? "Preparing preview payload…" : model.suggestedQRCodePayload)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
}
private var previewPayloadButton: some View {
Button {
Task {
await model.signInWithSuggestedPayload()
}
} label: {
Label("Use preview passport", systemImage: "wand.and.stars")
.frame(maxWidth: .infinity)
}
}
@ViewBuilder
private var trustFacts: some View {
TrustFactCard(
icon: "person.badge.key.fill",
title: "Account Binding",
message: "This device binds to your idp.global account and becomes your place for approvals and alerts."
)
TrustFactCard(
icon: "person.2.badge.gearshape.fill",
title: "Built by foss.global",
message: "foss.global is the open-source collective behind idp.global and the current preview environment."
)
TrustFactCard(
icon: "bolt.badge.clock",
title: "Preview Backend",
message: "Login, requests, and notifications are mocked behind a clean service boundary until live integration is ready."
)
}
}
private struct LoginCard<Content: View>: View {
let title: String
let subtitle: String
let content: () -> Content
init(title: String, subtitle: String, @ViewBuilder content: @escaping () -> Content) {
self.title = title
self.subtitle = subtitle
self.content = content
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.title2.weight(.semibold))
Text(subtitle)
.foregroundStyle(.secondary)
}
content()
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white.opacity(0.68), in: RoundedRectangle(cornerRadius: 32, style: .continuous))
}
}
private struct HeroTag: View {
let title: String
var body: some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(.white.opacity(0.14), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
private struct TrustFactCard: View {
let icon: String
let title: String
let message: String
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(loginAccent)
Text(title)
.font(.headline)
Text(message)
.foregroundStyle(.secondary)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
.buttonStyle(.bordered)
.controlSize(.large)
}
}

View File

@@ -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

View File

@@ -9,56 +9,58 @@ import AppKit
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 {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Use the camera to scan the QR code shown by the web portal. If youre on a simulator or desktop without a camera, the seeded payload works as a mock fallback.")
AppScrollScreen(compactLayout: compactLayout) {
AppSectionCard(title: title, compactLayout: compactLayout) {
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
LiveQRScannerView(onCodeScanned: onCodeScanned)
.frame(minHeight: 340)
}
VStack(alignment: .leading, spacing: 12) {
Text("Fallback Pairing Payload")
.font(.headline)
TextEditor(text: $manualFallback)
.font(.body.monospaced())
.scrollContentBackground(.hidden)
.padding(14)
.frame(minHeight: 120)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
AppSectionCard(title: "Manual fallback", compactLayout: compactLayout) {
AppTextEditorField(text: $manualFallback, minHeight: 120)
if compactLayout {
VStack(spacing: 12) {
useFallbackButton
useSeededButton
}
} else {
HStack(spacing: 12) {
Button {
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
onCodeScanned(chosen.isEmpty ? seededPayload : chosen)
dismiss()
} label: {
Label("Use Fallback Payload", systemImage: "arrow.up.forward.square")
}
.buttonStyle(.borderedProminent)
Button {
manualFallback = seededPayload
} label: {
Label("Use Seeded Mock", systemImage: "wand.and.rays")
}
.buttonStyle(.bordered)
useFallbackButton
useSeededButton
}
}
.padding(20)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
}
.padding(24)
}
.navigationTitle("Scan QR Code")
.navigationTitle(navigationTitleText)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
@@ -71,6 +73,36 @@ struct QRScannerSheet: View {
}
}
}
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 {

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
}
)
}
}

View File

@@ -0,0 +1,479 @@
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) {
AppPanel(compactLayout: true, radius: 22) {
AppBadge(title: "Preview passport", tone: watchAccent)
Text("Prove identity from your wrist")
.font(.title3.weight(.semibold))
Text("This preview connects directly to the mock service today.")
.font(.footnote)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
AppStatusTag(title: "Wrist-ready", tone: watchAccent)
AppStatusTag(title: "Preview sync", tone: watchGold)
}
}
if model.isBootstrapping {
ProgressView("Preparing preview passport...")
.frame(maxWidth: .infinity, alignment: .leading)
}
Button {
Task {
await model.signInWithSuggestedPayload()
}
} label: {
if model.isAuthenticating {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Label("Use Preview Passport", systemImage: "qrcode")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.disabled(model.isBootstrapping || model.suggestedPairingPayload.isEmpty || model.isAuthenticating)
AppPanel(compactLayout: true, radius: 18) {
Text("What works today")
.font(.headline)
Text("The watch shows pending identity checks, recent alerts, and quick actions.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 8)
.padding(.bottom, 20)
}
.navigationTitle("Set Up Watch")
}
}
private struct WatchInfoPill: View {
let title: String
let value: String
let tone: Color
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
Text(value)
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(tone.opacity(0.10), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
private struct WatchDashboardView: View {
@ObservedObject var model: AppViewModel
var body: some View {
List {
Section {
WatchPassportCard(model: model)
}
Section("Pending") {
if model.pendingRequests.isEmpty {
Text("No checks waiting.")
.foregroundStyle(.secondary)
Button("Seed Identity Check") {
Task {
await model.simulateIncomingRequest()
}
}
} else {
ForEach(model.pendingRequests) { request in
NavigationLink {
WatchRequestDetailView(model: model, requestID: request.id)
} label: {
WatchRequestRow(request: request)
}
}
}
}
Section("Recent Activity") {
if model.notifications.isEmpty {
Text("No recent alerts.")
.foregroundStyle(.secondary)
} else {
ForEach(model.notifications.prefix(3)) { notification in
NavigationLink {
WatchNotificationDetailView(model: model, notificationID: notification.id)
} label: {
WatchNotificationRow(notification: notification)
}
}
}
}
Section("Actions") {
Button("Refresh") {
Task {
await model.refreshDashboard()
}
}
.disabled(model.isRefreshing)
Button("Send Test Alert") {
Task {
await model.sendTestNotification()
}
}
if model.notificationPermission == .unknown || model.notificationPermission == .denied {
Button("Enable Alerts") {
Task {
await model.requestNotificationAccess()
}
}
}
}
Section("Account") {
if let profile = model.profile {
VStack(alignment: .leading, spacing: 4) {
Text(profile.handle)
.font(.headline)
Text(profile.organization)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("Notifications")
.font(.headline)
Text(model.notificationPermission.title)
.font(.footnote)
.foregroundStyle(.secondary)
}
Button("Sign Out", role: .destructive) {
model.signOut()
}
}
}
.navigationTitle("Passport")
.refreshable {
await model.refreshDashboard()
}
}
}
private struct WatchPassportCard: View {
@ObservedObject var model: AppViewModel
var body: some View {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(model.profile?.name ?? "Preview Session")
.font(.headline)
Text(model.pairedDeviceSummary)
.font(.footnote)
.foregroundStyle(.secondary)
if let session = model.session {
Text("Via \(session.pairingTransport.title)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
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())
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
}
.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)
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(.secondary)
Text(request.createdAt.watchRelativeString)
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}
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)
Spacer(minLength: 6)
if notification.isUnread {
Circle()
.fill(watchAccent)
.frame(width: 8, height: 8)
}
}
Text(notification.message)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
Text(notification.sentAt.watchRelativeString)
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}
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
}()
}