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 + }() +}