diff --git a/.gitignore b/.gitignore
index e8c1e30..b5a1f02 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.DS_Store
+agentcomms/
build/
DerivedData/
xcuserdata/
diff --git a/Assets.xcassets/AppIcon.appiconset/Contents.json b/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..fedfcea
--- /dev/null
+++ b/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -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
+ }
+}
diff --git a/Assets.xcassets/AppIcon.appiconset/ios-marketing-1024@1x.png b/Assets.xcassets/AppIcon.appiconset/ios-marketing-1024@1x.png
new file mode 100644
index 0000000..0b0e825
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/ios-marketing-1024@1x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/ipad-20@1x.png b/Assets.xcassets/AppIcon.appiconset/ipad-20@1x.png
new file mode 100644
index 0000000..5489647
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/ipad-20@1x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/ipad-20@2x.png b/Assets.xcassets/AppIcon.appiconset/ipad-20@2x.png
new file mode 100644
index 0000000..e650447
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/ipad-20@2x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/ipad-29@1x.png b/Assets.xcassets/AppIcon.appiconset/ipad-29@1x.png
new file mode 100644
index 0000000..1ba88e3
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/ipad-29@1x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/ipad-29@2x.png b/Assets.xcassets/AppIcon.appiconset/ipad-29@2x.png
new file mode 100644
index 0000000..33e0f68
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/ipad-29@2x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/ipad-40@1x.png b/Assets.xcassets/AppIcon.appiconset/ipad-40@1x.png
new file mode 100644
index 0000000..e650447
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/ipad-40@1x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/ipad-40@2x.png b/Assets.xcassets/AppIcon.appiconset/ipad-40@2x.png
new file mode 100644
index 0000000..03e3ae1
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/ipad-40@2x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/ipad-76@1x.png b/Assets.xcassets/AppIcon.appiconset/ipad-76@1x.png
new file mode 100644
index 0000000..a8b80ce
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/ipad-76@1x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/ipad-76@2x.png b/Assets.xcassets/AppIcon.appiconset/ipad-76@2x.png
new file mode 100644
index 0000000..69ac8f0
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/ipad-76@2x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/ipad-83.5@2x.png b/Assets.xcassets/AppIcon.appiconset/ipad-83.5@2x.png
new file mode 100644
index 0000000..0c93e1e
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/ipad-83.5@2x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/iphone-20@2x.png b/Assets.xcassets/AppIcon.appiconset/iphone-20@2x.png
new file mode 100644
index 0000000..e650447
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/iphone-20@2x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/iphone-20@3x.png b/Assets.xcassets/AppIcon.appiconset/iphone-20@3x.png
new file mode 100644
index 0000000..c8a6195
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/iphone-20@3x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/iphone-29@2x.png b/Assets.xcassets/AppIcon.appiconset/iphone-29@2x.png
new file mode 100644
index 0000000..33e0f68
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/iphone-29@2x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/iphone-29@3x.png b/Assets.xcassets/AppIcon.appiconset/iphone-29@3x.png
new file mode 100644
index 0000000..4559b19
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/iphone-29@3x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/iphone-40@2x.png b/Assets.xcassets/AppIcon.appiconset/iphone-40@2x.png
new file mode 100644
index 0000000..03e3ae1
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/iphone-40@2x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/iphone-40@3x.png b/Assets.xcassets/AppIcon.appiconset/iphone-40@3x.png
new file mode 100644
index 0000000..22dfc7b
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/iphone-40@3x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/iphone-60@2x.png b/Assets.xcassets/AppIcon.appiconset/iphone-60@2x.png
new file mode 100644
index 0000000..22dfc7b
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/iphone-60@2x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/iphone-60@3x.png b/Assets.xcassets/AppIcon.appiconset/iphone-60@3x.png
new file mode 100644
index 0000000..2d7ffde
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/iphone-60@3x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/mac-128@1x.png b/Assets.xcassets/AppIcon.appiconset/mac-128@1x.png
new file mode 100644
index 0000000..1ecb20c
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/mac-128@1x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/mac-128@2x.png b/Assets.xcassets/AppIcon.appiconset/mac-128@2x.png
new file mode 100644
index 0000000..6bb6d5f
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/mac-128@2x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/mac-16@1x.png b/Assets.xcassets/AppIcon.appiconset/mac-16@1x.png
new file mode 100644
index 0000000..65f121a
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/mac-16@1x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/mac-16@2x.png b/Assets.xcassets/AppIcon.appiconset/mac-16@2x.png
new file mode 100644
index 0000000..f7be521
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/mac-16@2x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/mac-256@1x.png b/Assets.xcassets/AppIcon.appiconset/mac-256@1x.png
new file mode 100644
index 0000000..6bb6d5f
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/mac-256@1x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/mac-256@2x.png b/Assets.xcassets/AppIcon.appiconset/mac-256@2x.png
new file mode 100644
index 0000000..d8d35f2
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/mac-256@2x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/mac-32@1x.png b/Assets.xcassets/AppIcon.appiconset/mac-32@1x.png
new file mode 100644
index 0000000..f7be521
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/mac-32@1x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/mac-32@2x.png b/Assets.xcassets/AppIcon.appiconset/mac-32@2x.png
new file mode 100644
index 0000000..d1b8c58
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/mac-32@2x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/mac-512@1x.png b/Assets.xcassets/AppIcon.appiconset/mac-512@1x.png
new file mode 100644
index 0000000..d8d35f2
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/mac-512@1x.png differ
diff --git a/Assets.xcassets/AppIcon.appiconset/mac-512@2x.png b/Assets.xcassets/AppIcon.appiconset/mac-512@2x.png
new file mode 100644
index 0000000..0b0e825
Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/mac-512@2x.png differ
diff --git a/Assets.xcassets/Contents.json b/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/IDPGlobal.entitlements b/IDPGlobal.entitlements
new file mode 100644
index 0000000..5f7b942
--- /dev/null
+++ b/IDPGlobal.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.developer.nfc.readersession.formats
+
+ NDEF
+
+
+
diff --git a/IDPGlobal.xcodeproj/project.pbxproj b/IDPGlobal.xcodeproj/project.pbxproj
index 575f3f6..7ec2d5b 100644
--- a/IDPGlobal.xcodeproj/project.pbxproj
+++ b/IDPGlobal.xcodeproj/project.pbxproj
@@ -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 = ""; };
B20000000000000000000002 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = ""; };
@@ -27,6 +62,12 @@
B20000000000000000000007 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = ""; };
B20000000000000000000008 /* HomeRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeRootView.swift; sourceTree = ""; };
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 = ""; };
+ B2000000000000000000000C /* WatchRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchRootView.swift; sourceTree = ""; };
+ B2000000000000000000000D /* NFCPairingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCPairingView.swift; sourceTree = ""; };
+ B2000000000000000000000E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ B2000000000000000000000F /* AppComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppComponents.swift; sourceTree = ""; };
/* 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 = "";
@@ -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 = "";
@@ -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 = "";
};
+ B4000000000000000000000C /* WatchApp */ = {
+ isa = PBXGroup;
+ children = (
+ B4000000000000000000000D /* App */,
+ B4000000000000000000000E /* Features */,
+ );
+ path = WatchApp;
+ sourceTree = "";
+ };
+ B4000000000000000000000D /* App */ = {
+ isa = PBXGroup;
+ children = (
+ B2000000000000000000000B /* IDPGlobalWatchApp.swift */,
+ );
+ path = App;
+ sourceTree = "";
+ };
+ B4000000000000000000000E /* Features */ = {
+ isa = PBXGroup;
+ children = (
+ B2000000000000000000000C /* WatchRootView.swift */,
+ );
+ path = Features;
+ sourceTree = "";
+ };
/* 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 */;
diff --git a/README.md b/README.md
index c190411..6bd28a8 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/Sources/App/AppComponents.swift b/Sources/App/AppComponents.swift
new file mode 100644
index 0000000..9f2f432
--- /dev/null
+++ b/Sources/App/AppComponents.swift
@@ -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: View {
+ let compactLayout: Bool
+ var bottomPadding: CGFloat? = nil
+ let content: () -> Content
+
+ init(
+ compactLayout: Bool,
+ bottomPadding: CGFloat? = nil,
+ @ViewBuilder content: @escaping () -> Content
+ ) {
+ self.compactLayout = compactLayout
+ self.bottomPadding = bottomPadding
+ self.content = content
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
+ content()
+ }
+ .frame(maxWidth: AppLayout.contentWidth(for: compactLayout), alignment: .leading)
+ .padding(.horizontal, AppLayout.horizontalPadding(for: compactLayout))
+ .padding(.top, AppLayout.verticalPadding(for: compactLayout))
+ .padding(.bottom, bottomPadding ?? AppLayout.verticalPadding(for: compactLayout))
+ .frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
+ }
+ .scrollIndicators(.hidden)
+ }
+}
+
+struct AppPanel: View {
+ let compactLayout: Bool
+ let radius: CGFloat
+ let content: () -> Content
+
+ init(
+ compactLayout: Bool,
+ radius: CGFloat = AppLayout.cardRadius,
+ @ViewBuilder content: @escaping () -> Content
+ ) {
+ self.compactLayout = compactLayout
+ self.radius = radius
+ self.content = content
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ content()
+ }
+ .padding(AppLayout.sectionPadding(for: compactLayout))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .appSurface(radius: radius)
+ }
+}
+
+struct AppBadge: View {
+ let title: String
+ var tone: Color = AppTheme.accent
+
+ var body: some View {
+ Text(title)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(tone)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(tone.opacity(0.10), in: Capsule())
+ }
+}
+
+struct AppSectionCard: View {
+ let title: String
+ var subtitle: String? = nil
+ let compactLayout: Bool
+ let content: () -> Content
+
+ init(
+ title: String,
+ subtitle: String? = nil,
+ compactLayout: Bool,
+ @ViewBuilder content: @escaping () -> Content
+ ) {
+ self.title = title
+ self.subtitle = subtitle
+ self.compactLayout = compactLayout
+ self.content = content
+ }
+
+ var body: some View {
+ AppPanel(compactLayout: compactLayout) {
+ AppSectionTitle(title: title, subtitle: subtitle)
+ content()
+ }
+ }
+}
+
+struct AppSectionTitle: View {
+ let title: String
+ 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)
+ }
+}
diff --git a/Sources/App/AppViewModel.swift b/Sources/App/AppViewModel.swift
index cb7e37e..79affd5 100644
--- a/Sources/App/AppViewModel.swift
+++ b/Sources/App/AppViewModel.swift
@@ -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."
}
}
diff --git a/Sources/App/IDPGlobalApp.swift b/Sources/App/IDPGlobalApp.swift
index 442bd8c..306b11a 100644
--- a/Sources/App/IDPGlobalApp.swift
+++ b/Sources/App/IDPGlobalApp.swift
@@ -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()
+ }
}
}
diff --git a/Sources/Core/Models/AppModels.swift b/Sources/Core/Models/AppModels.swift
index 5f84dd6..326fa49 100644
--- a/Sources/Core/Models/AppModels.swift
+++ b/Sources/Core/Models/AppModels.swift
@@ -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."
}
}
}
diff --git a/Sources/Core/Services/MockIDPService.swift b/Sources/Core/Services/MockIDPService.swift
index 5723449..5c90650 100644
--- a/Sources/Core/Services/MockIDPService.swift
+++ b/Sources/Core/Services/MockIDPService.swift
@@ -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
diff --git a/Sources/Features/Auth/LoginRootView.swift b/Sources/Features/Auth/LoginRootView.swift
index d2e54d3..24d7365 100644
--- a/Sources/Features/Auth/LoginRootView.swift
+++ b/Sources/Features/Auth/LoginRootView.swift
@@ -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: 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)
}
}
diff --git a/Sources/Features/Auth/NFCPairingView.swift b/Sources/Features/Auth/NFCPairingView.swift
new file mode 100644
index 0000000..a38bb68
--- /dev/null
+++ b/Sources/Features/Auth/NFCPairingView.swift
@@ -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?
+ private var locationContinuation: CheckedContinuation?
+
+ 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
diff --git a/Sources/Features/Auth/QRScannerView.swift b/Sources/Features/Auth/QRScannerView.swift
index 567e224..b13d7b9 100644
--- a/Sources/Features/Auth/QRScannerView.swift
+++ b/Sources/Features/Auth/QRScannerView.swift
@@ -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 you’re 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 {
diff --git a/Sources/Features/Home/HomeRootView.swift b/Sources/Features/Home/HomeRootView.swift
index 5c1051b..519dbfb 100644
--- a/Sources/Features/Home/HomeRootView.swift
+++ b/Sources/Features/Home/HomeRootView.swift
@@ -1,55 +1,40 @@
+import CryptoKit
+import Foundation
import SwiftUI
-private let dashboardAccent = Color(red: 0.12, green: 0.40, blue: 0.31)
-private let dashboardGold = Color(red: 0.84, green: 0.71, blue: 0.48)
-private let dashboardBorder = Color.black.opacity(0.06)
-private let dashboardShadow = Color.black.opacity(0.05)
-
-private enum DashboardSpacing {
- static let compactOuterPadding: CGFloat = 16
- static let regularOuterPadding: CGFloat = 28
- static let compactTopPadding: CGFloat = 10
- static let regularTopPadding: CGFloat = 18
- static let compactBottomPadding: CGFloat = 120
- static let regularBottomPadding: CGFloat = 56
- static let compactStackSpacing: CGFloat = 20
- static let regularStackSpacing: CGFloat = 28
- static let compactContentWidth: CGFloat = 720
- static let regularContentWidth: CGFloat = 980
- static let compactSectionPadding: CGFloat = 18
- static let regularSectionPadding: CGFloat = 24
- static let compactRadius: CGFloat = 24
- static let regularRadius: CGFloat = 28
-}
+private let dashboardAccent = AppTheme.accent
+private let dashboardGold = AppTheme.warmAccent
private extension View {
- func dashboardSurface(radius: CGFloat, fillOpacity: Double = 0.88) -> some View {
- background(
- Color.white.opacity(fillOpacity),
- in: RoundedRectangle(cornerRadius: radius, style: .continuous)
- )
- .overlay(
- RoundedRectangle(cornerRadius: radius, style: .continuous)
- .stroke(dashboardBorder, lineWidth: 1)
- )
- .shadow(color: dashboardShadow, radius: 14, y: 6)
+ @ViewBuilder
+ func inlineNavigationTitleOnIOS() -> some View {
+ #if os(iOS)
+ navigationBarTitleDisplayMode(.inline)
+ #else
+ self
+ #endif
+ }
+
+ @ViewBuilder
+ func cleanTabBarOnIOS() -> some View {
+ #if os(iOS)
+ toolbarBackground(.visible, for: .tabBar)
+ .toolbarBackground(Color.white.opacity(0.98), for: .tabBar)
+ #else
+ self
+ #endif
}
}
struct HomeRootView: View {
@ObservedObject var model: AppViewModel
- @Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
- ZStack {
- DashboardBackdrop()
-
- Group {
- if usesCompactNavigation {
- CompactHomeContainer(model: model)
- } else {
- RegularHomeContainer(model: model)
- }
+ Group {
+ if usesCompactNavigation {
+ CompactHomeContainer(model: model)
+ } else {
+ RegularHomeContainer(model: model)
}
}
.sheet(isPresented: $model.isNotificationCenterPresented) {
@@ -59,7 +44,7 @@ struct HomeRootView: View {
private var usesCompactNavigation: Bool {
#if os(iOS)
- horizontalSizeClass == .compact
+ true
#else
false
#endif
@@ -68,29 +53,34 @@ struct HomeRootView: View {
private struct CompactHomeContainer: View {
@ObservedObject var model: AppViewModel
+ @Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
TabView(selection: $model.selectedSection) {
- compactTab(for: .overview)
- compactTab(for: .requests)
- compactTab(for: .activity)
- compactTab(for: .account)
+ ForEach(AppSection.allCases) { section in
+ NavigationStack {
+ HomeSectionScreen(model: model, section: section, compactLayout: compactLayout)
+ .navigationTitle(section.title)
+ .inlineNavigationTitleOnIOS()
+ .toolbar {
+ DashboardToolbar(model: model)
+ }
+ }
+ .tag(section)
+ .tabItem {
+ Label(section.title, systemImage: section.systemImage)
+ }
+ }
}
+ .cleanTabBarOnIOS()
}
- @ViewBuilder
- private func compactTab(for section: AppSection) -> some View {
- NavigationStack {
- HomeSectionScreen(model: model, section: section, compactLayout: true)
- .navigationTitle(section.title)
- .toolbar {
- DashboardToolbar(model: model, compactLayout: true)
- }
- }
- .tag(section)
- .tabItem {
- Label(section.title, systemImage: section.systemImage)
- }
+ private var compactLayout: Bool {
+ #if os(iOS)
+ horizontalSizeClass == .compact
+ #else
+ false
+ #endif
}
}
@@ -104,7 +94,7 @@ private struct RegularHomeContainer: View {
HomeSectionScreen(model: model, section: model.selectedSection, compactLayout: false)
.navigationTitle(model.selectedSection.title)
.toolbar {
- DashboardToolbar(model: model, compactLayout: false)
+ DashboardToolbar(model: model)
}
}
.navigationSplitViewStyle(.balanced)
@@ -113,70 +103,10 @@ private struct RegularHomeContainer: View {
private struct DashboardToolbar: ToolbarContent {
@ObservedObject var model: AppViewModel
- let compactLayout: Bool
var body: some ToolbarContent {
- if compactLayout {
- ToolbarItemGroup(placement: .primaryAction) {
- NotificationBellButton(model: model)
-
- Menu {
- Button {
- Task {
- await model.refreshDashboard()
- }
- } label: {
- Label("Refresh", systemImage: "arrow.clockwise")
- }
-
- Button {
- Task {
- await model.simulateIncomingRequest()
- }
- } label: {
- Label("Mock Request", systemImage: "sparkles.rectangle.stack.fill")
- }
-
- Button {
- Task {
- await model.sendTestNotification()
- }
- } label: {
- Label("Send Test Alert", systemImage: "paperplane.fill")
- }
- } label: {
- Image(systemName: "ellipsis.circle")
- }
- }
- } else {
- ToolbarItemGroup(placement: .primaryAction) {
- NotificationBellButton(model: model)
-
- Button {
- Task {
- await model.refreshDashboard()
- }
- } label: {
- Label("Refresh", systemImage: "arrow.clockwise")
- }
- .disabled(model.isRefreshing)
-
- Button {
- Task {
- await model.simulateIncomingRequest()
- }
- } label: {
- Label("Mock Request", systemImage: "sparkles.rectangle.stack.fill")
- }
-
- Button {
- Task {
- await model.sendTestNotification()
- }
- } label: {
- Label("Test Alert", systemImage: "paperplane.fill")
- }
- }
+ ToolbarItemGroup(placement: .primaryAction) {
+ NotificationBellButton(model: model)
}
}
}
@@ -187,47 +117,124 @@ private struct HomeSectionScreen: View {
let compactLayout: Bool
@State private var focusedRequest: ApprovalRequest?
+ @State private var isOTPPresented = false
+ @StateObject private var identifyReader = NFCIdentifyReader()
var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: compactLayout ? DashboardSpacing.compactStackSpacing : DashboardSpacing.regularStackSpacing) {
- if let banner = model.bannerMessage {
- BannerCard(message: banner, compactLayout: compactLayout)
- }
+ AppScrollScreen(
+ compactLayout: compactLayout,
+ bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
+ ) {
+ HomeTopActions(
+ model: model,
+ identifyReader: identifyReader,
+ onScanQR: { model.isScannerPresented = true },
+ onShowOTP: { isOTPPresented = true }
+ )
- switch section {
- case .overview:
- OverviewPanel(
- model: model,
- compactLayout: compactLayout,
- onOpenRequest: { focusedRequest = $0 }
- )
- case .requests:
- RequestsPanel(
- model: model,
- compactLayout: compactLayout,
- onOpenRequest: { focusedRequest = $0 }
- )
- case .activity:
- ActivityPanel(
- model: model,
- compactLayout: compactLayout,
- onOpenRequest: { focusedRequest = $0 }
- )
- case .account:
- AccountPanel(model: model, compactLayout: compactLayout)
+ switch section {
+ case .overview:
+ OverviewPanel(model: model, compactLayout: compactLayout)
+ case .requests:
+ RequestsPanel(model: model, compactLayout: compactLayout, onOpenRequest: { focusedRequest = $0 })
+ case .activity:
+ ActivityPanel(model: model, compactLayout: compactLayout)
+ case .account:
+ AccountPanel(model: model, compactLayout: compactLayout)
+ }
+ }
+ .task {
+ identifyReader.onAuthenticationRequestDetected = { request in
+ Task {
+ await model.identifyWithNFC(request)
}
}
- .padding(.horizontal, compactLayout ? DashboardSpacing.compactOuterPadding : DashboardSpacing.regularOuterPadding)
- .padding(.top, compactLayout ? DashboardSpacing.compactTopPadding : DashboardSpacing.regularTopPadding)
- .padding(.bottom, compactLayout ? DashboardSpacing.compactBottomPadding : DashboardSpacing.regularBottomPadding)
- .frame(maxWidth: compactLayout ? DashboardSpacing.compactContentWidth : DashboardSpacing.regularContentWidth, alignment: .leading)
- .frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
+
+ identifyReader.onError = { message in
+ model.errorMessage = message
+ }
}
- .scrollIndicators(.hidden)
.sheet(item: $focusedRequest) { request in
RequestDetailSheet(request: request, model: model)
}
+ .sheet(isPresented: $model.isScannerPresented) {
+ QRScannerSheet(
+ seededPayload: model.session?.pairingCode ?? model.suggestedPairingPayload,
+ title: "Scan proof QR",
+ description: "Use the camera to scan an idp.global QR challenge from the site or device asking you to prove that it is really you.",
+ navigationTitle: "Scan Proof QR",
+ onCodeScanned: { payload in
+ Task {
+ await model.identifyWithPayload(payload, transport: .qr)
+ }
+ }
+ )
+ }
+ .sheet(isPresented: $isOTPPresented) {
+ if let session = model.session {
+ OneTimePasscodeSheet(session: session)
+ }
+ }
+ }
+}
+
+private struct HomeTopActions: View {
+ @ObservedObject var model: AppViewModel
+ @ObservedObject var identifyReader: NFCIdentifyReader
+ let onScanQR: () -> Void
+ let onShowOTP: () -> Void
+
+ var body: some View {
+ LazyVGrid(columns: columns, spacing: 12) {
+ identifyButton
+ qrButton
+ otpButton
+ }
+ }
+
+ private var columns: [GridItem] {
+ Array(repeating: GridItem(.flexible(), spacing: 12), count: 3)
+ }
+
+ private var identifyButton: some View {
+ Button {
+ identifyReader.beginScanning()
+ } label: {
+ AppActionTile(
+ title: identifyReader.isScanning ? "Scanning NFC" : "Tap NFC",
+ systemImage: "dot.radiowaves.left.and.right",
+ tone: dashboardAccent,
+ isBusy: identifyReader.isScanning || model.isIdentifying
+ )
+ }
+ .buttonStyle(.plain)
+ .disabled(identifyReader.isScanning || !identifyReader.isSupported || model.isIdentifying)
+ }
+
+ private var qrButton: some View {
+ Button {
+ onScanQR()
+ } label: {
+ AppActionTile(
+ title: "Scan QR",
+ systemImage: "qrcode.viewfinder",
+ tone: dashboardAccent
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ private var otpButton: some View {
+ Button {
+ onShowOTP()
+ } label: {
+ AppActionTile(
+ title: "OTP",
+ systemImage: "number.square.fill",
+ tone: dashboardGold
+ )
+ }
+ .buttonStyle(.plain)
}
}
@@ -246,7 +253,23 @@ private struct Sidebar: View {
Section("Workspace") {
ForEach(AppSection.allCases) { section in
- sidebarRow(for: section)
+ Button {
+ model.selectedSection = section
+ } label: {
+ HStack {
+ Label(section.title, systemImage: section.systemImage)
+ Spacer()
+ if badgeCount(for: section) > 0 {
+ AppStatusTag(title: "\(badgeCount(for: section))", tone: dashboardAccent)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .listRowBackground(
+ model.selectedSection == section
+ ? dashboardAccent.opacity(0.10)
+ : Color.clear
+ )
}
}
}
@@ -260,55 +283,11 @@ private struct Sidebar: View {
case .requests:
model.pendingRequests.count
case .activity:
- 0
+ model.unreadNotificationCount
case .account:
0
}
}
-
- @ViewBuilder
- private func sidebarRow(for section: AppSection) -> some View {
- Button {
- model.selectedSection = section
- } label: {
- HStack(spacing: 14) {
- Image(systemName: section.systemImage)
- .font(.headline)
- .frame(width: 30, height: 30)
- .background {
- if model.selectedSection == section {
- Circle()
- .fill(dashboardAccent.opacity(0.18))
- } else {
- Circle()
- .fill(.thinMaterial)
- }
- }
- .foregroundStyle(model.selectedSection == section ? dashboardAccent : .primary)
-
- Text(section.title)
- .font(.headline)
-
- Spacer()
-
- if badgeCount(for: section) > 0 {
- Text("\(badgeCount(for: section))")
- .font(.caption.weight(.semibold))
- .padding(.horizontal, 9)
- .padding(.vertical, 5)
- .background(.thinMaterial, in: Capsule())
- }
- }
- .padding(.vertical, 4)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .listRowBackground(
- model.selectedSection == section
- ? dashboardAccent.opacity(0.12)
- : Color.clear
- )
- }
}
private struct SidebarStatusCard: View {
@@ -317,16 +296,16 @@ private struct SidebarStatusCard: View {
let unreadCount: Int
var body: some View {
- VStack(alignment: .leading, spacing: 12) {
+ VStack(alignment: .leading, spacing: 10) {
Text("Digital Passport")
- .font(.title3.weight(.semibold))
+ .font(.headline)
- Text(profile?.handle ?? "Not paired yet")
+ Text(profile?.handle ?? "No passport active")
.foregroundStyle(.secondary)
- HStack(spacing: 10) {
- SmallMetricPill(title: "Pending", value: "\(pendingCount)")
- SmallMetricPill(title: "Unread", value: "\(unreadCount)")
+ HStack(spacing: 8) {
+ AppStatusTag(title: "\(pendingCount) pending", tone: dashboardAccent)
+ AppStatusTag(title: "\(unreadCount) unread", tone: dashboardGold)
}
}
.padding(.vertical, 6)
@@ -336,10 +315,9 @@ private struct SidebarStatusCard: View {
private struct OverviewPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
- let onOpenRequest: (ApprovalRequest) -> Void
var body: some View {
- VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) {
+ VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if let profile = model.profile, let session = model.session {
OverviewHero(
profile: profile,
@@ -349,456 +327,67 @@ private struct OverviewPanel: View {
compactLayout: compactLayout
)
}
-
- SectionCard(
- title: "Quick Actions",
- subtitle: "Refresh the bound session, seed a request, or test device alerts while the backend is still mocked.",
- compactLayout: compactLayout
- ) {
- QuickActionsDeck(model: model, compactLayout: compactLayout)
- }
-
- SectionCard(
- title: "Requests In Focus",
- subtitle: "Your passport is the identity surface. This queue is where anything asking for access should earn trust.",
- compactLayout: compactLayout
- ) {
- if model.pendingRequests.isEmpty {
- EmptyStateCopy(
- title: "Nothing waiting",
- systemImage: "checkmark.shield.fill",
- message: "Every pending approval has been handled."
- )
- } else {
- VStack(spacing: 16) {
- if let featured = model.pendingRequests.first {
- FeaturedRequestCard(
- request: featured,
- compactLayout: compactLayout,
- onOpenRequest: { onOpenRequest(featured) }
- )
- }
-
- ForEach(model.pendingRequests.dropFirst().prefix(2)) { request in
- RequestCard(
- request: request,
- compactLayout: compactLayout,
- isBusy: model.activeRequestID == request.id,
- onApprove: {
- Task { await model.approve(request) }
- },
- onReject: {
- Task { await model.reject(request) }
- },
- onOpenRequest: {
- onOpenRequest(request)
- }
- )
- }
- }
- }
- }
-
- SectionCard(
- title: "Recent Activity",
- subtitle: "Keep the full timeline in its own view, and use the bell above for alerts that need device-level attention.",
- compactLayout: compactLayout
- ) {
- ActivityPreviewCard(model: model, compactLayout: compactLayout)
- }
}
}
}
-private struct ActivityPanel: View {
- @ObservedObject var model: AppViewModel
- let compactLayout: Bool
- let onOpenRequest: (ApprovalRequest) -> Void
-
- @State private var selectedNotificationID: AppNotification.ID?
-
- private var notificationIDs: [AppNotification.ID] {
- model.notifications.map(\.id)
- }
-
- private var selectedNotification: AppNotification? {
- if let selectedNotificationID,
- let match = model.notifications.first(where: { $0.id == selectedNotificationID }) {
- return match
- }
-
- return model.notifications.first
- }
-
- var body: some View {
- VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) {
- if compactLayout {
- SectionCard(
- title: "Recent Activity",
- subtitle: "A dedicated home for approvals, pairing events, and system changes after they happen."
- ) {
- VStack(spacing: 16) {
- activityMetricRow
-
- if model.notifications.isEmpty {
- EmptyStateCopy(
- title: "No activity yet",
- systemImage: "clock.badge.xmark",
- message: "Once requests and pairing events arrive, the timeline will fill in here."
- )
- } else {
- ForEach(model.notifications) { notification in
- NotificationCard(
- notification: notification,
- compactLayout: compactLayout,
- onMarkRead: {
- Task { await model.markNotificationRead(notification) }
- }
- )
- }
- }
- }
- }
- } else {
- SectionCard(
- title: "Activity Timeline",
- subtitle: "Review what already happened across approvals, pairing, and system state without mixing it into the notification surface."
- ) {
- VStack(alignment: .leading, spacing: 18) {
- activityMetricRow
-
- if model.notifications.isEmpty {
- EmptyStateCopy(
- title: "No activity yet",
- systemImage: "clock.badge.xmark",
- message: "Once requests and pairing events arrive, the timeline will fill in here."
- )
- } else {
- HStack(alignment: .top, spacing: 18) {
- VStack(alignment: .leading, spacing: 14) {
- Text("Timeline")
- .font(.headline)
-
- Text("The latest product and security events stay readable here, while the bell above stays focused on device notifications.")
- .foregroundStyle(.secondary)
-
- VStack(spacing: 12) {
- ForEach(model.notifications) { notification in
- NotificationFeedRow(
- notification: notification,
- isSelected: notification.id == selectedNotification?.id
- ) {
- selectedNotificationID = notification.id
- }
- }
- }
- }
- .frame(width: 390, alignment: .leading)
- .padding(18)
- .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
-
- if let notification = selectedNotification {
- NotificationWorkbenchDetail(
- notification: notification,
- permissionState: model.notificationPermission,
- onMarkRead: {
- Task { await model.markNotificationRead(notification) }
- }
- )
- }
- }
- }
- }
- }
- }
-
- if !model.handledRequests.isEmpty {
- SectionCard(
- title: "Handled Requests",
- subtitle: "A compact audit trail for the approvals and rejections that already moved through the queue."
- ) {
- LazyVStack(spacing: 14) {
- ForEach(model.handledRequests.prefix(compactLayout ? 4 : 6)) { request in
- RequestCard(
- request: request,
- compactLayout: compactLayout,
- isBusy: false,
- onApprove: nil,
- onReject: nil,
- onOpenRequest: {
- onOpenRequest(request)
- }
- )
- }
- }
- }
- }
- }
- .onChange(of: notificationIDs, initial: true) { _, _ in
- syncSelectedNotification()
- }
- }
-
- @ViewBuilder
- private var activityMetricRow: some View {
- if compactLayout {
- VStack(spacing: 10) {
- SmallMetricPill(title: "Events", value: "\(model.notifications.count)")
- SmallMetricPill(title: "Unread", value: "\(model.unreadNotificationCount)")
- SmallMetricPill(title: "Handled", value: "\(model.handledRequests.count)")
- }
- } else {
- HStack(spacing: 14) {
- NotificationMetricCard(
- title: "Events",
- value: "\(model.notifications.count)",
- subtitle: model.notifications.isEmpty ? "Quiet so far" : "Timeline active",
- accent: dashboardAccent
- )
- NotificationMetricCard(
- title: "Unread",
- value: "\(model.unreadNotificationCount)",
- subtitle: model.unreadNotificationCount == 0 ? "Everything acknowledged" : "Still highlighted",
- accent: .orange
- )
- NotificationMetricCard(
- title: "Handled",
- value: "\(model.handledRequests.count)",
- subtitle: model.handledRequests.isEmpty ? "No completed approvals yet" : "Recent decisions ready to review",
- accent: dashboardGold
- )
- }
- }
- }
-
- private func syncSelectedNotification() {
- if let selectedNotificationID,
- notificationIDs.contains(selectedNotificationID) {
- return
- }
-
- selectedNotificationID = model.notifications.first?.id
- }
-}
-
private struct RequestsPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
let onOpenRequest: (ApprovalRequest) -> Void
- @State private var selectedRequestID: ApprovalRequest.ID?
-
- private var requestIDs: [ApprovalRequest.ID] {
- model.requests.map(\.id)
- }
-
- private var selectedRequest: ApprovalRequest? {
- if let selectedRequestID,
- let match = model.requests.first(where: { $0.id == selectedRequestID }) {
- return match
- }
-
- return model.pendingRequests.first ?? model.handledRequests.first
- }
-
var body: some View {
- VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) {
- if compactLayout {
- SectionCard(
- title: "Approval Desk",
- subtitle: "Treat every request like a border checkpoint: verify the origin, timing, and scope before letting it through.",
- compactLayout: compactLayout
- ) {
- VStack(spacing: 16) {
- RequestQueueSummary(
- pendingCount: model.pendingRequests.count,
- elevatedCount: model.elevatedPendingCount,
- compactLayout: compactLayout
- )
-
- if model.pendingRequests.isEmpty {
- EmptyStateCopy(
- title: "Queue is clear",
- systemImage: "checkmark.circle",
- message: "Use the toolbar to simulate another request if you want to keep testing."
- )
- } else {
- ForEach(model.pendingRequests) { request in
- RequestCard(
- request: request,
- compactLayout: compactLayout,
- isBusy: model.activeRequestID == request.id,
- onApprove: {
- Task { await model.approve(request) }
- },
- onReject: {
- Task { await model.reject(request) }
- },
- onOpenRequest: {
- onOpenRequest(request)
- }
- )
- }
- }
- }
- }
-
- SectionCard(
- title: "Decision Guide",
- subtitle: "What to check before approving high-sensitivity actions from your phone.",
- compactLayout: compactLayout
- ) {
- VStack(alignment: .leading, spacing: 14) {
- GuidanceRow(
- icon: "network.badge.shield.half.filled",
- title: "Confirm the origin",
- message: "The service hostname should match the product or automation you intentionally triggered."
- )
- GuidanceRow(
- icon: "timer",
- title: "Look for short lifetimes",
- message: "Privileged grants should usually be limited in time instead of creating long-lived access."
- )
- GuidanceRow(
- icon: "lock.shield",
- title: "Escalate mentally for elevated scopes",
- message: "Signing, publishing, and write scopes deserve a slower second look before approval."
- )
- }
- }
-
- if !model.handledRequests.isEmpty {
- SectionCard(
- title: "Recently Handled",
- subtitle: "A compact audit trail of the latest approvals and rejections.",
- compactLayout: compactLayout
- ) {
- LazyVStack(spacing: 14) {
- ForEach(model.handledRequests.prefix(4)) { request in
- RequestCard(
- request: request,
- compactLayout: compactLayout,
- isBusy: false,
- onApprove: nil,
- onReject: nil,
- onOpenRequest: {
- onOpenRequest(request)
- }
- )
- }
- }
- }
+ VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
+ if model.requests.isEmpty {
+ AppPanel(compactLayout: compactLayout) {
+ EmptyStateCopy(
+ title: "No checks waiting",
+ systemImage: "checkmark.circle",
+ message: "Identity proof requests from sites and devices appear here."
+ )
}
} else {
- SectionCard(
- title: "Approval Workbench",
- subtitle: "Use the queue on the left and a richer inline review on the right so each decision feels deliberate instead of mechanical."
- ) {
- VStack(alignment: .leading, spacing: 18) {
- RequestQueueSummary(
- pendingCount: model.pendingRequests.count,
- elevatedCount: model.elevatedPendingCount,
- compactLayout: compactLayout
- )
-
- if model.requests.isEmpty {
- EmptyStateCopy(
- title: "Queue is clear",
- systemImage: "checkmark.circle",
- message: "Use the toolbar to simulate another request if you want to keep testing."
- )
- } else {
- HStack(alignment: .top, spacing: 18) {
- VStack(alignment: .leading, spacing: 14) {
- Text("Queue")
- .font(.headline)
-
- Text("Pending and recently handled items stay visible here so you can sanity-check decisions without leaving the flow.")
- .foregroundStyle(.secondary)
-
- VStack(spacing: 12) {
- ForEach(model.requests) { request in
- RequestQueueRow(
- request: request,
- isSelected: request.id == selectedRequest?.id
- ) {
- selectedRequestID = request.id
- }
- }
- }
- }
- .frame(width: 390, alignment: .leading)
- .padding(18)
- .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
-
- if let request = selectedRequest {
- RequestWorkbenchDetail(
- request: request,
- isBusy: model.activeRequestID == request.id,
- onApprove: request.status == .pending ? {
- Task { await model.approve(request) }
- } : nil,
- onReject: request.status == .pending ? {
- Task { await model.reject(request) }
- } : nil,
- onOpenRequest: {
- onOpenRequest(request)
- }
- )
- }
- }
- }
- }
- }
-
- SectionCard(
- title: "Operator Checklist",
- subtitle: "A calm review pattern for larger screens, especially when elevated scopes show up."
- ) {
- LazyVGrid(
- columns: [
- GridItem(.flexible(), spacing: 14),
- GridItem(.flexible(), spacing: 14)
- ],
- alignment: .leading,
- spacing: 14
- ) {
- GuidanceCard(
- icon: "network.badge.shield.half.filled",
- title: "Confirm the origin",
- message: "The hostname should map to the workflow or portal you intentionally triggered."
- )
- GuidanceCard(
- icon: "timer",
- title: "Look for short lifetimes",
- message: "Elevated grants are safer when they expire quickly instead of becoming ambient access."
- )
- GuidanceCard(
- icon: "lock.shield",
- title: "Escalate for signing and publish scopes",
- message: "If the action can sign, publish, or write, slow down and verify the target system twice."
- )
- GuidanceCard(
- icon: "person.badge.shield.checkmark",
- title: "Match the device",
- message: "The request story should line up with the paired browser, CLI, or automation session you expect."
- )
- }
- }
+ RequestList(
+ requests: model.requests,
+ compactLayout: compactLayout,
+ activeRequestID: model.activeRequestID,
+ onApprove: { request in
+ Task { await model.approve(request) }
+ },
+ onReject: { request in
+ Task { await model.reject(request) }
+ },
+ onOpenRequest: onOpenRequest
+ )
}
}
- .onChange(of: requestIDs, initial: true) { _, _ in
- syncSelectedRequest()
- }
}
+}
- private func syncSelectedRequest() {
- if let selectedRequestID,
- requestIDs.contains(selectedRequestID) {
- return
+private struct ActivityPanel: View {
+ @ObservedObject var model: AppViewModel
+ let compactLayout: Bool
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
+ if model.notifications.isEmpty {
+ AppPanel(compactLayout: compactLayout) {
+ EmptyStateCopy(
+ title: "No proof activity yet",
+ systemImage: "clock.badge.xmark",
+ message: "Identity proofs and security events will appear here."
+ )
+ }
+ } else {
+ NotificationList(
+ notifications: model.notifications,
+ compactLayout: compactLayout,
+ onMarkRead: { notification in
+ Task { await model.markNotificationRead(notification) }
+ }
+ )
+ }
}
-
- selectedRequestID = model.pendingRequests.first?.id ?? model.handledRequests.first?.id
}
}
@@ -806,148 +395,30 @@ private struct NotificationsPanel: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
- @State private var selectedNotificationID: AppNotification.ID?
-
- private var notificationIDs: [AppNotification.ID] {
- model.notifications.map(\.id)
- }
-
- private var selectedNotification: AppNotification? {
- if let selectedNotificationID,
- let match = model.notifications.first(where: { $0.id == selectedNotificationID }) {
- return match
- }
-
- return model.notifications.first
- }
-
var body: some View {
- VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) {
- if compactLayout {
- SectionCard(
- title: "Notification Delivery",
- subtitle: "Control lock-screen delivery now, then evolve this into remote push once the backend is live.",
- compactLayout: compactLayout
- ) {
- NotificationPermissionCard(model: model, compactLayout: compactLayout)
- }
+ VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
+ AppSectionCard(title: "Delivery", compactLayout: compactLayout) {
+ NotificationPermissionSummary(model: model, compactLayout: compactLayout)
+ }
- SectionCard(
- title: "Alert Inbox",
- subtitle: "Unread alerts stay emphasized here until you explicitly clear them.",
- compactLayout: compactLayout
- ) {
- if model.notifications.isEmpty {
- EmptyStateCopy(
- title: "No alerts yet",
- systemImage: "bell.slash",
- message: "New pairing and approval alerts will accumulate here."
- )
- } else {
- LazyVStack(spacing: 14) {
- ForEach(model.notifications) { notification in
- NotificationCard(
- notification: notification,
- compactLayout: compactLayout,
- onMarkRead: {
- Task { await model.markNotificationRead(notification) }
- }
- )
- }
+ AppSectionCard(title: "Alerts", compactLayout: compactLayout) {
+ if model.notifications.isEmpty {
+ EmptyStateCopy(
+ title: "No alerts yet",
+ systemImage: "bell.slash",
+ message: "New passport and identity-proof alerts will accumulate here."
+ )
+ } else {
+ NotificationList(
+ notifications: model.notifications,
+ compactLayout: compactLayout,
+ onMarkRead: { notification in
+ Task { await model.markNotificationRead(notification) }
}
- }
- }
- } else {
- SectionCard(
- title: "Delivery Posture",
- subtitle: "Keep delivery health, unread pressure, and the latest alert in one glance from the notification center."
- ) {
- VStack(alignment: .leading, spacing: 18) {
- HStack(spacing: 14) {
- NotificationMetricCard(
- title: "Unread",
- value: "\(model.unreadNotificationCount)",
- subtitle: model.unreadNotificationCount == 0 ? "Inbox clear" : "Needs triage",
- accent: .orange
- )
- NotificationMetricCard(
- title: "Permission",
- value: model.notificationPermission.title,
- subtitle: model.notificationPermission == .allowed ? "Lock screen ready" : "Review device status",
- accent: dashboardAccent
- )
- NotificationMetricCard(
- title: "Latest",
- value: model.latestNotification?.kind.title ?? "Quiet",
- subtitle: model.latestNotification?.sentAt.formatted(date: .omitted, time: .shortened) ?? "No recent events",
- accent: dashboardGold
- )
- }
-
- NotificationPermissionCard(model: model, compactLayout: compactLayout)
- }
- }
-
- SectionCard(
- title: "Alert Inbox",
- subtitle: "Select an alert to inspect the message body, delivery state, and the right follow-up action."
- ) {
- if model.notifications.isEmpty {
- EmptyStateCopy(
- title: "No alerts yet",
- systemImage: "bell.slash",
- message: "New pairing and approval alerts will accumulate here."
- )
- } else {
- HStack(alignment: .top, spacing: 18) {
- VStack(alignment: .leading, spacing: 14) {
- Text("Feed")
- .font(.headline)
-
- Text("Unread items stay visually lifted until you clear them, which makes it easier to scan the important changes first.")
- .foregroundStyle(.secondary)
-
- VStack(spacing: 12) {
- ForEach(model.notifications) { notification in
- NotificationFeedRow(
- notification: notification,
- isSelected: notification.id == selectedNotification?.id
- ) {
- selectedNotificationID = notification.id
- }
- }
- }
- }
- .frame(maxWidth: 340, alignment: .leading)
- .padding(18)
- .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
-
- if let notification = selectedNotification {
- NotificationWorkbenchDetail(
- notification: notification,
- permissionState: model.notificationPermission,
- onMarkRead: {
- Task { await model.markNotificationRead(notification) }
- }
- )
- }
- }
- }
+ )
}
}
}
- .onChange(of: notificationIDs, initial: true) { _, _ in
- syncSelectedNotification()
- }
- }
-
- private func syncSelectedNotification() {
- if let selectedNotificationID,
- notificationIDs.contains(selectedNotificationID) {
- return
- }
-
- selectedNotificationID = model.notifications.first?.id
}
}
@@ -956,37 +427,20 @@ private struct AccountPanel: View {
let compactLayout: Bool
var body: some View {
- VStack(alignment: .leading, spacing: compactLayout ? 18 : 24) {
+ VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
if let profile = model.profile, let session = model.session {
AccountHero(profile: profile, session: session, compactLayout: compactLayout)
- SectionCard(
- title: "Session Security",
- subtitle: "The core trust facts for the currently paired session.",
- compactLayout: compactLayout
- ) {
- AccountFactGrid(profile: profile, session: session, compactLayout: compactLayout)
+ AppSectionCard(title: "Session", compactLayout: compactLayout) {
+ AccountFactsGrid(profile: profile, session: session, compactLayout: compactLayout)
}
}
- SectionCard(
- title: "Mock Pairing Payload",
- subtitle: "Useful for testing QR flow while the real portal integration is still pending.",
- compactLayout: compactLayout
- ) {
- Text(model.suggestedQRCodePayload)
- .font(.body.monospaced())
- .textSelection(.enabled)
- .padding(16)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
+ AppSectionCard(title: "Pairing payload", compactLayout: compactLayout) {
+ AppTextSurface(text: model.suggestedPairingPayload, monospaced: true)
}
- SectionCard(
- title: "Session Controls",
- subtitle: "Use this once you want to reset back to the login and pairing flow.",
- compactLayout: compactLayout
- ) {
+ AppSectionCard(title: "Actions", compactLayout: compactLayout) {
Button(role: .destructive) {
model.signOut()
} label: {
@@ -1005,765 +459,413 @@ private struct OverviewHero: View {
let unreadCount: Int
let compactLayout: Bool
- var body: some View {
- ZStack(alignment: .topLeading) {
- RoundedRectangle(cornerRadius: 34, style: .continuous)
- .fill(
- LinearGradient(
- colors: [
- Color(red: 0.07, green: 0.18, blue: 0.15),
- Color(red: 0.11, green: 0.28, blue: 0.24),
- Color(red: 0.29, green: 0.24, blue: 0.12)
- ],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- )
- .overlay(
- RoundedRectangle(cornerRadius: 34, style: .continuous)
- .strokeBorder(dashboardGold.opacity(0.55), lineWidth: 1.2)
- )
-
- Circle()
- .fill(.white.opacity(0.08))
- .frame(width: compactLayout ? 180 : 260, height: compactLayout ? 180 : 260)
- .offset(x: compactLayout ? 210 : 420, y: compactLayout ? -30 : -50)
-
- Image(systemName: "globe.europe.africa.fill")
- .font(.system(size: compactLayout ? 92 : 122))
- .foregroundStyle(.white.opacity(0.07))
- .offset(x: compactLayout ? 220 : 455, y: compactLayout ? 4 : 8)
-
- VStack(alignment: .leading, spacing: compactLayout ? 16 : 20) {
- passportHeader
-
- passportBody
-
- if !compactLayout {
- PassportMachineStrip(code: machineReadableCode)
- }
-
- passportMetrics
- }
- .padding(compactLayout ? 22 : 28)
- }
- .frame(minHeight: compactLayout ? 380 : 390)
+ private var detailColumns: [GridItem] {
+ Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
}
- private var passportHeader: some View {
- HStack(alignment: .top, spacing: 16) {
- VStack(alignment: .leading, spacing: 8) {
- Text("IDP.GLOBAL DIGITAL PASSPORT")
- .font(.caption.weight(.bold))
- .tracking(1.8)
- .foregroundStyle(.white.opacity(0.78))
+ private var metricColumns: [GridItem] {
+ Array(repeating: GridItem(.flexible(), spacing: 16), count: 3)
+ }
+ var body: some View {
+ AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
+ AppBadge(title: "Digital passport", tone: dashboardAccent)
+
+ VStack(alignment: .leading, spacing: 6) {
Text(profile.name)
- .font(.system(size: compactLayout ? 30 : 36, weight: .bold, design: .rounded))
- .foregroundStyle(.white)
+ .font(.system(size: compactLayout ? 30 : 38, weight: .bold, design: .rounded))
+ .lineLimit(2)
- Text("Bound to \(session.deviceName) for requests coming from \(session.originHost).")
- .font(compactLayout ? .subheadline : .title3)
- .foregroundStyle(.white.opacity(0.88))
+ Text("\(profile.handle) • \(profile.organization)")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
}
- Spacer(minLength: 0)
-
- PassportDocumentBadge(
- number: documentNumber,
- issuedAt: session.pairedAt,
- compactLayout: compactLayout
- )
- }
- }
-
- @ViewBuilder
- private var passportBody: some View {
- if compactLayout {
- VStack(alignment: .leading, spacing: 14) {
- HStack(alignment: .top, spacing: 14) {
- passportPortrait
-
- VStack(alignment: .leading, spacing: 10) {
- PassportField(label: "Holder", value: profile.name, emphasized: true)
- PassportField(label: "Handle", value: profile.handle, monospaced: true)
- PassportField(label: "Origin", value: session.originHost, monospaced: true)
- }
- }
-
- LazyVGrid(
- columns: [
- GridItem(.flexible(), spacing: 10),
- GridItem(.flexible(), spacing: 10)
- ],
- spacing: 10
- ) {
- PassportInlineFact(label: "Device", value: session.deviceName)
- PassportInlineFact(label: "Issued", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
- PassportInlineFact(label: "Organization", value: profile.organization)
- PassportInlineFact(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
- }
+ HStack(spacing: 8) {
+ AppStatusTag(title: "Passport active", tone: dashboardAccent)
+ AppStatusTag(title: session.pairingTransport.title, tone: dashboardGold)
}
- .padding(18)
- .background(.white.opacity(0.11), in: RoundedRectangle(cornerRadius: 28, style: .continuous))
- } else {
- VStack(alignment: .leading, spacing: 16) {
- HStack(alignment: .top, spacing: 18) {
- passportPortrait
- HStack(alignment: .top, spacing: 14) {
- passportPrimaryFields
- passportSecondaryFields
- }
- }
+ Divider()
- LazyVGrid(
- columns: [
- GridItem(.flexible(), spacing: 12),
- GridItem(.flexible(), spacing: 12),
- GridItem(.flexible(), spacing: 12)
- ],
- spacing: 12
- ) {
- PassportInlineFact(label: "Document No.", value: documentNumber, monospaced: true)
- PassportInlineFact(label: "Issued", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
- PassportInlineFact(label: "Membership", value: "\(profile.deviceCount) trusted devices")
- }
+ LazyVGrid(columns: detailColumns, alignment: .leading, spacing: 16) {
+ AppKeyValue(label: "Device", value: session.deviceName)
+ AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
+ AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
+ AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
}
- .padding(20)
- .background(.white.opacity(0.11), in: RoundedRectangle(cornerRadius: 28, style: .continuous))
- }
- }
- private var passportMetrics: some View {
- Group {
- if compactLayout {
- VStack(spacing: 10) {
- passportMetricCards
- }
- } else {
- HStack(spacing: 12) {
- passportMetricCards
- }
+ Divider()
+
+ LazyVGrid(columns: metricColumns, alignment: .leading, spacing: 16) {
+ AppMetric(title: "Pending", value: "\(pendingCount)")
+ AppMetric(title: "Alerts", value: "\(unreadCount)")
+ AppMetric(title: "Devices", value: "\(profile.deviceCount)")
}
}
}
-
- @ViewBuilder
- private var passportMetricCards: some View {
- PassportMetricBadge(
- title: "Pending",
- value: "\(pendingCount)",
- subtitle: pendingCount == 0 ? "No approvals waiting" : "Requests still at the border"
- )
- PassportMetricBadge(
- title: "Alerts",
- value: "\(unreadCount)",
- subtitle: unreadCount == 0 ? "Notification bell is clear" : "Unread device alerts"
- )
- PassportMetricBadge(
- title: "Devices",
- value: "\(profile.deviceCount)",
- subtitle: "\(profile.organization) membership"
- )
- }
-
- private var passportPortrait: some View {
- VStack(alignment: .leading, spacing: 12) {
- RoundedRectangle(cornerRadius: 26, style: .continuous)
- .fill(.white.opacity(0.12))
- .frame(width: compactLayout ? 102 : 132, height: compactLayout ? 132 : 166)
- .overlay {
- VStack(spacing: 10) {
- Circle()
- .fill(.white.opacity(0.18))
- .frame(width: compactLayout ? 52 : 64, height: compactLayout ? 52 : 64)
- .overlay {
- Text(holderInitials)
- .font(.system(size: compactLayout ? 24 : 28, weight: .bold, design: .rounded))
- .foregroundStyle(.white)
- }
-
- Text("TRUSTED HOLDER")
- .font(.caption2.weight(.bold))
- .tracking(1.2)
- .foregroundStyle(.white.opacity(0.72))
-
- Text(compactLayout ? documentNumber : profile.handle)
- .font(.footnote.monospaced())
- .foregroundStyle(.white.opacity(0.9))
- .lineLimit(2)
- .minimumScaleFactor(0.7)
- }
- .padding(12)
- }
-
- Text("Issued \(session.pairedAt.formatted(date: .abbreviated, time: .shortened))")
- .font(.caption)
- .foregroundStyle(.white.opacity(0.74))
- }
- }
-
- private var passportPrimaryFields: some View {
- VStack(alignment: .leading, spacing: 12) {
- PassportField(label: "Holder", value: profile.name, emphasized: true)
- PassportField(label: "Handle", value: profile.handle, monospaced: true)
- PassportField(label: "Organization", value: profile.organization)
- }
- }
-
- private var passportSecondaryFields: some View {
- VStack(alignment: .leading, spacing: 12) {
- PassportField(label: "Bound Device", value: session.deviceName)
- PassportField(label: "Origin", value: session.originHost, monospaced: true)
- PassportField(label: "Token Preview", value: "...\(session.tokenPreview)", monospaced: true)
- }
- }
-
- private var holderInitials: String {
- let parts = profile.name
- .split(separator: " ")
- .prefix(2)
- .compactMap { $0.first }
-
- let initials = String(parts)
- return initials.isEmpty ? "ID" : initials.uppercased()
- }
-
- private var documentNumber: String {
- "IDP-\(session.id.uuidString.prefix(8).uppercased())"
- }
-
- private var machineReadableCode: String {
- let normalizedName = sanitize(profile.name)
- let normalizedHandle = sanitize(profile.handle)
- let normalizedOrigin = sanitize(session.originHost)
- return "P<\(documentNumber)<\(normalizedName)<<\(normalizedHandle)<<\(normalizedOrigin)"
- }
-
- private func sanitize(_ value: String) -> String {
- value
- .uppercased()
- .map { character in
- character.isLetter || character.isNumber ? String(character) : "<"
- }
- .joined()
- }
}
-private struct PassportDocumentBadge: View {
- let number: String
- let issuedAt: Date
- let compactLayout: Bool
-
- var body: some View {
- VStack(alignment: .trailing, spacing: 8) {
- StatusBadge(title: "Bound", tone: .white)
-
- VStack(alignment: .trailing, spacing: 4) {
- Text("Document No.")
- .font(.caption2.weight(.bold))
- .tracking(1.0)
- .foregroundStyle(.white.opacity(0.72))
-
- Text(number)
- .font((compactLayout ? Font.footnote : Font.body).monospaced().weight(.semibold))
- .foregroundStyle(.white)
- }
-
- if !compactLayout {
- Text("Issued \(issuedAt.formatted(date: .abbreviated, time: .shortened))")
- .font(.caption)
- .foregroundStyle(.white.opacity(0.76))
- }
- }
- .padding(.horizontal, compactLayout ? 12 : 14)
- .padding(.vertical, 10)
- .background(.white.opacity(0.10), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
- }
-}
-
-private struct PassportInlineFact: View {
- let label: String
- let value: String
- var monospaced: Bool = false
-
- var body: some View {
- VStack(alignment: .leading, spacing: 5) {
- Text(label.uppercased())
- .font(.caption2.weight(.bold))
- .tracking(1.0)
- .foregroundStyle(.white.opacity(0.72))
-
- Text(value)
- .font(monospaced ? .subheadline.monospaced() : .subheadline.weight(.semibold))
- .foregroundStyle(.white)
- .lineLimit(2)
- .minimumScaleFactor(0.7)
- }
- .padding(12)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(.white.opacity(0.09), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
- }
-}
-
-private struct PassportField: View {
- let label: String
- let value: String
- var monospaced: Bool = false
- var emphasized: Bool = false
-
- var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(label.uppercased())
- .font(.caption2.weight(.bold))
- .tracking(1.0)
- .foregroundStyle(.white.opacity(0.72))
-
- Text(value)
- .font(valueFont)
- .foregroundStyle(.white)
- .lineLimit(2)
- .minimumScaleFactor(0.8)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
-
- private var valueFont: Font {
- if monospaced {
- return .body.monospaced()
- }
-
- return emphasized ? .headline : .body
- }
-}
-
-private struct PassportMetricBadge: View {
- let title: String
- let value: String
- let subtitle: String
-
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- Text(title.uppercased())
- .font(.caption.weight(.bold))
- .tracking(1.0)
- .foregroundStyle(.white.opacity(0.72))
-
- Text(value)
- .font(.title2.weight(.bold))
- .foregroundStyle(.white)
-
- Text(subtitle)
- .font(.footnote)
- .foregroundStyle(.white.opacity(0.82))
- }
- .padding(16)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(.white.opacity(0.10), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
- }
-}
-
-private struct PassportMachineStrip: View {
- let code: String
-
- var body: some View {
- Text(code)
- .font(.caption.monospaced().weight(.semibold))
- .lineLimit(1)
- .minimumScaleFactor(0.5)
- .padding(.horizontal, 14)
- .padding(.vertical, 12)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(Color.black.opacity(0.22), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
- .foregroundStyle(.white.opacity(0.94))
- }
-}
-
-private struct QuickActionsDeck: View {
+private struct NotificationPermissionSummary: View {
@ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View {
- Group {
- if compactLayout {
- VStack(spacing: 12) {
- actionButtons
- }
- } else {
- HStack(alignment: .top, spacing: 14) {
- actionButtons
- }
- }
- }
- }
-
- @ViewBuilder
- private var actionButtons: some View {
- ActionTile(
- title: "Refresh State",
- subtitle: "Pull the latest requests and notifications from the mock service.",
- systemImage: "arrow.clockwise"
- ) {
- Task {
- await model.refreshDashboard()
- }
- }
-
- ActionTile(
- title: "Seed Request",
- subtitle: "Inject a new elevated approval flow to test the queue.",
- systemImage: "sparkles.rectangle.stack.fill"
- ) {
- Task {
- await model.simulateIncomingRequest()
- }
- }
-
- ActionTile(
- title: "Test Alert",
- subtitle: "Schedule a local notification so the phone behavior is easy to verify.",
- systemImage: "bell.badge.fill"
- ) {
- Task {
- await model.sendTestNotification()
- }
- }
- }
-}
-
-private struct ActionTile: View {
- let title: String
- let subtitle: String
- let systemImage: String
- let action: () -> Void
-
- var body: some View {
- Button(action: action) {
- VStack(alignment: .leading, spacing: 12) {
- Image(systemName: systemImage)
- .font(.title3.weight(.semibold))
- .foregroundStyle(dashboardAccent)
- .frame(width: 42, height: 42)
- .background(dashboardAccent.opacity(0.10), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
- Text(title)
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: model.notificationPermission.systemImage)
.font(.headline)
- .foregroundStyle(.primary)
- Text(subtitle)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(18)
- .background(Color.white.opacity(0.76), in: RoundedRectangle(cornerRadius: 24, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 24, style: .continuous)
- .stroke(dashboardAccent.opacity(0.08), lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- }
-}
-
-private struct FeaturedRequestCard: View {
- let request: ApprovalRequest
- let compactLayout: Bool
- let onOpenRequest: () -> Void
-
- var body: some View {
- VStack(alignment: .leading, spacing: 16) {
- HStack(alignment: .center, spacing: 12) {
- Image(systemName: request.risk == .elevated ? "shield.lefthalf.filled.badge.checkmark" : request.kind.systemImage)
- .font(.title2)
- .foregroundStyle(request.risk == .elevated ? .orange : dashboardAccent)
+ .foregroundStyle(dashboardAccent)
+ .frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) {
- Text(request.trustHeadline)
+ Text(model.notificationPermission.title)
.font(.headline)
- Text(request.title)
- .font(.title3.weight(.semibold))
+ Text(model.notificationPermission.summary)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
}
-
- Spacer()
-
- StatusBadge(
- title: request.risk.title,
- tone: request.risk == .routine ? .mint : .orange
- )
- }
-
- Text(request.trustDetail)
- .foregroundStyle(.secondary)
-
- HStack(spacing: 8) {
- StatusBadge(title: request.kind.title, tone: .blue)
- StatusBadge(title: request.source, tone: .gray)
- StatusBadge(title: request.scopeSummary, tone: .green)
}
if compactLayout {
VStack(alignment: .leading, spacing: 12) {
- Button("Review Full Context", action: onOpenRequest)
- .buttonStyle(.borderedProminent)
- Text(request.risk.guidance)
- .font(.footnote)
- .foregroundStyle(.secondary)
+ permissionButtons
}
} else {
- HStack {
- Button("Review Full Context", action: onOpenRequest)
- .buttonStyle(.borderedProminent)
- Spacer()
- Text(request.risk.guidance)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.trailing)
- }
- }
- }
- .padding(compactLayout ? 18 : 22)
- .background(
- LinearGradient(
- colors: [
- request.risk == .routine ? dashboardAccent.opacity(0.12) : Color.orange.opacity(0.16),
- Color.white.opacity(0.7)
- ],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- ),
- in: RoundedRectangle(cornerRadius: 28, style: .continuous)
- )
- }
-}
-
-private struct RequestQueueSummary: View {
- let pendingCount: Int
- let elevatedCount: Int
- let compactLayout: Bool
-
- var body: some View {
- if compactLayout {
- VStack(spacing: 12) {
HStack(spacing: 12) {
- pendingCard
- elevatedCard
- }
-
- postureCard
- }
- } else {
- HStack(spacing: 12) {
- pendingCard
- elevatedCard
- postureCard
- }
- }
- }
-
- private var pendingCard: some View {
- RequestSummaryMetricCard(
- title: "Pending",
- value: "\(pendingCount)",
- subtitle: pendingCount == 0 ? "Queue is clear" : "Still waiting on your call",
- accent: dashboardAccent
- )
- }
-
- private var elevatedCard: some View {
- RequestSummaryMetricCard(
- title: "Elevated",
- value: "\(elevatedCount)",
- subtitle: elevatedCount == 0 ? "No privileged scopes" : "Needs slower review",
- accent: .orange
- )
- }
-
- private var postureCard: some View {
- RequestSummaryMetricCard(
- title: "Posture",
- value: trustMode,
- subtitle: postureSummary,
- accent: dashboardGold
- )
- }
-
- private var trustMode: String {
- if pendingCount == 0 {
- return "Clear"
- }
-
- if elevatedCount == 0 {
- return "Active"
- }
-
- return elevatedCount > 1 ? "Escalate" : "Guarded"
- }
-
- private var postureSummary: String {
- if pendingCount == 0 {
- return "Nothing at the border"
- }
-
- if elevatedCount == 0 {
- return "Routine traffic only"
- }
-
- return "Privileged access in queue"
- }
-}
-
-private struct RequestSummaryMetricCard: View {
- let title: String
- let value: String
- let subtitle: String
- let accent: Color
-
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- Text(title.uppercased())
- .font(.caption.weight(.semibold))
- .foregroundStyle(.secondary)
-
- Text(value)
- .font(.title3.weight(.semibold))
- .foregroundStyle(.primary)
-
- Text(subtitle)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- .padding(16)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 20, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .stroke(accent.opacity(0.08), lineWidth: 1)
- )
- }
-}
-
-private struct NotificationPermissionCard: View {
- @ObservedObject var model: AppViewModel
- let compactLayout: Bool
-
- var body: some View {
- VStack(alignment: .leading, spacing: 18) {
- HStack(alignment: .top, spacing: 14) {
- Image(systemName: model.notificationPermission.systemImage)
- .font(.title2)
- .frame(width: 38, height: 38)
- .background(.thinMaterial, in: Circle())
- .foregroundStyle(dashboardAccent)
-
- VStack(alignment: .leading, spacing: 5) {
- Text(model.notificationPermission.title)
- .font(.headline)
- Text(model.notificationPermission.summary)
- .foregroundStyle(.secondary)
- }
- }
-
- Group {
- if compactLayout {
- VStack(spacing: 12) {
- permissionButtons
- }
- } else {
- HStack(spacing: 12) {
- permissionButtons
- }
+ permissionButtons
}
}
}
- .padding(18)
- .dashboardSurface(radius: 24)
}
@ViewBuilder
private var permissionButtons: some View {
Button {
- Task {
- await model.requestNotificationAccess()
- }
+ Task { await model.requestNotificationAccess() }
} label: {
- Label("Enable Notifications", systemImage: "bell.and.waves.left.and.right.fill")
+ Label("Enable notifications", systemImage: "bell.and.waves.left.and.right.fill")
+ .frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
- Task {
- await model.sendTestNotification()
- }
+ Task { await model.sendTestNotification() }
} label: {
- Label("Send Test Alert", systemImage: "paperplane.fill")
+ Label("Send test alert", systemImage: "paperplane.fill")
+ .frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
-private struct ActivityPreviewCard: View {
- @ObservedObject var model: AppViewModel
+private struct AccountHero: View {
+ let profile: MemberProfile
+ let session: AuthSession
let compactLayout: Bool
var body: some View {
- VStack(alignment: .leading, spacing: 16) {
- if let latest = model.latestNotification {
- NotificationCard(
- notification: latest,
- compactLayout: compactLayout,
- onMarkRead: {
- Task { await model.markNotificationRead(latest) }
- }
- )
- } else {
- EmptyStateCopy(
- title: "No activity yet",
- systemImage: "clock.badge.xmark",
- message: "Once requests and pairing events arrive, the activity timeline will fill in here."
+ AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
+ AppBadge(title: "Account", tone: dashboardAccent)
+
+ Text(profile.name)
+ .font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded))
+ .lineLimit(2)
+
+ Text(profile.handle)
+ .font(.headline)
+ .foregroundStyle(.secondary)
+
+ Text("Active client: \(session.deviceName)")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+private struct AccountFactsGrid: View {
+ let profile: MemberProfile
+ let session: AuthSession
+ let compactLayout: Bool
+
+ private var columns: [GridItem] {
+ Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
+ }
+
+ var body: some View {
+ LazyVGrid(columns: columns, alignment: .leading, spacing: 16) {
+ AppKeyValue(label: "Organization", value: profile.organization)
+ AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
+ AppKeyValue(label: "Linked At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
+ AppKeyValue(label: "Method", value: session.pairingTransport.title)
+ AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
+ AppKeyValue(label: "Recovery", value: profile.recoverySummary)
+ if let signedGPSPosition = session.signedGPSPosition {
+ AppKeyValue(
+ label: "Signed GPS",
+ value: "\(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)",
+ monospaced: true
)
}
+ AppKeyValue(label: "Trusted Devices", value: "\(profile.deviceCount)")
+ }
+ }
+}
+
+private struct RequestList: View {
+ let requests: [ApprovalRequest]
+ let compactLayout: Bool
+ let activeRequestID: ApprovalRequest.ID?
+ let onApprove: ((ApprovalRequest) -> Void)?
+ let onReject: ((ApprovalRequest) -> Void)?
+ let onOpenRequest: (ApprovalRequest) -> Void
+
+ var body: some View {
+ VStack(spacing: 14) {
+ ForEach(requests) { request in
+ RequestCard(
+ request: request,
+ compactLayout: compactLayout,
+ isBusy: activeRequestID == request.id,
+ onApprove: onApprove == nil ? nil : { onApprove?(request) },
+ onReject: onReject == nil ? nil : { onReject?(request) },
+ onOpenRequest: { onOpenRequest(request) }
+ )
+ }
+ }
+ }
+}
+
+private struct RequestCard: View {
+ let request: ApprovalRequest
+ let compactLayout: Bool
+ let isBusy: Bool
+ let onApprove: (() -> Void)?
+ let onReject: (() -> Void)?
+ let onOpenRequest: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: request.kind.systemImage)
+ .font(.headline)
+ .foregroundStyle(requestAccent)
+ .frame(width: 28, height: 28)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(request.title)
+ .font(.headline)
+ .multilineTextAlignment(.leading)
+
+ Text(request.source)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ Spacer(minLength: 0)
+
+ AppStatusTag(title: request.status.title, tone: statusTone)
+ }
+
+ Text(request.subtitle)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
+ Text(request.scopeSummary)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ Spacer(minLength: 0)
+ Text(request.createdAt, style: .relative)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ if !request.scopes.isEmpty {
+ Text("Proof details: \(request.scopes.joined(separator: ", "))")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ controls
+ }
+ .padding(compactLayout ? 18 : 20)
+ .appSurface(radius: 24)
+ }
+
+ @ViewBuilder
+ private var controls: some View {
+ if compactLayout {
+ VStack(alignment: .leading, spacing: 10) {
+ reviewButton
+ decisionButtons
+ }
+ } else {
+ HStack(spacing: 12) {
+ reviewButton
+ Spacer(minLength: 0)
+ decisionButtons
+ }
+ }
+ }
+
+ private var reviewButton: some View {
+ Button {
+ onOpenRequest()
+ } label: {
+ Label("Review proof", systemImage: "arrow.up.forward.app")
+ }
+ .buttonStyle(.bordered)
+ }
+
+ @ViewBuilder
+ private var decisionButtons: some View {
+ if request.status == .pending, let onApprove, let onReject {
+ Button {
+ onApprove()
+ } label: {
+ if isBusy {
+ ProgressView()
+ } else {
+ Label("Verify", systemImage: "checkmark.circle.fill")
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(isBusy)
+
+ Button(role: .destructive) {
+ onReject()
+ } label: {
+ Label("Decline", systemImage: "xmark.circle.fill")
+ }
+ .buttonStyle(.bordered)
+ .disabled(isBusy)
+ }
+ }
+
+ private var statusTone: Color {
+ switch request.status {
+ case .pending:
+ .orange
+ case .approved:
+ .green
+ case .rejected:
+ .red
+ }
+ }
+
+ private var requestAccent: Color {
+ switch request.status {
+ case .approved:
+ .green
+ case .rejected:
+ .red
+ case .pending:
+ request.risk == .routine ? dashboardAccent : .orange
+ }
+ }
+}
+
+private struct NotificationList: View {
+ let notifications: [AppNotification]
+ let compactLayout: Bool
+ let onMarkRead: (AppNotification) -> Void
+
+ var body: some View {
+ VStack(spacing: 14) {
+ ForEach(notifications) { notification in
+ NotificationCard(
+ notification: notification,
+ compactLayout: compactLayout,
+ onMarkRead: { onMarkRead(notification) }
+ )
+ }
+ }
+ }
+}
+
+private struct NotificationCard: View {
+ let notification: AppNotification
+ let compactLayout: Bool
+ let onMarkRead: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: notification.kind.systemImage)
+ .font(.headline)
+ .foregroundStyle(accentColor)
+ .frame(width: 28, height: 28)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(notification.title)
+ .font(.headline)
+
+ HStack(spacing: 8) {
+ AppStatusTag(title: notification.kind.title, tone: accentColor)
+ if notification.isUnread {
+ AppStatusTag(title: "Unread", tone: .orange)
+ }
+ }
+ }
+
+ Spacer(minLength: 0)
+ }
+
+ Text(notification.message)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
if compactLayout {
- VStack(alignment: .leading, spacing: 12) {
- Button {
- model.selectedSection = .activity
- } label: {
- Label("Open Activity", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
+ VStack(alignment: .leading, spacing: 10) {
+ timestamp
+ if notification.isUnread {
+ markReadButton
}
- .buttonStyle(.borderedProminent)
-
- Button {
- model.isNotificationCenterPresented = true
- } label: {
- Label("Open Notification Bell", systemImage: "bell")
- }
- .buttonStyle(.bordered)
}
} else {
- HStack(spacing: 12) {
- Button {
- model.selectedSection = .activity
- } label: {
- Label("Open Activity", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
+ HStack {
+ timestamp
+ Spacer(minLength: 0)
+ if notification.isUnread {
+ markReadButton
}
- .buttonStyle(.borderedProminent)
-
- Button {
- model.isNotificationCenterPresented = true
- } label: {
- Label("Open Notifications", systemImage: "bell")
- }
- .buttonStyle(.bordered)
-
- Spacer()
-
- Text("Unread device alerts now live in the bell above instead of taking a full navigation slot.")
- .font(.footnote)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.trailing)
}
}
}
+ .padding(compactLayout ? 18 : 20)
+ .appSurface(radius: 24)
+ }
+
+ private var timestamp: some View {
+ Text(notification.sentAt.formatted(date: .abbreviated, time: .shortened))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ private var markReadButton: some View {
+ Button {
+ onMarkRead()
+ } label: {
+ Label("Mark read", systemImage: "checkmark")
+ }
+ .buttonStyle(.bordered)
+ }
+
+ private var accentColor: Color {
+ switch notification.kind {
+ case .approval:
+ .green
+ case .security:
+ .orange
+ case .system:
+ .blue
+ }
}
}
@@ -1802,15 +904,12 @@ private struct NotificationCenterSheet: View {
var body: some View {
NavigationStack {
- ScrollView {
+ AppScrollScreen(
+ compactLayout: compactLayout,
+ bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
+ ) {
NotificationsPanel(model: model, compactLayout: compactLayout)
- .padding(.horizontal, compactLayout ? DashboardSpacing.compactOuterPadding : DashboardSpacing.regularOuterPadding)
- .padding(.top, compactLayout ? DashboardSpacing.compactTopPadding : DashboardSpacing.regularTopPadding)
- .padding(.bottom, compactLayout ? DashboardSpacing.compactBottomPadding : DashboardSpacing.regularBottomPadding)
- .frame(maxWidth: compactLayout ? DashboardSpacing.compactContentWidth : DashboardSpacing.regularContentWidth, alignment: .leading)
- .frame(maxWidth: .infinity, alignment: compactLayout ? .leading : .center)
}
- .scrollIndicators(.hidden)
.navigationTitle("Notifications")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
@@ -1834,671 +933,6 @@ private struct NotificationCenterSheet: View {
}
}
-private struct AccountHero: View {
- let profile: MemberProfile
- let session: AuthSession
- let compactLayout: Bool
-
- var body: some View {
- ZStack(alignment: .bottomLeading) {
- RoundedRectangle(cornerRadius: 32, style: .continuous)
- .fill(
- LinearGradient(
- colors: [
- dashboardAccent.opacity(0.95),
- Color(red: 0.19, green: 0.49, blue: 0.40),
- dashboardGold.opacity(0.92)
- ],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- )
-
- VStack(alignment: .leading, spacing: 14) {
- Text(profile.name)
- .font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded))
- .foregroundStyle(.white)
- Text(profile.handle)
- .font(.headline)
- .foregroundStyle(.white.opacity(0.84))
- Text("Current trusted device: \(session.deviceName)")
- .foregroundStyle(.white.opacity(0.86))
- }
- .padding(compactLayout ? 22 : 28)
- }
- .frame(minHeight: compactLayout ? 190 : 220)
- }
-}
-
-private struct AccountFactGrid: View {
- let profile: MemberProfile
- let session: AuthSession
- let compactLayout: Bool
-
- private var columns: [GridItem] {
- Array(repeating: GridItem(.flexible(), spacing: 12), count: compactLayout ? 1 : 2)
- }
-
- var body: some View {
- LazyVGrid(columns: columns, spacing: 12) {
- FactCard(label: "Organization", value: profile.organization)
- FactCard(label: "Origin", value: session.originHost)
- FactCard(label: "Paired At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
- FactCard(label: "Token Preview", value: "…\(session.tokenPreview)")
- FactCard(label: "Trusted Devices", value: "\(profile.deviceCount)")
- FactCard(label: "Recovery", value: profile.recoverySummary)
- }
- }
-}
-
-private struct RequestCard: View {
- let request: ApprovalRequest
- let compactLayout: Bool
- let isBusy: Bool
- let onApprove: (() -> Void)?
- let onReject: (() -> Void)?
- let onOpenRequest: (() -> Void)?
-
- private var infoColumns: [GridItem] {
- Array(repeating: GridItem(.flexible(), spacing: 10), count: compactLayout ? 2 : 3)
- }
-
- var body: some View {
- VStack(alignment: .leading, spacing: 16) {
- HStack(alignment: .top, spacing: 14) {
- ZStack {
- Circle()
- .fill(requestAccent.opacity(0.14))
-
- Image(systemName: request.kind.systemImage)
- .font(.title2)
- .foregroundStyle(requestAccent)
- }
- .frame(width: 46, height: 46)
-
- VStack(alignment: .leading, spacing: 8) {
- HStack(alignment: .top, spacing: 12) {
- VStack(alignment: .leading, spacing: 4) {
- Text(request.trustHeadline)
- .font(.subheadline.weight(.semibold))
- .foregroundStyle(requestAccent)
-
- Text(request.title)
- .font(.headline)
- .foregroundStyle(.primary)
- }
-
- Spacer()
-
- StatusBadge(
- title: request.status.title,
- tone: statusTone
- )
- }
-
- Text(request.subtitle)
- .foregroundStyle(.secondary)
-
- HStack(spacing: 8) {
- StatusBadge(title: request.kind.title, tone: .blue)
- StatusBadge(title: request.risk.title, tone: request.risk == .routine ? .mint : .orange)
- Text(request.createdAt, style: .relative)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- }
- }
-
- LazyVGrid(columns: infoColumns, alignment: .leading, spacing: 10) {
- RequestFactPill(label: "Source", value: request.source, accent: dashboardAccent)
- RequestFactPill(
- label: "Requested",
- value: request.createdAt.formatted(date: .abbreviated, time: .shortened),
- accent: dashboardGold
- )
- RequestFactPill(label: "Access", value: request.scopeSummary, accent: requestAccent)
- }
-
- VStack(alignment: .leading, spacing: 10) {
- Label(request.status == .pending ? "Decision posture" : "Decision record", systemImage: request.status.systemImage)
- .font(.headline)
- .foregroundStyle(.primary)
-
- Text(request.trustDetail)
- .foregroundStyle(.secondary)
-
- Text(reviewSummary)
- .font(.subheadline.weight(.semibold))
- .foregroundStyle(requestAccent)
- }
- .padding(14)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(requestAccent.opacity(0.10), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 22, style: .continuous)
- .stroke(requestAccent.opacity(0.08), lineWidth: 1)
- )
-
- if !request.scopes.isEmpty {
- VStack(alignment: .leading, spacing: 10) {
- Text("Requested scopes")
- .font(.subheadline.weight(.semibold))
-
- FlowScopes(scopes: request.scopes)
- }
- }
-
- VStack(spacing: 12) {
- if let onOpenRequest {
- Button {
- onOpenRequest()
- } label: {
- Label("Review Details", systemImage: "arrow.up.forward.app")
- }
- .buttonStyle(.bordered)
- .frame(maxWidth: .infinity, alignment: .leading)
- }
-
- if let onApprove, let onReject, request.status == .pending {
- if compactLayout {
- VStack(spacing: 10) {
- Button {
- onApprove()
- } label: {
- if isBusy {
- ProgressView()
- } else {
- Label("Approve Request", systemImage: "checkmark.circle.fill")
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(isBusy)
-
- Button(role: .destructive) {
- onReject()
- } label: {
- Label("Reject Request", systemImage: "xmark.circle.fill")
- }
- .buttonStyle(.bordered)
- .disabled(isBusy)
- }
- } else {
- HStack(spacing: 12) {
- Button {
- onApprove()
- } label: {
- if isBusy {
- ProgressView()
- } else {
- Label("Approve", systemImage: "checkmark.circle.fill")
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(isBusy)
-
- Button(role: .destructive) {
- onReject()
- } label: {
- Label("Reject", systemImage: "xmark.circle.fill")
- }
- .buttonStyle(.bordered)
- .disabled(isBusy)
- }
- }
- }
- }
- }
- .padding(compactLayout ? 18 : 20)
- .background(
- LinearGradient(
- colors: [
- Color.white.opacity(0.92),
- requestAccent.opacity(0.05)
- ],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- ),
- in: RoundedRectangle(cornerRadius: 28, style: .continuous)
- )
- .overlay(
- RoundedRectangle(cornerRadius: 28, style: .continuous)
- .stroke(requestAccent.opacity(0.10), lineWidth: 1)
- )
- .shadow(color: dashboardShadow, radius: 12, y: 5)
- }
-
- private var statusTone: Color {
- switch request.status {
- case .pending:
- return .orange
- case .approved:
- return .green
- case .rejected:
- return .red
- }
- }
-
- private var requestAccent: Color {
- switch request.status {
- case .approved:
- return .green
- case .rejected:
- return .red
- case .pending:
- return request.risk == .routine ? dashboardAccent : .orange
- }
- }
-
- private var reviewSummary: String {
- switch request.status {
- case .pending:
- if request.risk == .elevated {
- return "This is privileged access. Let it through only if the origin and the moment both match what you just initiated."
- }
- return "This looks routine, but it still needs to match the browser, CLI, or device session you expect."
- case .approved:
- return "This request was already approved in the mock queue and is now part of the recent audit trail."
- case .rejected:
- return "This request was rejected and should remain a closed lane unless a new request is issued."
- }
- }
-}
-
-private struct RequestQueueRow: View {
- let request: ApprovalRequest
- let isSelected: Bool
- let action: () -> Void
-
- var body: some View {
- Button(action: action) {
- HStack(alignment: .top, spacing: 12) {
- ZStack {
- RoundedRectangle(cornerRadius: 16, style: .continuous)
- .fill(rowAccent.opacity(0.14))
-
- Image(systemName: request.kind.systemImage)
- .font(.headline)
- .foregroundStyle(rowAccent)
- }
- .frame(width: 38, height: 38)
-
- VStack(alignment: .leading, spacing: 10) {
- HStack(alignment: .top, spacing: 12) {
- VStack(alignment: .leading, spacing: 4) {
- Text(request.title)
- .font(.headline)
- .foregroundStyle(.primary)
- .multilineTextAlignment(.leading)
- .lineLimit(2)
-
- Text(request.trustHeadline)
- .font(.subheadline.weight(.semibold))
- .foregroundStyle(rowAccent)
- .lineLimit(1)
- }
-
- Spacer(minLength: 0)
-
- StatusBadge(
- title: request.status.title,
- tone: statusTone
- )
- }
-
- Text(request.source)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .lineLimit(1)
-
- Text(request.subtitle)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .multilineTextAlignment(.leading)
-
- HStack(spacing: 8) {
- StatusBadge(title: request.risk.title, tone: request.risk == .routine ? .mint : .orange)
- StatusBadge(title: request.scopeSummary, tone: .blue)
- Spacer()
- Text(request.createdAt, style: .relative)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- }
-
- Image(systemName: isSelected ? "chevron.right.circle.fill" : "chevron.right")
- .font(.headline)
- .foregroundStyle(isSelected ? rowAccent : .secondary.opacity(0.7))
- .padding(.top, 2)
- }
- .padding(16)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(backgroundStyle, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 24, style: .continuous)
- .stroke(isSelected ? rowAccent.opacity(0.36) : Color.clear, lineWidth: 1.5)
- )
- .overlay(alignment: .leading) {
- Capsule()
- .fill(rowAccent.opacity(isSelected ? 0.80 : 0.30))
- .frame(width: 5)
- .padding(.vertical, 16)
- .padding(.leading, 8)
- }
- }
- .buttonStyle(.plain)
- }
-
- private var statusTone: Color {
- switch request.status {
- case .pending:
- .orange
- case .approved:
- .green
- case .rejected:
- .red
- }
- }
-
- private var backgroundStyle: Color {
- isSelected ? rowAccent.opacity(0.08) : Color.white.opacity(0.90)
- }
-
- private var rowAccent: Color {
- switch request.status {
- case .approved:
- .green
- case .rejected:
- .red
- case .pending:
- request.risk == .routine ? dashboardAccent : .orange
- }
- }
-}
-
-private struct RequestWorkbenchDetail: View {
- let request: ApprovalRequest
- let isBusy: Bool
- let onApprove: (() -> Void)?
- let onReject: (() -> Void)?
- let onOpenRequest: () -> Void
-
- private let columns = [
- GridItem(.flexible(), spacing: 12),
- GridItem(.flexible(), spacing: 12)
- ]
-
- var body: some View {
- VStack(alignment: .leading, spacing: 18) {
- ZStack(alignment: .topLeading) {
- RoundedRectangle(cornerRadius: 30, style: .continuous)
- .fill(
- LinearGradient(
- colors: [
- request.risk == .routine ? dashboardAccent.opacity(0.95) : Color.orange.opacity(0.92),
- dashboardGold.opacity(0.88)
- ],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- )
- .overlay(
- RoundedRectangle(cornerRadius: 30, style: .continuous)
- .strokeBorder(requestAccent.opacity(0.20), lineWidth: 1)
- )
-
- VStack(alignment: .leading, spacing: 16) {
- HStack(alignment: .top, spacing: 12) {
- VStack(alignment: .leading, spacing: 10) {
- HStack(spacing: 8) {
- StatusBadge(title: request.kind.title, tone: .white)
- StatusBadge(title: request.risk.title, tone: .white)
- StatusBadge(title: request.status.title, tone: .white)
- }
-
- Text(request.title)
- .font(.system(size: 30, weight: .bold, design: .rounded))
- .foregroundStyle(.white)
-
- Text(request.trustHeadline)
- .font(.headline)
- .foregroundStyle(.white.opacity(0.84))
- }
-
- Spacer(minLength: 0)
-
- VStack(alignment: .trailing, spacing: 6) {
- Text("REQUESTED")
- .font(.caption.weight(.bold))
- .foregroundStyle(.white.opacity(0.72))
-
- Text(request.createdAt.formatted(date: .abbreviated, time: .shortened))
- .font(.subheadline.weight(.semibold))
- .foregroundStyle(.white)
- }
- }
-
- Text(request.subtitle)
- .font(.title3)
- .foregroundStyle(.white.opacity(0.88))
-
- HStack(spacing: 14) {
- Label(request.source, systemImage: "network")
- Label(request.scopeSummary, systemImage: "lock.shield")
- }
- .font(.subheadline)
- .foregroundStyle(.white.opacity(0.88))
-
- Text(request.trustDetail)
- .font(.subheadline)
- .foregroundStyle(.white.opacity(0.82))
- }
- .padding(24)
- }
- .frame(minHeight: 220)
-
- LazyVGrid(columns: columns, spacing: 12) {
- FactCard(label: "Source", value: request.source)
- FactCard(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened))
- FactCard(label: "Type", value: request.kind.title)
- FactCard(label: "Status", value: request.status.title)
- FactCard(label: "Risk", value: request.risk.summary)
- FactCard(label: "Access", value: request.scopeSummary)
- }
-
- HStack(alignment: .top, spacing: 12) {
- RequestSignalCard(
- title: "Trust Signals",
- subtitle: "The approval story should match the device, the product, and the moment you just triggered.",
- accent: requestAccent
- ) {
- VStack(alignment: .leading, spacing: 14) {
- GuidanceRow(
- icon: "network.badge.shield.half.filled",
- title: "Source must look familiar",
- message: "This request comes from \(request.source). Only approve if that host or product lines up with what you intended."
- )
- GuidanceRow(
- icon: "person.badge.shield.checkmark",
- title: "Action should fit the session",
- message: request.trustDetail
- )
- GuidanceRow(
- icon: request.risk == .routine ? "checkmark.shield" : "exclamationmark.shield",
- title: request.risk == .routine ? "Routine review is still a review" : "Elevated access deserves a pause",
- message: request.risk.guidance
- )
- }
- }
-
- RequestSignalCard(
- title: "Access Envelope",
- subtitle: "These are the capabilities this request wants before it can proceed.",
- accent: dashboardGold
- ) {
- if request.scopes.isEmpty {
- Text("The mock backend did not provide explicit scopes for this request.")
- .foregroundStyle(.secondary)
- } else {
- FlowScopes(scopes: request.scopes)
- }
- }
- }
-
- RequestSignalCard(
- title: request.status == .pending ? "Decision Rail" : "Decision Record",
- subtitle: request.status == .pending
- ? "Use the actions below only once the request story matches the device in your hand."
- : "This request already moved through the queue, so this rail becomes a compact audit note.",
- accent: statusTone
- ) {
- VStack(alignment: .leading, spacing: 14) {
- Text(request.trustDetail)
- .foregroundStyle(.secondary)
-
- Text(decisionSummary)
- .font(.headline)
-
- HStack(spacing: 12) {
- Button {
- onOpenRequest()
- } label: {
- Label("Open Full Review", systemImage: "arrow.up.forward.app")
- }
- .buttonStyle(.bordered)
-
- Spacer()
-
- if let onApprove, let onReject, request.status == .pending {
- Button {
- onApprove()
- } label: {
- if isBusy {
- ProgressView()
- } else {
- Label("Approve", systemImage: "checkmark.circle.fill")
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(isBusy)
-
- Button(role: .destructive) {
- onReject()
- } label: {
- Label("Reject", systemImage: "xmark.circle.fill")
- }
- .buttonStyle(.bordered)
- .disabled(isBusy)
- }
- }
- }
- }
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
-
- private var statusTone: Color {
- switch request.status {
- case .pending:
- return .orange
- case .approved:
- return .green
- case .rejected:
- return .red
- }
- }
-
- private var requestAccent: Color {
- request.risk == .routine ? dashboardAccent : .orange
- }
-
- private var decisionSummary: String {
- switch request.status {
- case .pending:
- return request.risk == .routine
- ? "Approve only if the origin and timing feel boringly expected."
- : "Privileged requests should feel unmistakably intentional before you approve them."
- case .approved:
- return "This request has already been approved and should now be treated as part of your recent decision history."
- case .rejected:
- return "This request was rejected and is now a record of a blocked access attempt."
- }
- }
-}
-
-private struct RequestFactPill: View {
- let label: String
- let value: String
- let accent: Color
-
- var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(label.uppercased())
- .font(.caption2.weight(.semibold))
- .foregroundStyle(.secondary)
-
- Text(value)
- .font(.subheadline.weight(.semibold))
- .foregroundStyle(.primary)
- .lineLimit(2)
- .minimumScaleFactor(0.8)
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 10)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .stroke(accent.opacity(0.08), lineWidth: 1)
- )
- }
-}
-
-private struct RequestSignalCard: View {
- let title: String
- let subtitle: String
- let accent: Color
- let content: () -> Content
-
- init(
- title: String,
- subtitle: String,
- accent: Color,
- @ViewBuilder content: @escaping () -> Content
- ) {
- self.title = title
- self.subtitle = subtitle
- self.accent = accent
- self.content = content
- }
-
- var body: some View {
- VStack(alignment: .leading, spacing: 14) {
- HStack(alignment: .top, spacing: 12) {
- Circle()
- .fill(accent.opacity(0.16))
- .frame(width: 34, height: 34)
- .overlay {
- Circle()
- .stroke(accent.opacity(0.30), lineWidth: 1)
- }
-
- VStack(alignment: .leading, spacing: 4) {
- Text(title)
- .font(.headline)
- Text(subtitle)
- .foregroundStyle(.secondary)
- }
- }
-
- content()
- }
- .padding(18)
- .frame(maxWidth: .infinity, alignment: .leading)
- .dashboardSurface(radius: 24)
- }
-}
-
private struct RequestDetailSheet: View {
let request: ApprovalRequest
@ObservedObject var model: AppViewModel
@@ -2507,46 +941,40 @@ private struct RequestDetailSheet: View {
var body: some View {
NavigationStack {
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- RequestDetailHero(request: request)
+ AppScrollScreen(
+ compactLayout: true,
+ bottomPadding: AppLayout.compactBottomDockPadding
+ ) {
+ RequestDetailHero(request: request)
- SectionCard(
- title: "Requested Access",
- subtitle: "The exact scopes or capabilities this action wants to receive."
- ) {
- if request.scopes.isEmpty {
- Text("No explicit scopes were provided by the mock backend.")
- .foregroundStyle(.secondary)
- } else {
- FlowScopes(scopes: request.scopes)
- }
- }
+ AppSectionCard(title: "Summary", compactLayout: true) {
+ AppKeyValue(label: "Source", value: request.source)
+ AppKeyValue(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened))
+ AppKeyValue(label: "Risk", value: request.risk.summary)
+ AppKeyValue(label: "Type", value: request.kind.title)
+ }
- SectionCard(
- title: "Trust Signals",
- subtitle: "The details to validate before you approve anything sensitive."
- ) {
- VStack(alignment: .leading, spacing: 12) {
- FactCard(label: "Source", value: request.source)
- FactCard(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened))
- FactCard(label: "Type", value: request.kind.title)
- FactCard(label: "Risk", value: request.risk.summary)
- }
- }
-
- SectionCard(
- title: "Decision Guidance",
- subtitle: "A short operator-minded reminder before you accept or reject this request."
- ) {
- Text(request.trustDetail)
+ AppSectionCard(title: "Proof details", compactLayout: true) {
+ if request.scopes.isEmpty {
+ Text("No explicit proof details were provided by the mock backend.")
+ .foregroundStyle(.secondary)
+ } else {
+ Text(request.scopes.joined(separator: "\n"))
+ .font(.body.monospaced())
.foregroundStyle(.secondary)
-
- Text(request.risk.guidance)
- .font(.headline)
}
+ }
- if request.status == .pending {
+ AppSectionCard(title: "Guidance", compactLayout: true) {
+ Text(request.trustDetail)
+ .foregroundStyle(.secondary)
+
+ Text(request.risk.guidance)
+ .font(.headline)
+ }
+
+ if request.status == .pending {
+ AppSectionCard(title: "Actions", compactLayout: true) {
VStack(spacing: 12) {
Button {
Task {
@@ -2557,7 +985,8 @@ private struct RequestDetailSheet: View {
if model.activeRequestID == request.id {
ProgressView()
} else {
- Label("Approve Request", systemImage: "checkmark.circle.fill")
+ Label("Verify identity", systemImage: "checkmark.circle.fill")
+ .frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
@@ -2569,20 +998,17 @@ private struct RequestDetailSheet: View {
dismiss()
}
} label: {
- Label("Reject Request", systemImage: "xmark.circle.fill")
+ Label("Decline", systemImage: "xmark.circle.fill")
+ .frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(model.activeRequestID == request.id)
}
}
}
- .padding(.horizontal, DashboardSpacing.compactOuterPadding)
- .padding(.top, DashboardSpacing.compactTopPadding)
- .padding(.bottom, DashboardSpacing.compactBottomPadding)
- .frame(maxWidth: DashboardSpacing.compactContentWidth, alignment: .leading)
- .frame(maxWidth: .infinity, alignment: .leading)
}
- .navigationTitle("Review Request")
+ .navigationTitle("Review Proof")
+ .inlineNavigationTitleOnIOS()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
@@ -2597,465 +1023,116 @@ private struct RequestDetailSheet: View {
private struct RequestDetailHero: View {
let request: ApprovalRequest
- var body: some View {
- ZStack(alignment: .bottomLeading) {
- RoundedRectangle(cornerRadius: 30, style: .continuous)
- .fill(
- LinearGradient(
- colors: [
- request.risk == .routine ? dashboardAccent.opacity(0.92) : Color.orange.opacity(0.92),
- dashboardGold.opacity(0.88)
- ],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- )
-
- VStack(alignment: .leading, spacing: 12) {
- Text(request.trustHeadline)
- .font(.headline)
- .foregroundStyle(.white.opacity(0.86))
- Text(request.title)
- .font(.system(size: 30, weight: .bold, design: .rounded))
- .foregroundStyle(.white)
- Text(request.subtitle)
- .foregroundStyle(.white.opacity(0.86))
- }
- .padding(24)
- }
- .frame(minHeight: 210)
- }
-}
-
-private struct NotificationCard: View {
- let notification: AppNotification
- let compactLayout: Bool
- let onMarkRead: () -> Void
-
- var body: some View {
- VStack(alignment: .leading, spacing: 14) {
- HStack(alignment: .top, spacing: 14) {
- Image(systemName: notification.kind.systemImage)
- .font(.title3)
- .frame(width: 38, height: 38)
- .background(.thinMaterial, in: Circle())
- .foregroundStyle(accentColor)
-
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Text(notification.title)
- .font(.headline)
- Spacer()
- if notification.isUnread {
- StatusBadge(title: "Unread", tone: .orange)
- }
- }
-
- Text(notification.kind.summary)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- }
-
- Text(notification.message)
- .foregroundStyle(.secondary)
-
- Group {
- if compactLayout {
- VStack(alignment: .leading, spacing: 10) {
- timestampLabel
- if notification.isUnread {
- markReadButton
- }
- }
- } else {
- HStack {
- timestampLabel
- Spacer()
- if notification.isUnread {
- markReadButton
- }
- }
- }
- }
- }
- .padding(compactLayout ? 16 : 18)
- .dashboardSurface(radius: compactLayout ? 22 : 24)
- }
-
- private var timestampLabel: some View {
- Text(notification.sentAt.formatted(date: .abbreviated, time: .shortened))
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
-
- private var markReadButton: some View {
- Button {
- onMarkRead()
- } label: {
- Label("Mark Read", systemImage: "checkmark")
- }
- .buttonStyle(.bordered)
- }
-
- private var accentColor: Color {
- switch notification.kind {
- case .approval:
+ private var accent: Color {
+ switch request.status {
+ case .approved:
.green
- case .security:
- .orange
- case .system:
- .blue
+ case .rejected:
+ .red
+ case .pending:
+ request.risk == .routine ? dashboardAccent : .orange
+ }
+ }
+
+ var body: some View {
+ AppPanel(compactLayout: true, radius: AppLayout.largeCardRadius) {
+ AppBadge(title: request.kind.title, tone: accent)
+
+ Text(request.title)
+ .font(.system(size: 30, weight: .bold, design: .rounded))
+ .lineLimit(3)
+
+ Text(request.subtitle)
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 8) {
+ AppStatusTag(title: request.status.title, tone: accent)
+ AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
+ }
}
}
}
-private struct NotificationMetricCard: View {
- let title: String
- let value: String
- let subtitle: String
- let accent: Color
+private struct OneTimePasscodeSheet: View {
+ let session: AuthSession
+
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
- VStack(alignment: .leading, spacing: 10) {
- Text(title.uppercased())
- .font(.caption.weight(.semibold))
- .foregroundStyle(.secondary)
+ NavigationStack {
+ TimelineView(.periodic(from: .now, by: 1)) { context in
+ let code = passcode(at: context.date)
+ let secondsRemaining = renewalCountdown(at: context.date)
- Text(value)
- .font(.title3.weight(.semibold))
- .foregroundStyle(.primary)
+ AppScrollScreen(compactLayout: compactLayout) {
+ AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
+ AppBadge(title: "One-time passcode", tone: dashboardGold)
- Text(subtitle)
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- .padding(18)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 20, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .stroke(accent.opacity(0.08), lineWidth: 1)
- )
- }
-}
+ Text("OTP")
+ .font(.system(size: compactLayout ? 32 : 40, weight: .bold, design: .rounded))
-private struct NotificationFeedRow: View {
- let notification: AppNotification
- let isSelected: Bool
- let action: () -> Void
-
- var body: some View {
- Button(action: action) {
- VStack(alignment: .leading, spacing: 10) {
- HStack(alignment: .top, spacing: 12) {
- Image(systemName: notification.kind.systemImage)
- .font(.headline)
- .foregroundStyle(accentColor)
- .frame(width: 34, height: 34)
- .background(.thinMaterial, in: Circle())
-
- VStack(alignment: .leading, spacing: 4) {
- Text(notification.title)
- .font(.headline)
- .foregroundStyle(.primary)
- .multilineTextAlignment(.leading)
-
- Text(notification.kind.summary)
+ Text("Share this code only with the site or device asking you to prove that it is really you.")
.font(.subheadline)
.foregroundStyle(.secondary)
- }
- Spacer(minLength: 0)
+ Text(code)
+ .font(.system(size: compactLayout ? 42 : 54, weight: .bold, design: .rounded).monospacedDigit())
+ .tracking(compactLayout ? 4 : 6)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, compactLayout ? 16 : 20)
+ .background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 24, style: .continuous)
+ .stroke(AppTheme.border, lineWidth: 1)
+ )
- if notification.isUnread {
- Circle()
- .fill(Color.orange)
- .frame(width: 10, height: 10)
+ HStack(spacing: 8) {
+ AppStatusTag(title: "Renews in \(secondsRemaining)s", tone: dashboardGold)
+ AppStatusTag(title: session.originHost, tone: dashboardAccent)
+ }
+
+ Divider()
+
+ AppKeyValue(label: "Client", value: session.deviceName)
+ AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
}
}
-
- Text(notification.message)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .lineLimit(2)
- .multilineTextAlignment(.leading)
-
- HStack {
- StatusBadge(title: notification.kind.title, tone: accentColor)
- Spacer()
- Text(notification.sentAt.formatted(date: .omitted, time: .shortened))
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
}
- .padding(16)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(backgroundStyle, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 24, style: .continuous)
- .stroke(isSelected ? accentColor.opacity(0.35) : Color.clear, lineWidth: 1.5)
- )
- }
- .buttonStyle(.plain)
- }
-
- private var accentColor: Color {
- switch notification.kind {
- case .approval:
- .green
- case .security:
- .orange
- case .system:
- .blue
- }
- }
-
- private var backgroundStyle: Color {
- isSelected ? accentColor.opacity(0.10) : Color.white.opacity(0.58)
- }
-}
-
-private struct NotificationWorkbenchDetail: View {
- let notification: AppNotification
- let permissionState: NotificationPermissionState
- let onMarkRead: () -> Void
-
- private let columns = [
- GridItem(.flexible(), spacing: 12),
- GridItem(.flexible(), spacing: 12)
- ]
-
- var body: some View {
- VStack(alignment: .leading, spacing: 18) {
- ZStack(alignment: .bottomLeading) {
- RoundedRectangle(cornerRadius: 30, style: .continuous)
- .fill(
- LinearGradient(
- colors: [
- accentColor.opacity(0.95),
- accentColor.opacity(0.70),
- dashboardGold.opacity(0.82)
- ],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- )
-
- VStack(alignment: .leading, spacing: 12) {
- HStack(spacing: 8) {
- StatusBadge(title: notification.kind.title, tone: .white)
- StatusBadge(title: notification.isUnread ? "Unread" : "Read", tone: .white)
+ .navigationTitle("OTP")
+ .inlineNavigationTitleOnIOS()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Close") {
+ dismiss()
}
-
- Text(notification.title)
- .font(.system(size: 30, weight: .bold, design: .rounded))
- .foregroundStyle(.white)
-
- Text(notification.message)
- .foregroundStyle(.white.opacity(0.9))
- }
- .padding(24)
- }
- .frame(minHeight: 210)
-
- LazyVGrid(columns: columns, spacing: 12) {
- FactCard(label: "Category", value: notification.kind.summary)
- FactCard(label: "Sent", value: notification.sentAt.formatted(date: .abbreviated, time: .shortened))
- FactCard(label: "Inbox State", value: notification.isUnread ? "Still highlighted" : "Already cleared")
- FactCard(label: "Delivery", value: permissionState.title)
- }
-
- VStack(alignment: .leading, spacing: 10) {
- Text("Delivery Context")
- .font(.headline)
-
- Text(permissionState.summary)
- .foregroundStyle(.secondary)
-
- Text(notification.isUnread ? "This alert is still asking for attention in the in-app feed." : "This alert has already been acknowledged in the mock inbox.")
- .font(.headline)
- }
- .padding(18)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 26, style: .continuous))
-
- if notification.isUnread {
- Button {
- onMarkRead()
- } label: {
- Label("Mark Read", systemImage: "checkmark")
- }
- .buttonStyle(.borderedProminent)
- }
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
-
- private var accentColor: Color {
- switch notification.kind {
- case .approval:
- .green
- case .security:
- .orange
- case .system:
- .blue
- }
- }
-}
-
-private struct SectionCard: View {
- let title: String
- let subtitle: String
- let compactLayout: Bool
- let content: () -> Content
-
- init(
- title: String,
- subtitle: String,
- compactLayout: Bool = false,
- @ViewBuilder content: @escaping () -> Content
- ) {
- self.title = title
- self.subtitle = subtitle
- self.compactLayout = compactLayout
- 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(compactLayout ? DashboardSpacing.compactSectionPadding : DashboardSpacing.regularSectionPadding)
- .frame(maxWidth: .infinity, alignment: .leading)
- .dashboardSurface(radius: compactLayout ? DashboardSpacing.compactRadius : DashboardSpacing.regularRadius)
- }
-}
-
-private struct BannerCard: View {
- let message: String
- let compactLayout: Bool
-
- var body: some View {
- HStack(spacing: 12) {
- Image(systemName: "sparkles")
- .font(.title3)
- .foregroundStyle(dashboardAccent)
- Text(message)
- .font(compactLayout ? .subheadline.weight(.semibold) : .headline)
- }
- .padding(.horizontal, 16)
- .padding(.vertical, 12)
- .dashboardSurface(radius: 999, fillOpacity: 0.84)
- }
-}
-
-private struct SmallMetricPill: View {
- let title: String
- let value: String
-
- var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(title.uppercased())
- .font(.caption2.weight(.semibold))
- .foregroundStyle(.secondary)
- Text(value)
- .font(.headline)
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 10)
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
- }
-}
-
-private struct HeroMetric: View {
- let title: String
- let value: String
-
- var body: some View {
- VStack(alignment: .leading, spacing: 6) {
- Text(title.uppercased())
- .font(.caption.weight(.semibold))
- .foregroundStyle(.white.opacity(0.72))
- Text(value)
- .font(.title2.weight(.bold))
- .foregroundStyle(.white)
- }
- .padding(.horizontal, 16)
- .padding(.vertical, 14)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(.white.opacity(0.12), in: RoundedRectangle(cornerRadius: 20, style: .continuous))
- }
-}
-
-private struct GuidanceRow: View {
- let icon: String
- let title: String
- let message: String
-
- var body: some View {
- HStack(alignment: .top, spacing: 12) {
- Image(systemName: icon)
- .font(.title3)
- .frame(width: 32)
- .foregroundStyle(dashboardAccent)
-
- VStack(alignment: .leading, spacing: 4) {
- Text(title)
- .font(.headline)
- Text(message)
- .foregroundStyle(.secondary)
- }
- }
- }
-}
-
-private struct GuidanceCard: View {
- let icon: String
- let title: String
- let message: String
-
- var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- Image(systemName: icon)
- .font(.title3)
- .foregroundStyle(dashboardAccent)
-
- Text(title)
- .font(.headline)
-
- Text(message)
- .foregroundStyle(.secondary)
- }
- .padding(18)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
- }
-}
-
-private struct FlowScopes: View {
- let scopes: [String]
-
- var body: some View {
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 8) {
- ForEach(scopes, id: \.self) { scope in
- Text(scope)
- .font(.caption.monospaced())
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .background(.thinMaterial, in: Capsule())
}
}
}
}
+
+ private var compactLayout: Bool {
+ #if os(iOS)
+ horizontalSizeClass == .compact
+ #else
+ false
+ #endif
+ }
+
+ private func passcode(at date: Date) -> String {
+ let timeSlot = Int(date.timeIntervalSince1970 / 30)
+ let digest = SHA256.hash(data: Data("\(session.pairingCode)|\(timeSlot)".utf8))
+ let value = digest.prefix(4).reduce(UInt32(0)) { partialResult, byte in
+ (partialResult << 8) | UInt32(byte)
+ }
+
+ return String(format: "%06d", locale: Locale(identifier: "en_US_POSIX"), Int(value % 1_000_000))
+ }
+
+ private func renewalCountdown(at date: Date) -> Int {
+ let elapsed = Int(date.timeIntervalSince1970) % 30
+ return elapsed == 0 ? 30 : 30 - elapsed
+ }
}
private struct EmptyStateCopy: View {
@@ -3073,67 +1150,3 @@ private struct EmptyStateCopy: View {
.padding(.vertical, 10)
}
}
-
-private struct FactCard: View {
- let label: String
- let value: String
-
- var body: some View {
- VStack(alignment: .leading, spacing: 6) {
- Text(label.uppercased())
- .font(.caption.weight(.semibold))
- .foregroundStyle(.secondary)
- Text(value)
- .font(.body)
- }
- .padding(14)
- .frame(maxWidth: .infinity, alignment: .leading)
- .dashboardSurface(radius: 18)
- }
-}
-
-private struct StatusBadge: View {
- let title: String
- let tone: Color
-
- 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.14), in: Capsule())
- .foregroundStyle(tone)
- }
-}
-
-private struct DashboardBackdrop: View {
- var body: some View {
- LinearGradient(
- colors: [
- Color(red: 0.98, green: 0.98, blue: 0.97),
- Color.white,
- Color(red: 0.97, green: 0.98, blue: 0.99)
- ],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- .overlay(alignment: .topLeading) {
- Circle()
- .fill(dashboardAccent.opacity(0.10))
- .frame(width: 360, height: 360)
- .blur(radius: 70)
- .offset(x: -120, y: -120)
- }
- .overlay(alignment: .bottomTrailing) {
- Circle()
- .fill(dashboardGold.opacity(0.12))
- .frame(width: 420, height: 420)
- .blur(radius: 90)
- .offset(x: 140, y: 160)
- }
- .ignoresSafeArea()
- }
-}
diff --git a/WatchApp/App/IDPGlobalWatchApp.swift b/WatchApp/App/IDPGlobalWatchApp.swift
new file mode 100644
index 0000000..57cf75a
--- /dev/null
+++ b/WatchApp/App/IDPGlobalWatchApp.swift
@@ -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 {
+ Binding(
+ get: { model.errorMessage != nil },
+ set: { isPresented in
+ if !isPresented {
+ model.errorMessage = nil
+ }
+ }
+ )
+ }
+}
diff --git a/WatchApp/Features/WatchRootView.swift b/WatchApp/Features/WatchRootView.swift
new file mode 100644
index 0000000..0f43c8c
--- /dev/null
+++ b/WatchApp/Features/WatchRootView.swift
@@ -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
+ }()
+}