diff --git a/swift/Assets.xcassets/AppIcon.appiconset/ios-marketing-1024@1x.png b/swift/Assets.xcassets/AppIcon.appiconset/ios-marketing-1024@1x.png index 0b0e825..20077d9 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/ios-marketing-1024@1x.png and b/swift/Assets.xcassets/AppIcon.appiconset/ios-marketing-1024@1x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/ipad-20@1x.png b/swift/Assets.xcassets/AppIcon.appiconset/ipad-20@1x.png index 5489647..d7f6860 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/ipad-20@1x.png and b/swift/Assets.xcassets/AppIcon.appiconset/ipad-20@1x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/ipad-20@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/ipad-20@2x.png index e650447..9c1f93f 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/ipad-20@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/ipad-20@2x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/ipad-29@1x.png b/swift/Assets.xcassets/AppIcon.appiconset/ipad-29@1x.png index 1ba88e3..a912eef 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/ipad-29@1x.png and b/swift/Assets.xcassets/AppIcon.appiconset/ipad-29@1x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/ipad-29@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/ipad-29@2x.png index 33e0f68..257a501 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/ipad-29@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/ipad-29@2x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/ipad-40@1x.png b/swift/Assets.xcassets/AppIcon.appiconset/ipad-40@1x.png index e650447..9c1f93f 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/ipad-40@1x.png and b/swift/Assets.xcassets/AppIcon.appiconset/ipad-40@1x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/ipad-40@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/ipad-40@2x.png index 03e3ae1..e71c5b5 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/ipad-40@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/ipad-40@2x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/ipad-76@1x.png b/swift/Assets.xcassets/AppIcon.appiconset/ipad-76@1x.png index a8b80ce..c070e7d 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/ipad-76@1x.png and b/swift/Assets.xcassets/AppIcon.appiconset/ipad-76@1x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/ipad-76@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/ipad-76@2x.png index 69ac8f0..e87761b 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/ipad-76@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/ipad-76@2x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/ipad-83.5@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/ipad-83.5@2x.png index 0c93e1e..c79422f 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/ipad-83.5@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/ipad-83.5@2x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/iphone-20@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/iphone-20@2x.png index e650447..9c1f93f 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/iphone-20@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/iphone-20@2x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/iphone-20@3x.png b/swift/Assets.xcassets/AppIcon.appiconset/iphone-20@3x.png index c8a6195..0a1e995 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/iphone-20@3x.png and b/swift/Assets.xcassets/AppIcon.appiconset/iphone-20@3x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/iphone-29@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/iphone-29@2x.png index 33e0f68..257a501 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/iphone-29@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/iphone-29@2x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/iphone-29@3x.png b/swift/Assets.xcassets/AppIcon.appiconset/iphone-29@3x.png index 4559b19..4efddef 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/iphone-29@3x.png and b/swift/Assets.xcassets/AppIcon.appiconset/iphone-29@3x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/iphone-40@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/iphone-40@2x.png index 03e3ae1..e71c5b5 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/iphone-40@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/iphone-40@2x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/iphone-40@3x.png b/swift/Assets.xcassets/AppIcon.appiconset/iphone-40@3x.png index 22dfc7b..b2a496f 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/iphone-40@3x.png and b/swift/Assets.xcassets/AppIcon.appiconset/iphone-40@3x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/iphone-60@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/iphone-60@2x.png index 22dfc7b..b2a496f 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/iphone-60@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/iphone-60@2x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/iphone-60@3x.png b/swift/Assets.xcassets/AppIcon.appiconset/iphone-60@3x.png index 2d7ffde..f1a173e 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/iphone-60@3x.png and b/swift/Assets.xcassets/AppIcon.appiconset/iphone-60@3x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/mac-128@1x.png b/swift/Assets.xcassets/AppIcon.appiconset/mac-128@1x.png index 1ecb20c..eb0110d 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/mac-128@1x.png and b/swift/Assets.xcassets/AppIcon.appiconset/mac-128@1x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/mac-128@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/mac-128@2x.png index 6bb6d5f..9353328 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/mac-128@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/mac-128@2x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/mac-16@1x.png b/swift/Assets.xcassets/AppIcon.appiconset/mac-16@1x.png index 65f121a..0764bd0 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/mac-16@1x.png and b/swift/Assets.xcassets/AppIcon.appiconset/mac-16@1x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/mac-16@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/mac-16@2x.png index f7be521..8a7b525 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/mac-16@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/mac-16@2x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/mac-256@1x.png b/swift/Assets.xcassets/AppIcon.appiconset/mac-256@1x.png index 6bb6d5f..9353328 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/mac-256@1x.png and b/swift/Assets.xcassets/AppIcon.appiconset/mac-256@1x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/mac-256@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/mac-256@2x.png index d8d35f2..5c3dff3 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/mac-256@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/mac-256@2x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/mac-32@1x.png b/swift/Assets.xcassets/AppIcon.appiconset/mac-32@1x.png index f7be521..8a7b525 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/mac-32@1x.png and b/swift/Assets.xcassets/AppIcon.appiconset/mac-32@1x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/mac-32@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/mac-32@2x.png index d1b8c58..fd3e87f 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/mac-32@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/mac-32@2x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/mac-512@1x.png b/swift/Assets.xcassets/AppIcon.appiconset/mac-512@1x.png index d8d35f2..5c3dff3 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/mac-512@1x.png and b/swift/Assets.xcassets/AppIcon.appiconset/mac-512@1x.png differ diff --git a/swift/Assets.xcassets/AppIcon.appiconset/mac-512@2x.png b/swift/Assets.xcassets/AppIcon.appiconset/mac-512@2x.png index 0b0e825..20077d9 100644 Binary files a/swift/Assets.xcassets/AppIcon.appiconset/mac-512@2x.png and b/swift/Assets.xcassets/AppIcon.appiconset/mac-512@2x.png differ diff --git a/swift/Assets.xcassets/AppMonogram.imageset/AppMonogram.png b/swift/Assets.xcassets/AppMonogram.imageset/AppMonogram.png new file mode 100644 index 0000000..7c2c355 Binary files /dev/null and b/swift/Assets.xcassets/AppMonogram.imageset/AppMonogram.png differ diff --git a/swift/Assets.xcassets/AppMonogram.imageset/Contents.json b/swift/Assets.xcassets/AppMonogram.imageset/Contents.json new file mode 100644 index 0000000..027853a --- /dev/null +++ b/swift/Assets.xcassets/AppMonogram.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "AppMonogram.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/swift/Assets.xcassets/IdPTint.colorset/Contents.json b/swift/Assets.xcassets/IdPTint.colorset/Contents.json new file mode 100644 index 0000000..2a10cc3 --- /dev/null +++ b/swift/Assets.xcassets/IdPTint.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.902", + "green" : "0.357", + "red" : "0.431" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.941", + "green" : "0.478", + "red" : "0.545" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/swift/IDPGlobal.entitlements b/swift/IDPGlobal.entitlements index 5f7b942..e5e920c 100644 --- a/swift/IDPGlobal.entitlements +++ b/swift/IDPGlobal.entitlements @@ -2,6 +2,16 @@ + com.apple.developer.activitykit + + com.apple.security.application-groups + + group.global.idp.app + + com.apple.developer.icloud-services + + CloudKit + com.apple.developer.nfc.readersession.formats NDEF diff --git a/swift/IDPGlobal.xcodeproj/project.pbxproj b/swift/IDPGlobal.xcodeproj/project.pbxproj index 112482c..4ef7ed4 100644 --- a/swift/IDPGlobal.xcodeproj/project.pbxproj +++ b/swift/IDPGlobal.xcodeproj/project.pbxproj @@ -40,6 +40,29 @@ B1000000000000000000001F /* OneTimePasscodeGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */; }; B10000000000000000000020 /* AppViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000019 /* AppViewModelTests.swift */; }; B10000000000000000000021 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B2000000000000000000001B /* XCTest.framework */; }; + B10000000000000000000022 /* IdPTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000001C /* IdPTokens.swift */; }; + B10000000000000000000023 /* ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000001D /* ButtonStyles.swift */; }; + B10000000000000000000024 /* GlassChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000001E /* GlassChrome.swift */; }; + B10000000000000000000025 /* Cards.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000001F /* Cards.swift */; }; + B10000000000000000000026 /* StatusDot.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000020 /* StatusDot.swift */; }; + B10000000000000000000027 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000021 /* Haptics.swift */; }; + B10000000000000000000028 /* IdPTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000022 /* IdPTokens.swift */; }; + B10000000000000000000029 /* ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000023 /* ButtonStyles.swift */; }; + B1000000000000000000002A /* GlassChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000024 /* GlassChrome.swift */; }; + B1000000000000000000002B /* Cards.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000025 /* Cards.swift */; }; + B1000000000000000000002C /* StatusDot.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000026 /* StatusDot.swift */; }; + B1000000000000000000002D /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000027 /* Haptics.swift */; }; + B1000000000000000000002E /* ApprovalActivityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000028 /* ApprovalActivityController.swift */; }; + B1000000000000000000002F /* ApprovalActivityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000028 /* ApprovalActivityController.swift */; }; + B10000000000000000000030 /* ApprovalActivityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000029 /* ApprovalActivityModels.swift */; }; + B10000000000000000000031 /* ApprovalActivityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000029 /* ApprovalActivityModels.swift */; }; + B10000000000000000000032 /* IDPGlobalWidgetsBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000002A /* IDPGlobalWidgetsBundle.swift */; }; + B10000000000000000000033 /* IDPGlobalWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = B2000000000000000000002C /* IDPGlobalWidgets.appex */; platformFilter = ios; }; + B10000000000000000000034 /* IDPGlobalWidgets.appex in Embed Widget Extensions */ = {isa = PBXBuildFile; fileRef = B2000000000000000000002C /* IDPGlobalWidgets.appex */; platformFilter = watchos; }; + B10000000000000000000035 /* AppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000003 /* AppModels.swift */; }; + B10000000000000000000036 /* AppStateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000011 /* AppStateStore.swift */; }; + B10000000000000000000037 /* MockIDPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004 /* MockIDPService.swift */; }; + B10000000000000000000038 /* PairingPayloadParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000013 /* PairingPayloadParser.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -57,6 +80,13 @@ remoteGlobalIDString = B50000000000000000000001; remoteInfo = IDPGlobal; }; + B90000000000000000000005 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B60000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B50000000000000000000004; + remoteInfo = IDPGlobalWidgets; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -71,6 +101,28 @@ name = "Embed Watch Content"; runOnlyForDeploymentPostprocessing = 0; }; + B3000000000000000000000B /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + B10000000000000000000033 /* IDPGlobalWidgets.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + B3000000000000000000000F /* Embed Widget Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + B10000000000000000000034 /* IDPGlobalWidgets.appex in Embed Widget Extensions */, + ); + name = "Embed Widget Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -101,6 +153,23 @@ B20000000000000000000019 /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = ""; }; B2000000000000000000001A /* IDPGlobalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IDPGlobalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B2000000000000000000001B /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + B2000000000000000000001C /* IdPTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdPTokens.swift; sourceTree = ""; }; + B2000000000000000000001D /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = ""; }; + B2000000000000000000001E /* GlassChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassChrome.swift; sourceTree = ""; }; + B2000000000000000000001F /* Cards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cards.swift; sourceTree = ""; }; + B20000000000000000000020 /* StatusDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusDot.swift; sourceTree = ""; }; + B20000000000000000000021 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; + B20000000000000000000022 /* IdPTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdPTokens.swift; sourceTree = ""; }; + B20000000000000000000023 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = ""; }; + B20000000000000000000024 /* GlassChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassChrome.swift; sourceTree = ""; }; + B20000000000000000000025 /* Cards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cards.swift; sourceTree = ""; }; + B20000000000000000000026 /* StatusDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusDot.swift; sourceTree = ""; }; + B20000000000000000000027 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; + B20000000000000000000028 /* ApprovalActivityController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApprovalActivityController.swift; sourceTree = ""; }; + B20000000000000000000029 /* ApprovalActivityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApprovalActivityModels.swift; sourceTree = ""; }; + B2000000000000000000002A /* IDPGlobalWidgetsBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDPGlobalWidgetsBundle.swift; sourceTree = ""; }; + B2000000000000000000002B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B2000000000000000000002C /* IDPGlobalWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = IDPGlobalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -126,6 +195,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B3000000000000000000000C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -163,6 +239,7 @@ children = ( B20000000000000000000010 /* AppTheme.swift */, B2000000000000000000000F /* AppComponents.swift */, + B20000000000000000000028 /* ApprovalActivityController.swift */, B20000000000000000000001 /* IDPGlobalApp.swift */, B20000000000000000000002 /* AppViewModel.swift */, ); @@ -172,6 +249,7 @@ B40000000000000000000005 /* Core */ = { isa = PBXGroup; children = ( + B40000000000000000000010 /* Design */, B40000000000000000000006 /* Models */, B40000000000000000000007 /* Services */, ); @@ -182,6 +260,7 @@ isa = PBXGroup; children = ( B20000000000000000000003 /* AppModels.swift */, + B20000000000000000000029 /* ApprovalActivityModels.swift */, ); path = Models; sourceTree = ""; @@ -212,6 +291,7 @@ children = ( B20000000000000000000009 /* IDPGlobal.app */, B2000000000000000000000A /* IDPGlobalWatch.app */, + B2000000000000000000002C /* IDPGlobalWidgets.appex */, B2000000000000000000001A /* IDPGlobalTests.xctest */, ); name = Products; @@ -242,7 +322,9 @@ isa = PBXGroup; children = ( B4000000000000000000000D /* App */, + B40000000000000000000011 /* Design */, B4000000000000000000000E /* Features */, + B40000000000000000000012 /* Widgets */, ); path = WatchApp; sourceTree = ""; @@ -273,6 +355,41 @@ path = Tests; sourceTree = ""; }; + B40000000000000000000010 /* Design */ = { + isa = PBXGroup; + children = ( + B2000000000000000000001D /* ButtonStyles.swift */, + B2000000000000000000001F /* Cards.swift */, + B2000000000000000000001E /* GlassChrome.swift */, + B20000000000000000000021 /* Haptics.swift */, + B2000000000000000000001C /* IdPTokens.swift */, + B20000000000000000000020 /* StatusDot.swift */, + ); + path = Design; + sourceTree = ""; + }; + B40000000000000000000011 /* Design */ = { + isa = PBXGroup; + children = ( + B20000000000000000000023 /* ButtonStyles.swift */, + B20000000000000000000025 /* Cards.swift */, + B20000000000000000000024 /* GlassChrome.swift */, + B20000000000000000000027 /* Haptics.swift */, + B20000000000000000000022 /* IdPTokens.swift */, + B20000000000000000000026 /* StatusDot.swift */, + ); + path = Design; + sourceTree = ""; + }; + B40000000000000000000012 /* Widgets */ = { + isa = PBXGroup; + children = ( + B2000000000000000000002A /* IDPGlobalWidgetsBundle.swift */, + B2000000000000000000002B /* Info.plist */, + ); + path = Widgets; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -284,11 +401,13 @@ B30000000000000000000001 /* Frameworks */, B30000000000000000000003 /* Resources */, B30000000000000000000004 /* Embed Watch Content */, + B3000000000000000000000B /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( B90000000000000000000002 /* PBXTargetDependency */, + B90000000000000000000006 /* PBXTargetDependency */, ); name = IDPGlobal; productName = IDPGlobal; @@ -302,10 +421,12 @@ B30000000000000000000007 /* Sources */, B30000000000000000000005 /* Frameworks */, B30000000000000000000006 /* Resources */, + B3000000000000000000000F /* Embed Widget Extensions */, ); buildRules = ( ); dependencies = ( + B90000000000000000000007 /* PBXTargetDependency */, ); name = IDPGlobalWatch; productName = IDPGlobalWatch; @@ -330,6 +451,23 @@ productReference = B2000000000000000000001A /* IDPGlobalTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + B50000000000000000000004 /* IDPGlobalWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = B70000000000000000000005 /* Build configuration list for PBXNativeTarget "IDPGlobalWidgets" */; + buildPhases = ( + B3000000000000000000000E /* Sources */, + B3000000000000000000000C /* Frameworks */, + B3000000000000000000000D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = IDPGlobalWidgets; + productName = IDPGlobalWidgets; + productReference = B2000000000000000000002C /* IDPGlobalWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -350,6 +488,9 @@ CreatedOnToolsVersion = 26.0; TestTargetID = B50000000000000000000001; }; + B50000000000000000000004 = { + CreatedOnToolsVersion = 26.0; + }; }; }; buildConfigurationList = B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */; @@ -368,6 +509,7 @@ B50000000000000000000001 /* IDPGlobal */, B50000000000000000000002 /* IDPGlobalWatch */, B50000000000000000000003 /* IDPGlobalTests */, + B50000000000000000000004 /* IDPGlobalWidgets */, ); }; /* End PBXProject section */ @@ -395,6 +537,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B3000000000000000000000D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -402,15 +551,22 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B1000000000000000000002E /* ApprovalActivityController.swift in Sources */, + B10000000000000000000030 /* ApprovalActivityModels.swift in Sources */, B10000000000000000000015 /* AppStateStore.swift in Sources */, B10000000000000000000012 /* AppComponents.swift in Sources */, B10000000000000000000014 /* AppTheme.swift in Sources */, B10000000000000000000002 /* AppViewModel.swift in Sources */, + B10000000000000000000023 /* ButtonStyles.swift in Sources */, + B10000000000000000000025 /* Cards.swift in Sources */, + B10000000000000000000024 /* GlassChrome.swift in Sources */, + B10000000000000000000027 /* Haptics.swift in Sources */, B10000000000000000000019 /* HomeCards.swift in Sources */, B10000000000000000000018 /* HomePanels.swift in Sources */, B10000000000000000000008 /* HomeRootView.swift in Sources */, B1000000000000000000001A /* HomeSheets.swift in Sources */, B10000000000000000000001 /* IDPGlobalApp.swift in Sources */, + B10000000000000000000022 /* IdPTokens.swift in Sources */, B10000000000000000000006 /* LoginRootView.swift in Sources */, B10000000000000000000004 /* MockIDPService.swift in Sources */, B10000000000000000000010 /* NFCPairingView.swift in Sources */, @@ -419,6 +575,7 @@ B10000000000000000000003 /* AppModels.swift in Sources */, B10000000000000000000017 /* PairingPayloadParser.swift in Sources */, B10000000000000000000007 /* QRScannerView.swift in Sources */, + B10000000000000000000026 /* StatusDot.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -426,15 +583,22 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B1000000000000000000002F /* ApprovalActivityController.swift in Sources */, B1000000000000000000001C /* AppStateStore.swift in Sources */, B10000000000000000000013 /* AppComponents.swift in Sources */, B1000000000000000000001B /* AppTheme.swift in Sources */, B10000000000000000000009 /* AppViewModel.swift in Sources */, B1000000000000000000000A /* AppModels.swift in Sources */, + B10000000000000000000029 /* ButtonStyles.swift in Sources */, + B1000000000000000000002B /* Cards.swift in Sources */, + B1000000000000000000002A /* GlassChrome.swift in Sources */, + B1000000000000000000002D /* Haptics.swift in Sources */, B1000000000000000000000D /* IDPGlobalWatchApp.swift in Sources */, + B10000000000000000000028 /* IdPTokens.swift in Sources */, B1000000000000000000000B /* MockIDPService.swift in Sources */, B1000000000000000000000C /* NotificationCoordinator.swift in Sources */, B1000000000000000000001D /* PairingPayloadParser.swift in Sources */, + B1000000000000000000002C /* StatusDot.swift in Sources */, B1000000000000000000000E /* WatchRootView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -449,6 +613,19 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B3000000000000000000000E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000031 /* ApprovalActivityModels.swift in Sources */, + B10000000000000000000035 /* AppModels.swift in Sources */, + B10000000000000000000036 /* AppStateStore.swift in Sources */, + B10000000000000000000032 /* IDPGlobalWidgetsBundle.swift in Sources */, + B10000000000000000000037 /* MockIDPService.swift in Sources */, + B10000000000000000000038 /* PairingPayloadParser.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -463,6 +640,18 @@ target = B50000000000000000000001 /* IDPGlobal */; targetProxy = B90000000000000000000003 /* PBXContainerItemProxy */; }; + B90000000000000000000006 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = B50000000000000000000004 /* IDPGlobalWidgets */; + targetProxy = B90000000000000000000005 /* PBXContainerItemProxy */; + }; + B90000000000000000000007 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = watchos; + target = B50000000000000000000004 /* IDPGlobalWidgets */; + targetProxy = B90000000000000000000005 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -485,8 +674,8 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MACOSX_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; SDKROOT = auto; SWIFT_VERSION = 5.0; }; @@ -512,8 +701,8 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MACOSX_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; SDKROOT = auto; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; @@ -533,11 +722,23 @@ ENABLE_TESTABILITY = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleURLTypes = ( + { + CFBundleTypeRole = Editor; + CFBundleURLName = idpglobal; + CFBundleURLSchemes = ( + idpglobal, + ); + }, + ); INFOPLIST_KEY_CFBundleDisplayName = "idp.global"; + INFOPLIST_KEY_LSUIElement = YES; 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_NSSupportsLiveActivities = YES; + INFOPLIST_KEY_NSSupportsLiveActivitiesFrequentUpdates = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; LD_RUNPATH_SEARCH_PATHS = ( @@ -554,6 +755,8 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; }; name = Debug; }; @@ -568,11 +771,23 @@ DEVELOPMENT_ASSET_PATHS = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleURLTypes = ( + { + CFBundleTypeRole = Editor; + CFBundleURLName = idpglobal; + CFBundleURLSchemes = ( + idpglobal, + ); + }, + ); INFOPLIST_KEY_CFBundleDisplayName = "idp.global"; + INFOPLIST_KEY_LSUIElement = YES; 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_NSSupportsLiveActivities = YES; + INFOPLIST_KEY_NSSupportsLiveActivitiesFrequentUpdates = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; LD_RUNPATH_SEARCH_PATHS = ( @@ -588,6 +803,8 @@ SWIFT_OBSERVATION_ENABLED = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; }; name = Release; }; @@ -595,11 +812,21 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_ENTITLEMENTS = IDPGlobalShared.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleURLTypes = ( + { + CFBundleTypeRole = Editor; + CFBundleURLName = idpglobal; + CFBundleURLSchemes = ( + idpglobal, + ); + }, + ); INFOPLIST_KEY_CFBundleDisplayName = "idp.global Watch"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = global.idp.app; LD_RUNPATH_SEARCH_PATHS = ( @@ -616,7 +843,7 @@ SWIFT_OBSERVATION_ENABLED = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.0; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Debug; }; @@ -624,11 +851,21 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_ENTITLEMENTS = IDPGlobalShared.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleURLTypes = ( + { + CFBundleTypeRole = Editor; + CFBundleURLName = idpglobal; + CFBundleURLSchemes = ( + idpglobal, + ); + }, + ); INFOPLIST_KEY_CFBundleDisplayName = "idp.global Watch"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = global.idp.app; LD_RUNPATH_SEARCH_PATHS = ( @@ -645,18 +882,23 @@ SWIFT_OBSERVATION_ENABLED = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.0; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Release; }; B80000000000000000000007 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal.debug.dylib"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@loader_path/../Frameworks", + "@loader_path/../../../IDPGlobal.app/Contents/MacOS", + ); + MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.tests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -664,7 +906,6 @@ SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = macosx; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal"; TEST_TARGET_NAME = IDPGlobal; }; name = Debug; @@ -672,11 +913,16 @@ B80000000000000000000008 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal.debug.dylib"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@loader_path/../Frameworks", + "@loader_path/../../../IDPGlobal.app/Contents/MacOS", + ); + MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.tests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -684,11 +930,68 @@ SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = macosx; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal"; TEST_TARGET_NAME = IDPGlobal; }; name = Release; }; + B80000000000000000000009 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = IDPGlobalShared.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = WatchApp/Widgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.watchkitapp.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + WATCHOS_DEPLOYMENT_TARGET = 11.0; + }; + name = Debug; + }; + B8000000000000000000000A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = IDPGlobalShared.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = WatchApp/Widgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.watchkitapp.widgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + WATCHOS_DEPLOYMENT_TARGET = 11.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -728,6 +1031,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B70000000000000000000005 /* Build configuration list for PBXNativeTarget "IDPGlobalWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B80000000000000000000009 /* Debug */, + B8000000000000000000000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = B60000000000000000000001 /* Project object */; diff --git a/swift/IDPGlobalShared.entitlements b/swift/IDPGlobalShared.entitlements new file mode 100644 index 0000000..3b2d55a --- /dev/null +++ b/swift/IDPGlobalShared.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.global.idp.app + + + diff --git a/swift/Sources/App/AppViewModel.swift b/swift/Sources/App/AppViewModel.swift index a0c40cc..a6611cc 100644 --- a/swift/Sources/App/AppViewModel.swift +++ b/swift/Sources/App/AppViewModel.swift @@ -1,5 +1,10 @@ import Combine import Foundation +import SwiftUI + +#if canImport(WidgetKit) +import WidgetKit +#endif @MainActor final class AppViewModel: ObservableObject { @@ -10,12 +15,13 @@ final class AppViewModel: ObservableObject { @Published var requests: [ApprovalRequest] = [] @Published var notifications: [AppNotification] = [] @Published var notificationPermission: NotificationPermissionState = .unknown - @Published var selectedSection: AppSection = .overview + @Published var selectedSection: AppSection = .inbox @Published var isBootstrapping = false @Published var isAuthenticating = false @Published var isIdentifying = false @Published var isRefreshing = false @Published var isNotificationCenterPresented = false + @Published var isShowingPairingSuccess = false @Published var activeRequestID: ApprovalRequest.ID? @Published var isScannerPresented = false @Published var errorMessage: String? @@ -32,14 +38,25 @@ final class AppViewModel: ObservableObject { } let rawValue = String(argument.dropFirst("--mock-section=".count)) - if rawValue == "notifications" { - return .activity + + switch rawValue { + case "requests", "inbox": + return .inbox + case "notifications", "activity": + return .notifications + case "devices", "account": + return .devices + case "identity", "overview": + return .identity + case "settings": + return .settings + default: + return AppSection(rawValue: rawValue) } - return AppSection(rawValue: rawValue) } init( - service: IDPServicing = MockIDPService(), + service: IDPServicing = MockIDPService.shared, notificationCoordinator: NotificationCoordinating = NotificationCoordinator(), appStateStore: AppStateStoring = UserDefaultsAppStateStore(), launchArguments: [String] = ProcessInfo.processInfo.arguments @@ -148,15 +165,28 @@ final class AppViewModel: ObservableObject { isAuthenticating = true defer { isAuthenticating = false } + let wasSignedOut = session == nil + do { let result = try await service.signIn(with: normalizedRequest) session = result.session apply(snapshot: result.snapshot) persistCurrentState() notificationPermission = await notificationCoordinator.authorizationStatus() - selectedSection = .overview + selectedSection = .inbox errorMessage = nil isScannerPresented = false + + if wasSignedOut { + isShowingPairingSuccess = true + Haptics.success() + + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(1200)) + guard let self, self.session != nil else { return } + self.isShowingPairingSuccess = false + } + } } catch let error as AppError { errorMessage = error.errorDescription } catch { @@ -250,7 +280,7 @@ final class AppViewModel: ObservableObject { let snapshot = try await service.simulateIncomingRequest() apply(snapshot: snapshot) persistCurrentState() - selectedSection = .requests + selectedSection = .inbox errorMessage = nil } catch { errorMessage = "Unable to create a mock identity check right now." @@ -296,9 +326,36 @@ final class AppViewModel: ObservableObject { profile = nil requests = [] notifications = [] - selectedSection = .overview + selectedSection = .inbox manualPairingPayload = suggestedPairingPayload + isShowingPairingSuccess = false errorMessage = nil + + Task { + await ApprovalActivityController.endAll() + #if canImport(WidgetKit) + WidgetCenter.shared.reloadAllTimelines() + #endif + } + } + + func openDeepLink(_ url: URL) { + let destination = (url.host ?? url.lastPathComponent).lowercased() + + switch destination { + case "inbox": + selectedSection = .inbox + case "notifications": + selectedSection = .notifications + case "devices": + selectedSection = .devices + case "identity": + selectedSection = .identity + case "settings": + selectedSection = .settings + default: + break + } } private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async { @@ -352,8 +409,20 @@ final class AppViewModel: ObservableObject { } private func apply(snapshot: DashboardSnapshot) { - profile = snapshot.profile - requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt } - notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt } + withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { + self.profile = snapshot.profile + self.requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt } + self.notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt } + } + + let profileValue = snapshot.profile + let requestsValue = snapshot.requests.sorted { $0.createdAt > $1.createdAt } + + Task { + await ApprovalActivityController.sync(requests: requestsValue, profile: profileValue) + #if canImport(WidgetKit) + WidgetCenter.shared.reloadAllTimelines() + #endif + } } } diff --git a/swift/Sources/App/ApprovalActivityController.swift b/swift/Sources/App/ApprovalActivityController.swift new file mode 100644 index 0000000..d89e0d9 --- /dev/null +++ b/swift/Sources/App/ApprovalActivityController.swift @@ -0,0 +1,63 @@ +import Foundation + +#if canImport(ActivityKit) && os(iOS) +import ActivityKit + +enum ApprovalActivityController { + static func sync(requests: [ApprovalRequest], profile: MemberProfile) async { + let pendingRequest = requests.first(where: { $0.status == .pending }) + + guard let pendingRequest else { + await endAll() + return + } + + let payload = pendingRequest.activityPayload(handle: profile.handle) + let contentState = ApprovalActivityAttributes.ContentState( + requestID: payload.requestID, + title: payload.title, + appName: payload.appName, + source: payload.source, + handle: payload.handle, + location: payload.location + ) + let content = ActivityContent(state: contentState, staleDate: pendingRequest.activityExpiryDate) + + if let currentActivity = Activity.activities.first(where: { $0.attributes.requestID == payload.requestID }) { + await currentActivity.update(content) + } else { + do { + _ = try Activity.request( + attributes: ApprovalActivityAttributes(requestID: payload.requestID, createdAt: payload.createdAt), + content: content + ) + } catch { + } + } + + for activity in Activity.activities where activity.attributes.requestID != payload.requestID { + await activity.end(nil, dismissalPolicy: .immediate) + } + } + + static func sync(requests: [ApprovalRequest], profile: MemberProfile?) async { + guard let profile else { + await endAll() + return + } + + await sync(requests: requests, profile: profile) + } + + static func endAll() async { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + } +} +#else +enum ApprovalActivityController { + static func sync(requests: [ApprovalRequest], profile: MemberProfile?) async {} + static func endAll() async {} +} +#endif diff --git a/swift/Sources/App/IDPGlobalApp.swift b/swift/Sources/App/IDPGlobalApp.swift index 306b11a..6b01534 100644 --- a/swift/Sources/App/IDPGlobalApp.swift +++ b/swift/Sources/App/IDPGlobalApp.swift @@ -5,9 +5,27 @@ struct IDPGlobalApp: App { @StateObject private var model = AppViewModel() var body: some Scene { - WindowGroup { - RootView(model: model) - .tint(AppTheme.accent) + #if os(macOS) + MenuBarExtra("idp.global", systemImage: "shield.lefthalf.filled") { + RootSceneContent(model: model) + .frame(minWidth: 400, minHeight: 560) + .tint(IdP.tint) + .task { + await model.bootstrap() + } + .alert("Something went wrong", isPresented: errorPresented) { + Button("OK") { + model.errorMessage = nil + } + } message: { + Text(model.errorMessage ?? "") + } + } + .menuBarExtraStyle(.window) + #else + WindowGroup { + RootSceneContent(model: model) + .tint(IdP.tint) .task { await model.bootstrap() } @@ -19,8 +37,6 @@ struct IDPGlobalApp: App { Text(model.errorMessage ?? "") } } - #if os(macOS) - .defaultSize(width: 1380, height: 920) #endif } @@ -36,19 +52,47 @@ struct IDPGlobalApp: App { } } -private struct RootView: View { +private struct RootSceneContent: View { @ObservedObject var model: AppViewModel var body: some View { Group { if model.session == nil { LoginRootView(model: model) + } else if model.isShowingPairingSuccess { + PairingSuccessView() } else { + #if os(macOS) + MenuBarPopover(model: model) + #else HomeRootView(model: model) + #endif } } .background { - AppBackground() + Color.idpGroupedBackground.ignoresSafeArea() + } + .onOpenURL { url in + model.openDeepLink(url) } } } + +private struct PairingSuccessView: View { + var body: some View { + VStack(spacing: 18) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 72, weight: .semibold)) + .foregroundStyle(.green) + + Text("Passport linked") + .font(.title2.weight(.semibold)) + + Text("Your device is ready to approve sign-ins.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(32) + } +} diff --git a/swift/Sources/Core/Design/ButtonStyles.swift b/swift/Sources/Core/Design/ButtonStyles.swift new file mode 100644 index 0000000..ccd2eb3 --- /dev/null +++ b/swift/Sources/Core/Design/ButtonStyles.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct PrimaryActionStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + PrimaryActionBody(configuration: configuration) + } + + private struct PrimaryActionBody: View { + let configuration: Configuration + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + configuration.label + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.horizontal, 18) + .padding(.vertical, 14) + .foregroundStyle(.white) + .background( + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .fill(isEnabled ? IdP.tint : Color.secondary.opacity(0.25)) + ) + .opacity(configuration.isPressed ? 0.92 : 1) + .scaleEffect(configuration.isPressed ? 0.985 : 1) + .animation(.easeOut(duration: 0.16), value: configuration.isPressed) + } + } +} + +struct SecondaryActionStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.horizontal, 18) + .padding(.vertical, 14) + .foregroundStyle(.primary) + .background( + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .fill(Color.idpSecondaryGroupedBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .stroke(Color.idpSeparator.opacity(0.55), lineWidth: 1) + ) + .opacity(configuration.isPressed ? 0.9 : 1) + .scaleEffect(configuration.isPressed ? 0.985 : 1) + .animation(.easeOut(duration: 0.16), value: configuration.isPressed) + } +} + +struct DestructiveStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.horizontal, 18) + .padding(.vertical, 14) + .foregroundStyle(.red) + .background( + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .fill(Color.red.opacity(0.10)) + ) + .overlay( + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .stroke(Color.red.opacity(0.18), lineWidth: 1) + ) + .opacity(configuration.isPressed ? 0.9 : 1) + .scaleEffect(configuration.isPressed ? 0.985 : 1) + .animation(.easeOut(duration: 0.16), value: configuration.isPressed) + } +} diff --git a/swift/Sources/Core/Design/Cards.swift b/swift/Sources/Core/Design/Cards.swift new file mode 100644 index 0000000..9532eef --- /dev/null +++ b/swift/Sources/Core/Design/Cards.swift @@ -0,0 +1,100 @@ +import SwiftUI + +struct ApprovalCardModifier: ViewModifier { + var highlighted = false + + func body(content: Content) -> some View { + content + .padding(18) + .background( + RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) + .fill(Color.idpSecondaryGroupedBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) + .stroke(highlighted ? IdP.tint.opacity(0.7) : Color.idpSeparator.opacity(0.55), lineWidth: highlighted ? 1.5 : 1) + ) + .overlay { + if highlighted { + RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) + .stroke(IdP.tint.opacity(0.12), lineWidth: 6) + .padding(-2) + } + } + } +} + +extension View { + func approvalCard(highlighted: Bool = false) -> some View { + modifier(ApprovalCardModifier(highlighted: highlighted)) + } + + func deviceRowStyle() -> some View { + modifier(DeviceRowStyle()) + } +} + +struct RequestHeroCard: View { + let request: ApprovalRequest + let handle: String + + var body: some View { + HStack(alignment: .top, spacing: 16) { + MonogramAvatar(title: request.source, size: 64) + + VStack(alignment: .leading, spacing: 8) { + Text("\(request.source) wants to sign in as you") + .font(.title3.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + + Text("Continue as \(Text(handle).foregroundStyle(IdP.tint))") + .font(.subheadline) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Label(request.kind.title, systemImage: request.kind.systemImage) + Text(request.createdAt, style: .relative) + } + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + } + .approvalCard(highlighted: true) + } +} + +struct MonogramAvatar: View { + let title: String + var size: CGFloat = 40 + var tint: Color = IdP.tint + + private var monogram: String { + String(title.trimmingCharacters(in: .whitespacesAndNewlines).first ?? "I").uppercased() + } + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: size * 0.34, style: .continuous) + .fill(tint.opacity(0.14)) + + Image("AppMonogram") + .resizable() + .scaledToFit() + .frame(width: size * 0.44, height: size * 0.44) + .opacity(0.18) + + Text(monogram) + .font(.system(size: size * 0.42, weight: .semibold, design: .rounded)) + .foregroundStyle(tint) + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +struct DeviceRowStyle: ViewModifier { + func body(content: Content) -> some View { + content + .padding(.vertical, 4) + } +} diff --git a/swift/Sources/Core/Design/GlassChrome.swift b/swift/Sources/Core/Design/GlassChrome.swift new file mode 100644 index 0000000..8a250a4 --- /dev/null +++ b/swift/Sources/Core/Design/GlassChrome.swift @@ -0,0 +1,39 @@ +import SwiftUI + +public extension View { + @ViewBuilder + func idpGlassChrome() -> some View { + if #available(iOS 26, macOS 26, *) { + self.glassEffect(.regular) + } else { + self.background(.regularMaterial) + } + } +} + +struct IdPGlassCapsule: View { + let padding: EdgeInsets + let content: Content + + init( + padding: EdgeInsets = EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16), + @ViewBuilder content: () -> Content + ) { + self.padding = padding + self.content = content() + } + + var body: some View { + content + .padding(padding) + .background( + Capsule(style: .continuous) + .fill(.clear) + .idpGlassChrome() + ) + .overlay( + Capsule(style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 1) + ) + } +} diff --git a/swift/Sources/Core/Design/Haptics.swift b/swift/Sources/Core/Design/Haptics.swift new file mode 100644 index 0000000..ddb09b8 --- /dev/null +++ b/swift/Sources/Core/Design/Haptics.swift @@ -0,0 +1,33 @@ +import SwiftUI + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +enum Haptics { + static func success() { + #if os(iOS) + UINotificationFeedbackGenerator().notificationOccurred(.success) + #elseif os(macOS) + NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .now) + #endif + } + + static func warning() { + #if os(iOS) + UINotificationFeedbackGenerator().notificationOccurred(.warning) + #elseif os(macOS) + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) + #endif + } + + static func selection() { + #if os(iOS) + UISelectionFeedbackGenerator().selectionChanged() + #elseif os(macOS) + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) + #endif + } +} diff --git a/swift/Sources/Core/Design/IdPTokens.swift b/swift/Sources/Core/Design/IdPTokens.swift new file mode 100644 index 0000000..94eb332 --- /dev/null +++ b/swift/Sources/Core/Design/IdPTokens.swift @@ -0,0 +1,109 @@ +import SwiftUI + +#if os(macOS) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif + +public enum IdP { + public static let tint = Color("IdPTint") + public static let cardRadius: CGFloat = 22 + public static let controlRadius: CGFloat = 14 + public static let badgeRadius: CGFloat = 8 + + static func horizontalPadding(compact: Bool) -> CGFloat { + compact ? 16 : 24 + } + + static func verticalPadding(compact: Bool) -> CGFloat { + compact ? 16 : 24 + } +} + +extension Color { + static var idpGroupedBackground: Color { + #if os(macOS) + Color(nsColor: .windowBackgroundColor) + #elseif os(watchOS) + .black + #else + Color(uiColor: .systemGroupedBackground) + #endif + } + + static var idpSecondaryGroupedBackground: Color { + #if os(macOS) + Color(nsColor: .controlBackgroundColor) + #elseif os(watchOS) + Color.white.opacity(0.08) + #else + Color(uiColor: .secondarySystemGroupedBackground) + #endif + } + + static var idpTertiaryFill: Color { + #if os(macOS) + Color(nsColor: .quaternaryLabelColor).opacity(0.08) + #elseif os(watchOS) + Color.white.opacity(0.12) + #else + Color(uiColor: .tertiarySystemFill) + #endif + } + + static var idpSeparator: Color { + #if os(macOS) + Color(nsColor: .separatorColor) + #elseif os(watchOS) + Color.white.opacity(0.14) + #else + Color(uiColor: .separator) + #endif + } +} + +extension View { + func idpScreenPadding(compact: Bool) -> some View { + padding(.horizontal, IdP.horizontalPadding(compact: compact)) + .padding(.vertical, IdP.verticalPadding(compact: compact)) + } + + @ViewBuilder + func idpInlineNavigationTitle() -> some View { + #if os(macOS) + self + #else + navigationBarTitleDisplayMode(.inline) + #endif + } + + @ViewBuilder + func idpTabBarChrome() -> some View { + #if os(macOS) + self + #else + toolbarBackground(.visible, for: .tabBar) + .toolbarBackground(.regularMaterial, for: .tabBar) + #endif + } + + @ViewBuilder + func idpSearchable(text: Binding, isPresented: Binding) -> some View { + #if os(macOS) + searchable(text: text, isPresented: isPresented) + #else + searchable(text: text, isPresented: isPresented, placement: .navigationBarDrawer(displayMode: .always)) + #endif + } +} + +extension ToolbarItemPlacement { + static var idpTrailingToolbar: ToolbarItemPlacement { + #if os(macOS) + .primaryAction + #else + .topBarTrailing + #endif + } +} diff --git a/swift/Sources/Core/Design/StatusDot.swift b/swift/Sources/Core/Design/StatusDot.swift new file mode 100644 index 0000000..0b0ec5d --- /dev/null +++ b/swift/Sources/Core/Design/StatusDot.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct StatusDot: View { + let color: Color + + var body: some View { + Circle() + .fill(color) + .frame(width: 10, height: 10) + .overlay( + Circle() + .stroke(Color.white.opacity(0.65), lineWidth: 1) + ) + .accessibilityHidden(true) + } +} diff --git a/swift/Sources/Core/Models/AppModels.swift b/swift/Sources/Core/Models/AppModels.swift index c1304b9..3f2d69d 100644 --- a/swift/Sources/Core/Models/AppModels.swift +++ b/swift/Sources/Core/Models/AppModels.swift @@ -2,28 +2,31 @@ import CryptoKit import Foundation enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable { - case overview - case requests - case activity - case account + case inbox + case notifications + case devices + case identity + case settings var id: String { rawValue } var title: String { switch self { - case .overview: "Passport" - case .requests: "Requests" - case .activity: "Activity" - case .account: "Account" + case .inbox: "Inbox" + case .notifications: "Notifications" + case .devices: "Devices" + case .identity: "Identity" + case .settings: "Settings" } } var systemImage: String { switch self { - case .overview: "person.crop.square.fill" - case .requests: "checklist.checked" - case .activity: "clock.arrow.trianglehead.counterclockwise.rotate.90" - case .account: "person.crop.circle.fill" + case .inbox: "tray.full.fill" + case .notifications: "bell.badge.fill" + case .devices: "desktopcomputer" + case .identity: "person.crop.rectangle.stack.fill" + case .settings: "gearshape.fill" } } } @@ -301,8 +304,8 @@ enum ApprovalStatus: String, Hashable, Codable { var title: String { switch self { case .pending: "Pending" - case .approved: "Verified" - case .rejected: "Declined" + case .approved: "Approved" + case .rejected: "Denied" } } diff --git a/swift/Sources/Core/Models/ApprovalActivityModels.swift b/swift/Sources/Core/Models/ApprovalActivityModels.swift new file mode 100644 index 0000000..48f2e52 --- /dev/null +++ b/swift/Sources/Core/Models/ApprovalActivityModels.swift @@ -0,0 +1,59 @@ +import Foundation + +#if canImport(ActivityKit) && os(iOS) +import ActivityKit +#endif + +struct ApprovalActivityPayload: Codable, Hashable { + let requestID: String + let title: String + let appName: String + let source: String + let handle: String + let location: String + let createdAt: Date +} + +extension ApprovalRequest { + var activityAppName: String { + source + .replacingOccurrences(of: "auth.", with: "") + .replacingOccurrences(of: ".idp.global", with: ".idp.global") + } + + var activityLocation: String { + "Berlin, DE" + } + + var activityExpiryDate: Date { + createdAt.addingTimeInterval(risk == .elevated ? 180 : 300) + } + + func activityPayload(handle: String) -> ApprovalActivityPayload { + ApprovalActivityPayload( + requestID: id.uuidString, + title: title, + appName: activityAppName, + source: source, + handle: handle, + location: activityLocation, + createdAt: createdAt + ) + } +} + +#if canImport(ActivityKit) && os(iOS) +struct ApprovalActivityAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + let requestID: String + let title: String + let appName: String + let source: String + let handle: String + let location: String + } + + let requestID: String + let createdAt: Date +} +#endif diff --git a/swift/Sources/Core/Services/AppStateStore.swift b/swift/Sources/Core/Services/AppStateStore.swift index f6a592e..fd9716b 100644 --- a/swift/Sources/Core/Services/AppStateStore.swift +++ b/swift/Sources/Core/Services/AppStateStore.swift @@ -1,5 +1,13 @@ import Foundation +enum SharedDefaults { + static let appGroupIdentifier = "group.global.idp.app" + + static var userDefaults: UserDefaults { + UserDefaults(suiteName: appGroupIdentifier) ?? .standard + } +} + struct PersistedAppState: Codable, Equatable { let session: AuthSession let profile: MemberProfile @@ -19,7 +27,7 @@ final class UserDefaultsAppStateStore: AppStateStoring { private let encoder = JSONEncoder() private let decoder = JSONDecoder() - init(defaults: UserDefaults = .standard, storageKey: String = "persisted-app-state") { + init(defaults: UserDefaults = SharedDefaults.userDefaults, storageKey: String = "persisted-app-state") { self.defaults = defaults self.storageKey = storageKey } diff --git a/swift/Sources/Core/Services/MockIDPService.swift b/swift/Sources/Core/Services/MockIDPService.swift index 0beac26..22a7ba0 100644 --- a/swift/Sources/Core/Services/MockIDPService.swift +++ b/swift/Sources/Core/Services/MockIDPService.swift @@ -12,6 +12,8 @@ protocol IDPServicing { } actor MockIDPService: IDPServicing { + static let shared = MockIDPService() + private let profile = MemberProfile( name: "Phil Kunz", handle: "phil@idp.global", @@ -20,15 +22,24 @@ actor MockIDPService: IDPServicing { recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified." ) + private let appStateStore: AppStateStoring private var requests: [ApprovalRequest] = [] private var notifications: [AppNotification] = [] - init() { - requests = Self.seedRequests() - notifications = Self.seedNotifications() + init(appStateStore: AppStateStoring = UserDefaultsAppStateStore()) { + self.appStateStore = appStateStore + + if let state = appStateStore.load() { + requests = state.requests.sorted { $0.createdAt > $1.createdAt } + notifications = state.notifications.sorted { $0.sentAt > $1.sentAt } + } else { + requests = Self.seedRequests() + notifications = Self.seedNotifications() + } } func bootstrap() async throws -> BootstrapContext { + restoreSharedState() try await Task.sleep(for: .milliseconds(120)) return BootstrapContext( suggestedPairingPayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP" @@ -36,6 +47,7 @@ actor MockIDPService: IDPServicing { } func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult { + restoreSharedState() try await Task.sleep(for: .milliseconds(260)) try validateSignedGPSPosition(in: request) @@ -51,6 +63,8 @@ actor MockIDPService: IDPServicing { at: 0 ) + persistSharedStateIfAvailable() + return SignInResult( session: session, snapshot: snapshot() @@ -58,6 +72,7 @@ actor MockIDPService: IDPServicing { } func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot { + restoreSharedState() try await Task.sleep(for: .milliseconds(180)) try validateSignedGPSPosition(in: request) @@ -73,15 +88,19 @@ actor MockIDPService: IDPServicing { at: 0 ) + persistSharedStateIfAvailable() + return snapshot() } func refreshDashboard() async throws -> DashboardSnapshot { + restoreSharedState() try await Task.sleep(for: .milliseconds(180)) return snapshot() } func approveRequest(id: UUID) async throws -> DashboardSnapshot { + restoreSharedState() try await Task.sleep(for: .milliseconds(150)) guard let index = requests.firstIndex(where: { $0.id == id }) else { @@ -100,10 +119,13 @@ actor MockIDPService: IDPServicing { at: 0 ) + persistSharedStateIfAvailable() + return snapshot() } func rejectRequest(id: UUID) async throws -> DashboardSnapshot { + restoreSharedState() try await Task.sleep(for: .milliseconds(150)) guard let index = requests.firstIndex(where: { $0.id == id }) else { @@ -122,10 +144,13 @@ actor MockIDPService: IDPServicing { at: 0 ) + persistSharedStateIfAvailable() + return snapshot() } func simulateIncomingRequest() async throws -> DashboardSnapshot { + restoreSharedState() try await Task.sleep(for: .milliseconds(120)) let syntheticRequest = ApprovalRequest( @@ -151,10 +176,13 @@ actor MockIDPService: IDPServicing { at: 0 ) + persistSharedStateIfAvailable() + return snapshot() } func markNotificationRead(id: UUID) async throws -> DashboardSnapshot { + restoreSharedState() try await Task.sleep(for: .milliseconds(80)) guard let index = notifications.firstIndex(where: { $0.id == id }) else { @@ -162,6 +190,7 @@ actor MockIDPService: IDPServicing { } notifications[index].isUnread = false + persistSharedStateIfAvailable() return snapshot() } @@ -227,6 +256,30 @@ actor MockIDPService: IDPServicing { return "An identity proof was completed for \(context.deviceName) on \(context.originHost)." } + private func restoreSharedState() { + guard let state = appStateStore.load() else { + requests = Self.seedRequests() + notifications = Self.seedNotifications() + return + } + + requests = state.requests.sorted { $0.createdAt > $1.createdAt } + notifications = state.notifications.sorted { $0.sentAt > $1.sentAt } + } + + private func persistSharedStateIfAvailable() { + guard let state = appStateStore.load() else { return } + + appStateStore.save( + PersistedAppState( + session: state.session, + profile: state.profile, + requests: requests, + notifications: notifications + ) + ) + } + private static func seedRequests() -> [ApprovalRequest] { [ ApprovalRequest( diff --git a/swift/Sources/Features/Auth/LoginRootView.swift b/swift/Sources/Features/Auth/LoginRootView.swift index 24d7365..4393062 100644 --- a/swift/Sources/Features/Auth/LoginRootView.swift +++ b/swift/Sources/Features/Auth/LoginRootView.swift @@ -1,193 +1,126 @@ import SwiftUI -private let loginAccent = AppTheme.accent - struct LoginRootView: View { @ObservedObject var model: AppViewModel - @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + #if !os(macOS) + @State private var isNFCSheetPresented = false + #endif var body: some View { - AppScrollScreen(compactLayout: compactLayout) { - LoginHeroPanel(model: model, compactLayout: compactLayout) - PairingConsoleCard(model: model, compactLayout: compactLayout) - } - .sheet(isPresented: $model.isScannerPresented) { - QRScannerSheet( - seededPayload: model.suggestedPairingPayload, - title: "Scan linking QR", - description: "Use the camera to scan the QR code from the web flow that activates this device as your passport.", - navigationTitle: "Scan Linking QR", - onCodeScanned: { payload in + #if os(macOS) + MacPairingView(model: model) + #else + NavigationStack { + ZStack(alignment: .top) { + LiveQRScannerView { payload in model.manualPairingPayload = payload Task { await model.signIn(with: payload, transport: .qr) } } - ) - } - } + .ignoresSafeArea() - private var compactLayout: Bool { - #if os(iOS) - horizontalSizeClass == .compact - #else - false + VStack(spacing: 0) { + IdPGlassCapsule { + VStack(alignment: .leading, spacing: 6) { + Text("Scan a pairing code") + .font(.headline) + + Text("Turn this iPhone into your idp.global passport with QR or NFC.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 16) + .padding(.top, 12) + + Spacer() + + Button { + isNFCSheetPresented = true + } label: { + IdPGlassCapsule { + HStack(spacing: 10) { + Image(systemName: "wave.3.right") + .foregroundStyle(IdP.tint) + Text("Hold near NFC tag") + .font(.headline) + .foregroundStyle(.primary) + } + } + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Use demo payload") { + Task { + await model.signInWithSuggestedPayload() + } + } + .font(.footnote) + .disabled(model.isAuthenticating || model.suggestedPairingPayload.isEmpty) + } + } + } + .sheet(isPresented: $isNFCSheetPresented) { + NFCSheet(actionTitle: "Approve") { request in + await model.signIn(with: request) + } + } #endif } } -private struct LoginHeroPanel: View { +#if os(macOS) +private struct MacPairingView: View { @ObservedObject var model: AppViewModel - let compactLayout: Bool var body: some View { - AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) { - AppBadge(title: "Secure passport setup", tone: loginAccent) + VStack(alignment: .leading, spacing: 18) { + HStack(spacing: 12) { + Image(systemName: "shield.lefthalf.filled") + .font(.title2) + .foregroundStyle(IdP.tint) - Text("Turn this device into a passport for your idp.global identity") - .font(.system(size: compactLayout ? 28 : 36, weight: .bold, design: .rounded)) - .lineLimit(3) - - Text("Scan a linking QR code or paste a payload to activate this device as your passport for identity proofs and security alerts.") - .font(.subheadline) - .foregroundStyle(.secondary) - - Divider() - - VStack(alignment: .leading, spacing: 14) { - LoginFeatureRow(icon: "qrcode.viewfinder", title: "Scan a QR code from the web flow") - LoginFeatureRow(icon: "doc.text.viewfinder", title: "Paste a payload when you already have one") - LoginFeatureRow(icon: "iphone.gen3", title: "Handle identity checks and alerts here") - } - - if model.isBootstrapping { - ProgressView("Preparing preview passport...") - .tint(loginAccent) - } - } - } -} - -private struct LoginFeatureRow: View { - let icon: String - let title: String - - var body: some View { - HStack(alignment: .center, spacing: 12) { - Image(systemName: icon) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(loginAccent) - .frame(width: 28, height: 28) - - Text(title) - .font(.headline) - - Spacer(minLength: 0) - } - } -} - -private struct PairingConsoleCard: View { - @ObservedObject var model: AppViewModel - let compactLayout: Bool - - var body: some View { - AppSectionCard(title: "Set up passport", compactLayout: compactLayout) { - VStack(alignment: .leading, spacing: 8) { - Text("Link payload") - .font(.subheadline.weight(.semibold)) - - AppTextEditorField( - text: $model.manualPairingPayload, - minHeight: compactLayout ? 132 : 150 - ) - } - - if model.isAuthenticating { - HStack(spacing: 10) { - ProgressView() - Text("Activating this passport...") + VStack(alignment: .leading, spacing: 2) { + Text("Set up idp.global") + .font(.headline) + Text("Use the demo payload or paste a pairing link.") + .font(.subheadline) .foregroundStyle(.secondary) } } - Text("NFC, QR, and OTP proof methods become available after this passport is active.") - .font(.footnote) - .foregroundStyle(.secondary) + TextEditor(text: $model.manualPairingPayload) + .font(.footnote.monospaced()) + .scrollContentBackground(.hidden) + .frame(minHeight: 140) + .padding(10) + .background(Color.idpSecondaryGroupedBackground, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) - if compactLayout { - VStack(spacing: 12) { - primaryButtons - secondaryButtons - } - } else { - VStack(spacing: 12) { - HStack(spacing: 12) { - primaryButtons + VStack(spacing: 10) { + Button("Use demo payload") { + Task { + await model.signInWithSuggestedPayload() } - - secondaryButtons } - } - } - } + .buttonStyle(PrimaryActionStyle()) - @ViewBuilder - private var primaryButtons: some View { - Button { - model.isScannerPresented = true - } label: { - Label("Scan QR", systemImage: "qrcode.viewfinder") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - } - - @ViewBuilder - private var secondaryButtons: some View { - if compactLayout { - VStack(spacing: 12) { - usePayloadButton - previewPayloadButton - } - } else { - HStack(spacing: 12) { - usePayloadButton - previewPayloadButton + Button("Link with payload") { + Task { + await model.signInWithManualPayload() + } + } + .buttonStyle(SecondaryActionStyle()) } } - } - - private var usePayloadButton: some View { - Button { - Task { - await model.signInWithManualPayload() - } - } label: { - if model.isAuthenticating { - ProgressView() - .frame(maxWidth: .infinity) - } else { - Label("Link with payload", systemImage: "arrow.right.circle") - .frame(maxWidth: .infinity) - } - } - .buttonStyle(.bordered) - .controlSize(.large) - .disabled(model.isAuthenticating) - } - - private var previewPayloadButton: some View { - Button { - Task { - await model.signInWithSuggestedPayload() - } - } label: { - Label("Use preview passport", systemImage: "wand.and.stars") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .controlSize(.large) + .padding(20) } } +#endif diff --git a/swift/Sources/Features/Auth/NFCPairingView.swift b/swift/Sources/Features/Auth/NFCPairingView.swift index a38bb68..335fb6c 100644 --- a/swift/Sources/Features/Auth/NFCPairingView.swift +++ b/swift/Sources/Features/Auth/NFCPairingView.swift @@ -40,7 +40,7 @@ final class NFCIdentifyReader: NSObject, ObservableObject, @preconcurrency NFCND 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." + session.alertMessage = "Hold your iPhone near the idp.global tag. A signed location proof will be attached before approval is sent." self.session = session session.begin() } @@ -161,11 +161,11 @@ final class NFCIdentifyReader: NSObject, ObservableObject, @preconcurrency NFCND 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 idleHelperText = "Hold this iPhone near a reader or tag to attach a signed location proof and confirm sign-in." + private static let scanningHelperText = "Hold the top of your iPhone near the NFC tag until the payload is read." + private static let signingLocationHelperText = "Tag detected. Capturing and signing the current GPS position before approval is sent." + private static let unavailableHelperText = "NFC approval is unavailable on this device." + private static let unavailableErrorMessage = "NFC approval 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." } diff --git a/swift/Sources/Features/Auth/QRScannerView.swift b/swift/Sources/Features/Auth/QRScannerView.swift index ba3c6a0..5135212 100644 --- a/swift/Sources/Features/Auth/QRScannerView.swift +++ b/swift/Sources/Features/Auth/QRScannerView.swift @@ -1,6 +1,7 @@ import AVFoundation import Combine import SwiftUI + #if os(iOS) import UIKit #elseif os(macOS) @@ -15,7 +16,6 @@ struct QRScannerSheet: View { let onCodeScanned: (String) -> Void @Environment(\.dismiss) private var dismiss - @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var manualFallback = "" init( @@ -34,33 +34,59 @@ struct QRScannerSheet: View { var body: some View { NavigationStack { - AppScrollScreen(compactLayout: compactLayout) { - AppSectionCard(title: title, compactLayout: compactLayout) { - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) - - LiveQRScannerView(onCodeScanned: onCodeScanned) - .frame(minHeight: 340) + ZStack(alignment: .top) { + LiveQRScannerView { payload in + onCodeScanned(payload) + dismiss() } + .ignoresSafeArea() - AppSectionCard(title: "Manual fallback", compactLayout: compactLayout) { - AppTextEditorField(text: $manualFallback, minHeight: 120) - - if compactLayout { - VStack(spacing: 12) { - useFallbackButton - useSeededButton + VStack(spacing: 12) { + IdPGlassCapsule { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.headline) + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) } - } else { - HStack(spacing: 12) { - useFallbackButton - useSeededButton + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + + VStack(alignment: .leading, spacing: 12) { + Text("Manual fallback") + .font(.headline) + + TextEditor(text: $manualFallback) + .font(.footnote.monospaced()) + .scrollContentBackground(.hidden) + .frame(minHeight: 110) + .padding(10) + .background(Color.idpTertiaryFill, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) + + VStack(spacing: 10) { + Button("Use payload") { + let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines) + onCodeScanned(chosen.isEmpty ? seededPayload : chosen) + dismiss() + } + .buttonStyle(PrimaryActionStyle()) + + Button("Use demo payload") { + manualFallback = seededPayload + } + .buttonStyle(SecondaryActionStyle()) } } + .padding(18) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) } + .padding(16) } .navigationTitle(navigationTitleText) + .applyInlineNavigationTitleDisplayMode() .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { @@ -73,85 +99,74 @@ struct QRScannerSheet: View { } } } +} - private var compactLayout: Bool { - #if os(iOS) - horizontalSizeClass == .compact +private extension View { + @ViewBuilder + func applyInlineNavigationTitleDisplayMode() -> some View { + #if os(macOS) + self #else - false + navigationBarTitleDisplayMode(.inline) #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 { +struct LiveQRScannerView: View { let onCodeScanned: (String) -> Void @StateObject private var scanner = QRScannerViewModel() + @State private var didDetectCode = false var body: some View { ZStack(alignment: .bottomLeading) { Group { if scanner.isPreviewAvailable { ScannerPreview(session: scanner.captureSession) - .clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous)) } else { - RoundedRectangle(cornerRadius: 30, style: .continuous) - .fill(Color.black.opacity(0.86)) + Color.black - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { Image(systemName: "video.slash.fill") - .font(.system(size: 28, weight: .semibold)) + .font(.system(size: 24, weight: .semibold)) .foregroundStyle(.white) - Text("Live camera preview unavailable") + Text("Camera preview unavailable") .font(.title3.weight(.semibold)) .foregroundStyle(.white) Text(scanner.statusMessage) .foregroundStyle(.white.opacity(0.78)) } .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } - RoundedRectangle(cornerRadius: 30, style: .continuous) - .strokeBorder(.white.opacity(0.22), lineWidth: 1.5) + Color.black.opacity(0.18) + .ignoresSafeArea() - VStack(alignment: .leading, spacing: 8) { - Text("Camera Scanner") + ScanFrameOverlay(detected: didDetectCode) + .padding(40) + + VStack(alignment: .leading, spacing: 6) { + Text("Point the camera at the pairing QR") .font(.headline.weight(.semibold)) .foregroundStyle(.white) Text(scanner.statusMessage) + .font(.subheadline) .foregroundStyle(.white.opacity(0.84)) } .padding(22) - - ScanFrameOverlay() - .padding(40) } .task { scanner.onCodeScanned = { payload in - onCodeScanned(payload) + withAnimation(.spring(response: 0.3, dampingFraction: 0.82)) { + didDetectCode = true + } + + Task { + try? await Task.sleep(for: .milliseconds(180)) + onCodeScanned(payload) + } } await scanner.start() } @@ -162,19 +177,46 @@ private struct LiveQRScannerView: View { } private struct ScanFrameOverlay: View { + let detected: Bool + var body: some View { GeometryReader { geometry in let size = min(geometry.size.width, geometry.size.height) * 0.5 + let inset = detected ? 18.0 : 0 - RoundedRectangle(cornerRadius: 28, style: .continuous) - .strokeBorder(.white.opacity(0.82), style: StrokeStyle(lineWidth: 3, dash: [10, 8])) - .frame(width: size, height: size) - .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + ZStack { + CornerTick(rotation: .degrees(0)) + .frame(width: size, height: size) + CornerTick(rotation: .degrees(90)) + .frame(width: size, height: size) + CornerTick(rotation: .degrees(180)) + .frame(width: size, height: size) + CornerTick(rotation: .degrees(270)) + .frame(width: size, height: size) + } + .frame(width: size - inset, height: size - inset) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + .animation(.spring(response: 0.3, dampingFraction: 0.82), value: detected) } .allowsHitTesting(false) } } +private struct CornerTick: View { + let rotation: Angle + + var body: some View { + Path { path in + let length: CGFloat = 34 + path.move(to: CGPoint(x: 0, y: length)) + path.addLine(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: length, y: 0)) + } + .stroke(.white, style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round)) + .rotationEffect(rotation) + } +} + private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { @Published var isPreviewAvailable = false @Published var statusMessage = "Point the camera at the QR code from the idp.global web portal." @@ -191,9 +233,8 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet #if os(iOS) && targetEnvironment(simulator) await MainActor.run { isPreviewAvailable = false - statusMessage = "The iOS simulator has no live camera feed. Use the seeded payload below." + statusMessage = "The iOS simulator has no live camera feed. Use the demo payload below." } - #else #endif #if !(os(iOS) && targetEnvironment(simulator)) @@ -207,7 +248,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet await MainActor.run { self.statusMessage = granted ? "Point the camera at the QR code from the idp.global web portal." - : "Camera access was denied. Use the fallback payload below." + : "Camera access was denied. Use the manual fallback instead." } guard granted else { return } await configureIfNeeded() @@ -215,7 +256,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet case .denied, .restricted: await MainActor.run { isPreviewAvailable = false - statusMessage = "Camera access is unavailable. Use the fallback payload below." + statusMessage = "Camera access is unavailable. Use the manual fallback instead." } @unknown default: await MainActor.run { @@ -285,7 +326,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet guard let device = AVCaptureDevice.default(for: .video) else { DispatchQueue.main.async { self.isPreviewAvailable = false - self.statusMessage = "No compatible camera was found. Use the fallback payload below." + self.statusMessage = "No compatible camera was found. Use the manual fallback instead." } return } @@ -293,7 +334,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet guard let input = try? AVCaptureDeviceInput(device: device) else { DispatchQueue.main.async { self.isPreviewAvailable = false - self.statusMessage = "No compatible camera was found. Use the fallback payload below." + self.statusMessage = "No compatible camera was found. Use the manual fallback instead." } return } @@ -301,7 +342,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet guard self.captureSession.canAddInput(input) else { DispatchQueue.main.async { self.isPreviewAvailable = false - self.statusMessage = "No compatible camera was found. Use the fallback payload below." + self.statusMessage = "No compatible camera was found. Use the manual fallback instead." } return } @@ -313,7 +354,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet self.captureSession.removeInput(input) DispatchQueue.main.async { self.isPreviewAvailable = false - self.statusMessage = "Unable to configure QR metadata scanning on this device." + self.statusMessage = "Unable to configure QR scanning on this device." } return } @@ -327,7 +368,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet self.captureSession.removeInput(input) DispatchQueue.main.async { self.isPreviewAvailable = false - self.statusMessage = "This camera does not support QR metadata scanning. Use the fallback payload below." + self.statusMessage = "This camera does not support QR scanning. Use the manual fallback instead." } return } diff --git a/swift/Sources/Features/Home/HomeCards.swift b/swift/Sources/Features/Home/HomeCards.swift index 05c815e..7b890f0 100644 --- a/swift/Sources/Features/Home/HomeCards.swift +++ b/swift/Sources/Features/Home/HomeCards.swift @@ -1,330 +1,346 @@ import SwiftUI -struct RequestList: View { - let requests: [ApprovalRequest] - let compactLayout: Bool - let activeRequestID: ApprovalRequest.ID? - let onApprove: ((ApprovalRequest) -> Void)? - let onReject: ((ApprovalRequest) -> Void)? - let onOpenRequest: (ApprovalRequest) -> Void +extension ApprovalRequest { + var appDisplayName: String { + source + .replacingOccurrences(of: "auth.", with: "") + .replacingOccurrences(of: ".idp.global", with: ".idp.global") + } - 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) } - ) - } + var inboxTitle: String { + "Sign in to \(appDisplayName)" + } + + var locationSummary: String { + "Berlin, DE" + } + + var deviceSummary: String { + switch kind { + case .signIn: + "Safari on Berlin iPhone" + case .accessGrant: + "Chrome on iPad Pro" + case .elevatedAction: + "Berlin MacBook Pro" + } + } + + var networkSummary: String { + switch kind { + case .signIn: + "Home Wi-Fi" + case .accessGrant: + "Shared office Wi-Fi" + case .elevatedAction: + "Ethernet" + } + } + + var ipSummary: String { + risk == .elevated ? "84.187.12.44" : "84.187.12.36" + } + + var trustColor: Color { + switch (status, risk) { + case (.rejected, _): + .red + case (.approved, _), (_, .routine): + .green + case (.pending, .elevated): + .yellow + } + } + + var trustExplanation: String { + switch (status, risk) { + case (.approved, _): + "This proof came from a signed device session that matches your usual sign-in pattern." + case (.rejected, _): + "This request was denied, so no data will be shared unless a new sign-in is started." + case (.pending, .routine): + "The origin and device pattern look familiar for this account." + case (.pending, .elevated): + "The request is valid, but it is asking for a stronger proof than usual." + } + } + + var expiresAt: Date { + createdAt.addingTimeInterval(risk == .elevated ? 180 : 300) + } +} + +private enum NotificationPresentationStatus { + case approved + case denied + case expired + + var title: String { + switch self { + case .approved: "Approved" + case .denied: "Denied" + case .expired: "Expired" + } + } + + var color: Color { + switch self { + case .approved: .green + case .denied: .red + case .expired: .secondary } } } -private struct RequestCard: View { - let request: ApprovalRequest - let compactLayout: Bool - let isBusy: Bool - let onApprove: (() -> Void)? - let onReject: (() -> Void)? - let onOpenRequest: () -> Void +extension AppNotification { + fileprivate var presentationStatus: NotificationPresentationStatus { + let haystack = "\(title) \(message)".lowercased() + if haystack.contains("declined") || haystack.contains("denied") { + return .denied + } + if haystack.contains("expired") || haystack.contains("quiet hours") { + return .expired + } + return .approved + } +} + +struct StatusPill: View { + let title: String + let color: Color var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: request.kind.systemImage) + Text(title) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(color.opacity(0.12), in: Capsule(style: .continuous)) + .foregroundStyle(color) + } +} + +struct TimeChip: View { + let date: Date + var compact = false + + var body: some View { + Text(date, format: .dateTime.hour().minute()) + .font(compact ? .caption2.weight(.medium) : .caption.weight(.medium)) + .monospacedDigit() + .padding(.horizontal, compact ? 8 : 10) + .padding(.vertical, compact ? 4 : 6) + .background(Color.idpTertiaryFill, in: Capsule(style: .continuous)) + .foregroundStyle(.secondary) + } +} + +struct ApprovalRow: View { + let request: ApprovalRequest + let handle: String + var compact = false + var highlighted = false + + var body: some View { + HStack(spacing: 12) { + MonogramAvatar(title: request.appDisplayName, size: compact ? 32 : 40) + + VStack(alignment: .leading, spacing: 4) { + Text(request.inboxTitle) + .font(compact ? .subheadline.weight(.semibold) : .headline) + .foregroundStyle(.primary) + .lineLimit(2) + + Text("as \(handle) · \(request.locationSummary)") + .font(compact ? .caption : .subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 8) + + HStack(spacing: 10) { + TimeChip(date: request.createdAt, compact: compact) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, compact ? 6 : 10) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(highlighted ? IdP.tint.opacity(0.06) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(highlighted ? IdP.tint : Color.clear, lineWidth: highlighted ? 1.5 : 0) + ) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(request.inboxTitle), \(request.locationSummary), \(request.createdAt.formatted(date: .omitted, time: .shortened))") + } +} + +struct NotificationEventRow: View { + let notification: AppNotification + + var body: some View { + HStack(alignment: .top, spacing: 12) { + MonogramAvatar(title: notification.title, size: 40, tint: notification.presentationStatus.color) + + VStack(alignment: .leading, spacing: 5) { + Text(notification.title) .font(.headline) - .foregroundStyle(requestAccent) - .frame(width: 28, height: 28) + .lineLimit(2) - 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) + Text(notification.message) + .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(2) } - controls - } - .padding(compactLayout ? 18 : 20) - .appSurface(radius: 24) - } + Spacer(minLength: 8) - @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 + StatusPill(title: notification.presentationStatus.title, color: notification.presentationStatus.color) } + .padding(.vertical, 8) + .accessibilityElement(children: .combine) } } -struct NotificationList: View { - let notifications: [AppNotification] - let compactLayout: Bool - let onMarkRead: (AppNotification) -> Void +struct NotificationPermissionCard: View { + @ObservedObject var model: AppViewModel var body: some View { - VStack(spacing: 14) { - ForEach(notifications) { notification in - NotificationCard( - notification: notification, - compactLayout: compactLayout, - onMarkRead: { onMarkRead(notification) } - ) - } - } - } -} + VStack(alignment: .leading, spacing: 14) { + Label("Allow sign-in alerts", systemImage: model.notificationPermission.systemImage) + .font(.headline) -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) + Text(model.notificationPermission.summary) .font(.subheadline) .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - if compactLayout { - VStack(alignment: .leading, spacing: 10) { - timestamp - if notification.isUnread { - markReadButton + VStack(spacing: 10) { + Button("Enable Notifications") { + Task { + await model.requestNotificationAccess() } } - } else { - HStack { - timestamp - Spacer(minLength: 0) - if notification.isUnread { - markReadButton + .buttonStyle(PrimaryActionStyle()) + + Button("Send Test Alert") { + Task { + await model.sendTestNotification() } } + .buttonStyle(SecondaryActionStyle()) } } - .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 - } + .approvalCard() } } -struct NotificationBellButton: View { - @ObservedObject var model: AppViewModel +struct DevicePresentation: Identifiable, Hashable { + let id: UUID + let name: String + let systemImage: String + let lastSeen: Date + let isCurrent: Bool + let isTrusted: Bool + + init( + id: UUID = UUID(), + name: String, + systemImage: String, + lastSeen: Date, + isCurrent: Bool, + isTrusted: Bool + ) { + self.id = id + self.name = name + self.systemImage = systemImage + self.lastSeen = lastSeen + self.isCurrent = isCurrent + self.isTrusted = isTrusted + } +} + +struct DeviceItemRow: View { + let device: DevicePresentation var body: some View { - Button { - model.isNotificationCenterPresented = true - } label: { - Image(systemName: imageName) + HStack(spacing: 12) { + Image(systemName: device.systemImage) .font(.headline) - .foregroundStyle(iconTone) - .frame(width: 28, height: 28, alignment: .center) - .background(alignment: .center) { - #if os(iOS) - GeometryReader { proxy in - Color.clear - .preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global)) - } - #endif - } + .foregroundStyle(IdP.tint) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 3) { + Text(device.name) + .font(.body.weight(.medium)) + + Text(device.isCurrent ? "This device" : "Seen \(device.lastSeen, style: .relative)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + StatusDot(color: device.isTrusted ? .green : .yellow) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) } - .accessibilityLabel("Notifications") - } - - private var imageName: String { - #if os(iOS) - model.unreadNotificationCount == 0 ? "bell" : "bell.fill" - #else - model.unreadNotificationCount == 0 ? "bell" : "bell.badge.fill" - #endif - } - - private var iconTone: some ShapeStyle { - model.unreadNotificationCount == 0 ? Color.primary : dashboardAccent + .deviceRowStyle() + .accessibilityElement(children: .combine) } } -struct NotificationCenterSheet: View { - @ObservedObject var model: AppViewModel - @Environment(\.dismiss) private var dismiss - @Environment(\.horizontalSizeClass) private var horizontalSizeClass +struct TrustSignalBanner: View { + let request: ApprovalRequest var body: some View { - NavigationStack { - AppScrollScreen( - compactLayout: compactLayout, - bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding - ) { - NotificationsPanel(model: model, compactLayout: compactLayout) - } - .navigationTitle("Notifications") - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } + HStack(alignment: .top, spacing: 12) { + Image(systemName: symbolName) + .font(.headline) + .foregroundStyle(request.trustColor) + + VStack(alignment: .leading, spacing: 4) { + Text(request.trustHeadline) + .font(.subheadline.weight(.semibold)) + + Text(request.trustExplanation) + .font(.subheadline) + .foregroundStyle(.secondary) } } - #if os(iOS) - .presentationDetents(compactLayout ? [.large] : [.medium, .large]) - #endif + .padding(.vertical, 8) } - private var compactLayout: Bool { - #if os(iOS) - horizontalSizeClass == .compact - #else - false - #endif + private var symbolName: String { + switch request.trustColor { + case .green: + return "checkmark.shield.fill" + case .yellow: + return "exclamationmark.triangle.fill" + default: + return "xmark.shield.fill" + } + } +} + +struct EmptyPaneView: View { + let title: String + let message: String + let systemImage: String + + var body: some View { + ContentUnavailableView { + Label(title, systemImage: systemImage) + } description: { + Text(message) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/swift/Sources/Features/Home/HomePanels.swift b/swift/Sources/Features/Home/HomePanels.swift index 77c9cf6..2160565 100644 --- a/swift/Sources/Features/Home/HomePanels.swift +++ b/swift/Sources/Features/Home/HomePanels.swift @@ -1,317 +1,467 @@ import SwiftUI -struct OverviewPanel: View { +struct InboxListView: View { @ObservedObject var model: AppViewModel - let compactLayout: Bool + @Binding var selectedRequestID: ApprovalRequest.ID? + @Binding var searchText: String + @Binding var isSearchPresented: Bool + var usesSelection = false - var body: some View { - VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { - if let profile = model.profile, let session = model.session { - OverviewHero( - profile: profile, - session: session, - pendingCount: model.pendingRequests.count, - unreadCount: model.unreadNotificationCount, - compactLayout: compactLayout - ) - } + private var filteredRequests: [ApprovalRequest] { + guard !searchText.isEmpty else { + return model.requests + } + + return model.requests.filter { + $0.inboxTitle.localizedCaseInsensitiveContains(searchText) + || $0.source.localizedCaseInsensitiveContains(searchText) + || $0.subtitle.localizedCaseInsensitiveContains(searchText) } } -} -struct RequestsPanel: View { - @ObservedObject var model: AppViewModel - let compactLayout: Bool - let onOpenRequest: (ApprovalRequest) -> Void + private var recentRequests: [ApprovalRequest] { + filteredRequests.filter { Date.now.timeIntervalSince($0.createdAt) <= 60 * 30 } + } + + private var earlierRequests: [ApprovalRequest] { + filteredRequests.filter { Date.now.timeIntervalSince($0.createdAt) > 60 * 30 } + } + + private var highlightedRequestID: ApprovalRequest.ID? { + filteredRequests.first?.id + } var body: some View { - VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { - if model.requests.isEmpty { - AppPanel(compactLayout: compactLayout) { - EmptyStateCopy( - title: "No checks waiting", - systemImage: "checkmark.circle", - message: "Identity proof requests from sites and devices appear here." - ) - } + List { + if filteredRequests.isEmpty { + EmptyPaneView( + title: "No sign-in requests", + message: "New approval requests will appear here as soon as a relying party asks for proof.", + systemImage: "tray" + ) + .listRowBackground(Color.clear) } else { - RequestList( - requests: model.requests, - compactLayout: compactLayout, - activeRequestID: model.activeRequestID, - onApprove: { request in - Task { await model.approve(request) } - }, - onReject: { request in - Task { await model.reject(request) } - }, - onOpenRequest: onOpenRequest - ) - } - } - } -} - -struct ActivityPanel: View { - @ObservedObject var model: AppViewModel - let compactLayout: Bool - - var body: some View { - VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { - if model.notifications.isEmpty { - AppPanel(compactLayout: compactLayout) { - EmptyStateCopy( - title: "No proof activity yet", - systemImage: "clock.badge.xmark", - message: "Identity proofs and security events will appear here." - ) + ForEach(recentRequests) { request in + row(for: request, compact: false) + .transition(.move(edge: .top).combined(with: .opacity)) } - } else { - NotificationList( - notifications: model.notifications, - compactLayout: compactLayout, - onMarkRead: { notification in - Task { await model.markNotificationRead(notification) } - } - ) - } - } - } -} -struct NotificationsPanel: View { - @ObservedObject var model: AppViewModel - let compactLayout: Bool - - var body: some View { - VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { - AppSectionCard(title: "Delivery", compactLayout: compactLayout) { - NotificationPermissionSummary(model: model, compactLayout: compactLayout) - } - - AppSectionCard(title: "Alerts", compactLayout: compactLayout) { - if model.notifications.isEmpty { - EmptyStateCopy( - title: "No alerts yet", - systemImage: "bell.slash", - message: "New passport and identity-proof alerts will accumulate here." - ) - } else { - NotificationList( - notifications: model.notifications, - compactLayout: compactLayout, - onMarkRead: { notification in - Task { await model.markNotificationRead(notification) } + if !earlierRequests.isEmpty { + Section { + ForEach(earlierRequests) { request in + row(for: request, compact: true) + .transition(.move(edge: .top).combined(with: .opacity)) } - ) - } - } - } - } -} - -struct AccountPanel: View { - @ObservedObject var model: AppViewModel - let compactLayout: Bool - - var body: some View { - VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { - if let profile = model.profile, let session = model.session { - AccountHero(profile: profile, session: session, compactLayout: compactLayout) - - AppSectionCard(title: "Session", compactLayout: compactLayout) { - AccountFactsGrid(profile: profile, session: session, compactLayout: compactLayout) - } - } - - AppSectionCard(title: "Pairing payload", compactLayout: compactLayout) { - AppTextSurface(text: model.suggestedPairingPayload, monospaced: true) - } - - AppSectionCard(title: "Actions", compactLayout: compactLayout) { - Button(role: .destructive) { - model.signOut() - } label: { - Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") - } - .buttonStyle(.bordered) - } - } - } -} - -private struct OverviewHero: View { - let profile: MemberProfile - let session: AuthSession - let pendingCount: Int - let unreadCount: Int - let compactLayout: Bool - - private var detailColumns: [GridItem] { - Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2) - } - - private var metricColumns: [GridItem] { - Array(repeating: GridItem(.flexible(), spacing: 16), count: 3) - } - - var body: some View { - AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) { - AppBadge(title: "Digital passport", tone: dashboardAccent) - - VStack(alignment: .leading, spacing: 6) { - Text(profile.name) - .font(.system(size: compactLayout ? 30 : 38, weight: .bold, design: .rounded)) - .lineLimit(2) - - Text("\(profile.handle) • \(profile.organization)") - .font(.subheadline) - .foregroundStyle(.secondary) - } - - HStack(spacing: 8) { - AppStatusTag(title: "Passport active", tone: dashboardAccent) - AppStatusTag(title: session.pairingTransport.title, tone: dashboardGold) - } - - Divider() - - LazyVGrid(columns: detailColumns, alignment: .leading, spacing: 16) { - AppKeyValue(label: "Device", value: session.deviceName) - AppKeyValue(label: "Origin", value: session.originHost, monospaced: true) - AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened)) - AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true) - } - - Divider() - - LazyVGrid(columns: metricColumns, alignment: .leading, spacing: 16) { - AppMetric(title: "Pending", value: "\(pendingCount)") - AppMetric(title: "Alerts", value: "\(unreadCount)") - AppMetric(title: "Devices", value: "\(profile.deviceCount)") - } - } - } -} - -private struct NotificationPermissionSummary: View { - @ObservedObject var model: AppViewModel - let compactLayout: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: model.notificationPermission.systemImage) - .font(.headline) - .foregroundStyle(dashboardAccent) - .frame(width: 28, height: 28) - - VStack(alignment: .leading, spacing: 4) { - Text(model.notificationPermission.title) - .font(.headline) - Text(model.notificationPermission.summary) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - - if compactLayout { - VStack(alignment: .leading, spacing: 12) { - permissionButtons - } - } else { - HStack(spacing: 12) { - permissionButtons + } header: { + Text("Earlier today") + .textCase(nil) + } } } } + .listStyle(.plain) + .navigationTitle("Inbox") + .animation(.spring(response: 0.35, dampingFraction: 0.88), value: filteredRequests.map(\.id)) + .idpSearchable(text: $searchText, isPresented: $isSearchPresented) } @ViewBuilder - private var permissionButtons: some View { - Button { - Task { await model.requestNotificationAccess() } - } label: { - Label("Enable notifications", systemImage: "bell.and.waves.left.and.right.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - - Button { - Task { await model.sendTestNotification() } - } label: { - Label("Send test alert", systemImage: "paperplane.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - } -} - -private struct AccountHero: View { - let profile: MemberProfile - let session: AuthSession - let compactLayout: Bool - - var body: some View { - AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) { - AppBadge(title: "Account", tone: dashboardAccent) - - Text(profile.name) - .font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded)) - .lineLimit(2) - - Text(profile.handle) - .font(.headline) - .foregroundStyle(.secondary) - - Text("Active client: \(session.deviceName)") - .font(.subheadline) - .foregroundStyle(.secondary) - } - } -} - -private struct AccountFactsGrid: View { - let profile: MemberProfile - let session: AuthSession - let compactLayout: Bool - - private var columns: [GridItem] { - Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2) - } - - var body: some View { - LazyVGrid(columns: columns, alignment: .leading, spacing: 16) { - AppKeyValue(label: "Organization", value: profile.organization) - AppKeyValue(label: "Origin", value: session.originHost, monospaced: true) - AppKeyValue(label: "Linked At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened)) - AppKeyValue(label: "Method", value: session.pairingTransport.title) - AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true) - AppKeyValue(label: "Recovery", value: profile.recoverySummary) - if let signedGPSPosition = session.signedGPSPosition { - AppKeyValue( - label: "Signed GPS", - value: "\(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)", - monospaced: true + private func row(for request: ApprovalRequest, compact: Bool) -> some View { + if usesSelection { + Button { + selectedRequestID = request.id + Haptics.selection() + } label: { + ApprovalRow( + request: request, + handle: model.profile?.handle ?? "@you", + compact: compact, + highlighted: highlightedRequestID == request.id ) } - AppKeyValue(label: "Trusted Devices", value: "\(profile.deviceCount)") + .buttonStyle(.plain) + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } else { + NavigationLink(value: request.id) { + ApprovalRow( + request: request, + handle: model.profile?.handle ?? "@you", + compact: compact, + highlighted: highlightedRequestID == request.id + ) + } + .buttonStyle(.plain) + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) } } } -private struct EmptyStateCopy: View { - let title: String - let systemImage: String - let message: String +struct NotificationCenterView: View { + @ObservedObject var model: AppViewModel + + private var groupedNotifications: [(String, [AppNotification])] { + let calendar = Calendar.current + let groups = Dictionary(grouping: model.notifications) { calendar.startOfDay(for: $0.sentAt) } + + return groups + .keys + .sorted(by: >) + .map { day in + (sectionTitle(for: day), groups[day]?.sorted(by: { $0.sentAt > $1.sentAt }) ?? []) + } + } var body: some View { - ContentUnavailableView( - title, - systemImage: systemImage, - description: Text(message) - ) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) + Group { + if model.notifications.isEmpty { + EmptyPaneView( + title: "All clear", + message: "You'll see new sign-in requests here.", + systemImage: "shield" + ) + } else { + List { + if model.notificationPermission == .unknown || model.notificationPermission == .denied { + NotificationPermissionCard(model: model) + .listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + + ForEach(groupedNotifications, id: \.0) { section in + Section { + ForEach(section.1) { notification in + Button { + guard notification.isUnread else { return } + Task { + await model.markNotificationRead(notification) + } + } label: { + NotificationEventRow(notification: notification) + } + .buttonStyle(.plain) + } + } header: { + Text(section.0) + .textCase(nil) + } + } + } + .listStyle(.plain) + } + } + .navigationTitle("Notifications") + } + + private func sectionTitle(for date: Date) -> String { + if Calendar.current.isDateInToday(date) { + return "Today" + } + if Calendar.current.isDateInYesterday(date) { + return "Yesterday" + } + return date.formatted(.dateTime.month(.wide).day()) + } +} + +struct DevicesView: View { + @ObservedObject var model: AppViewModel + @State private var isPairingCodePresented = false + + private var devices: [DevicePresentation] { + guard let session else { return [] } + + let current = DevicePresentation( + name: session.deviceName, + systemImage: symbolName(for: session.deviceName), + lastSeen: .now, + isCurrent: true, + isTrusted: true + ) + + let others = [ + DevicePresentation(name: "Phil's iPad Pro", systemImage: "ipad", lastSeen: .now.addingTimeInterval(-60 * 18), isCurrent: false, isTrusted: true), + DevicePresentation(name: "Berlin MacBook Pro", systemImage: "laptopcomputer", lastSeen: .now.addingTimeInterval(-60 * 74), isCurrent: false, isTrusted: true), + DevicePresentation(name: "Apple Watch", systemImage: "applewatch", lastSeen: .now.addingTimeInterval(-60 * 180), isCurrent: false, isTrusted: false) + ] + + let count = max((model.profile?.deviceCount ?? 1) - 1, 0) + return [current] + Array(others.prefix(count)) + } + + private var session: AuthSession? { + model.session + } + + var body: some View { + Form { + Section("This device") { + if let current = devices.first { + DeviceItemRow(device: current) + } + } + + Section("Other devices · \(max(devices.count - 1, 0))") { + ForEach(Array(devices.dropFirst())) { device in + DeviceItemRow(device: device) + } + } + + Section { + VStack(spacing: 12) { + Button("Pair another device") { + isPairingCodePresented = true + } + .buttonStyle(PrimaryActionStyle()) + + Button("Sign out everywhere") { + model.signOut() + } + .buttonStyle(DestructiveStyle()) + } + .padding(.vertical, 6) + } + } + .navigationTitle("Devices") + .sheet(isPresented: $isPairingCodePresented) { + if let session { + OneTimePasscodeSheet(session: session) + } + } + } + + private func symbolName(for deviceName: String) -> String { + let lowercased = deviceName.lowercased() + if lowercased.contains("ipad") { + return "ipad" + } + if lowercased.contains("watch") { + return "applewatch" + } + if lowercased.contains("mac") || lowercased.contains("safari") { + return "laptopcomputer" + } + return "iphone" + } +} + +struct IdentityView: View { + @ObservedObject var model: AppViewModel + + var body: some View { + Form { + if let profile = model.profile { + Section("Identity") { + LabeledContent("Name", value: profile.name) + LabeledContent("Handle", value: profile.handle) + LabeledContent("Organization", value: profile.organization) + } + + Section("Recovery") { + Text(profile.recoverySummary) + .font(.body) + } + } + + if let session = model.session { + Section("Session") { + LabeledContent("Device", value: session.deviceName) + LabeledContent("Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened)) + LabeledContent("Origin", value: session.originHost) + LabeledContent("Transport", value: session.pairingTransport.title) + } + + Section("Pairing payload") { + Text(session.pairingCode) + .font(.footnote.monospaced()) + .textSelection(.enabled) + } + } + } + .navigationTitle("Identity") + } +} + +struct SettingsView: View { + @ObservedObject var model: AppViewModel + + var body: some View { + Form { + Section("Alerts") { + LabeledContent("Notifications", value: model.notificationPermission.title) + + Button("Enable Notifications") { + Task { + await model.requestNotificationAccess() + } + } + .buttonStyle(SecondaryActionStyle()) + + Button("Send Test Notification") { + Task { + await model.sendTestNotification() + } + } + .buttonStyle(SecondaryActionStyle()) + } + + Section("Demo") { + Button("Simulate Incoming Request") { + Task { + await model.simulateIncomingRequest() + } + } + .buttonStyle(PrimaryActionStyle()) + + Button("Refresh") { + Task { + await model.refreshDashboard() + } + } + .buttonStyle(SecondaryActionStyle()) + } + } + .navigationTitle("Settings") + } +} + +enum PreviewFixtures { + static let profile = MemberProfile( + name: "Jurgen Meyer", + handle: "@jurgen", + organization: "idp.global", + deviceCount: 4, + recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified." + ) + + static let session = AuthSession( + deviceName: "iPhone 17 Pro", + originHost: "github.com", + pairedAt: .now.addingTimeInterval(-60 * 90), + tokenPreview: "berlin", + pairingCode: "idp.global://pair?token=swiftapp-demo-berlin&origin=github.com&device=iPhone%2017%20Pro", + pairingTransport: .preview + ) + + static let requests: [ApprovalRequest] = [ + ApprovalRequest( + title: "Prove identity for GitHub", + subtitle: "GitHub is asking for a routine sign-in confirmation.", + source: "github.com", + createdAt: .now.addingTimeInterval(-60 * 4), + kind: .signIn, + risk: .routine, + scopes: ["email", "profile", "session:read"], + status: .pending + ), + ApprovalRequest( + title: "Prove identity for workspace", + subtitle: "Your secure workspace needs a stronger proof before unlocking.", + source: "workspace.idp.global", + createdAt: .now.addingTimeInterval(-60 * 42), + kind: .elevatedAction, + risk: .elevated, + scopes: ["profile", "device", "location"], + status: .pending + ), + ApprovalRequest( + title: "CLI session", + subtitle: "A CLI login was completed earlier today.", + source: "cli.idp.global", + createdAt: .now.addingTimeInterval(-60 * 120), + kind: .signIn, + risk: .routine, + scopes: ["profile"], + status: .approved + ) + ] + + static let notifications: [AppNotification] = [ + AppNotification( + title: "GitHub sign-in approved", + message: "Your latest sign-in request for github.com was approved.", + sentAt: .now.addingTimeInterval(-60 * 9), + kind: .approval, + isUnread: true + ), + AppNotification( + title: "Recovery check passed", + message: "Backup recovery channels were verified in the last 24 hours.", + sentAt: .now.addingTimeInterval(-60 * 110), + kind: .system, + isUnread: false + ), + AppNotification( + title: "Session expired", + message: "A pending workstation approval expired before it could be completed.", + sentAt: .now.addingTimeInterval(-60 * 1_500), + kind: .security, + isUnread: false + ) + ] + + @MainActor + static func model() -> AppViewModel { + let model = AppViewModel( + service: MockIDPService.shared, + notificationCoordinator: PreviewNotificationCoordinator(), + appStateStore: PreviewStateStore(), + launchArguments: [] + ) + model.session = session + model.profile = profile + model.requests = requests + model.notifications = notifications + model.selectedSection = .inbox + model.manualPairingPayload = session.pairingCode + model.suggestedPairingPayload = session.pairingCode + model.notificationPermission = .allowed + return model + } +} + +private struct PreviewNotificationCoordinator: NotificationCoordinating { + func authorizationStatus() async -> NotificationPermissionState { .allowed } + func requestAuthorization() async throws -> NotificationPermissionState { .allowed } + func scheduleTestNotification(title: String, body: String) async throws {} +} + +private struct PreviewStateStore: AppStateStoring { + func load() -> PersistedAppState? { nil } + func save(_ state: PersistedAppState) {} + func clear() {} +} + +#Preview("Inbox Light") { + NavigationStack { + InboxPreviewHost() + } +} + +#Preview("Inbox Dark") { + NavigationStack { + InboxPreviewHost() + } + .preferredColorScheme(.dark) +} + +@MainActor +private struct InboxPreviewHost: View { + @State private var selectedRequestID = PreviewFixtures.requests.first?.id + @State private var searchText = "" + @State private var isSearchPresented = false + @State private var model = PreviewFixtures.model() + + var body: some View { + InboxListView( + model: model, + selectedRequestID: $selectedRequestID, + searchText: $searchText, + isSearchPresented: $isSearchPresented + ) } } diff --git a/swift/Sources/Features/Home/HomeRootView.swift b/swift/Sources/Features/Home/HomeRootView.swift index 9271797..65d262b 100644 --- a/swift/Sources/Features/Home/HomeRootView.swift +++ b/swift/Sources/Features/Home/HomeRootView.swift @@ -1,78 +1,73 @@ import SwiftUI -let dashboardAccent = AppTheme.accent -let dashboardGold = AppTheme.warmAccent - -extension View { - @ViewBuilder - func inlineNavigationTitleOnIOS() -> some View { - #if os(iOS) - navigationBarTitleDisplayMode(.inline) - #else - self - #endif - } - - @ViewBuilder - func cleanTabBarOnIOS() -> some View { - #if os(iOS) - toolbarBackground(.visible, for: .tabBar) - .toolbarBackground(AppTheme.chromeFill, for: .tabBar) - #else - self - #endif - } -} - struct HomeRootView: View { @ObservedObject var model: AppViewModel - @State private var notificationBellFrame: CGRect? + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + @State private var selectedRequestID: ApprovalRequest.ID? + @State private var searchText = "" + @State private var isSearchPresented = false var body: some View { Group { - if usesCompactNavigation { - CompactHomeContainer(model: model) - } else { - RegularHomeContainer(model: model) - } - } - .onPreferenceChange(NotificationBellFrameKey.self) { notificationBellFrame = $0 } - .overlay(alignment: .topLeading) { - if usesCompactNavigation { - NotificationBellBadgeOverlay( - unreadCount: model.unreadNotificationCount, - bellFrame: notificationBellFrame + if usesRegularNavigation { + RegularHomeContainer( + model: model, + selectedRequestID: $selectedRequestID, + searchText: $searchText, + isSearchPresented: $isSearchPresented + ) + } else { + CompactHomeContainer( + model: model, + selectedRequestID: $selectedRequestID, + searchText: $searchText, + isSearchPresented: $isSearchPresented ) - .ignoresSafeArea() } } - .sheet(isPresented: $model.isNotificationCenterPresented) { - NotificationCenterSheet(model: model) + .onAppear(perform: syncSelection) + .onChange(of: model.requests.map(\.id)) { _, _ in + syncSelection() } } - private var usesCompactNavigation: Bool { + private var usesRegularNavigation: Bool { #if os(iOS) - true + horizontalSizeClass == .regular #else false #endif } + + private func syncSelection() { + if let selectedRequestID, + model.requests.contains(where: { $0.id == selectedRequestID }) { + return + } + + selectedRequestID = model.pendingRequests.first?.id ?? model.requests.first?.id + } } private struct CompactHomeContainer: View { @ObservedObject var model: AppViewModel - @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Binding var selectedRequestID: ApprovalRequest.ID? + @Binding var searchText: String + @Binding var isSearchPresented: Bool var body: some View { TabView(selection: $model.selectedSection) { ForEach(AppSection.allCases) { section in NavigationStack { - HomeSectionScreen(model: model, section: section, compactLayout: compactLayout) - .navigationTitle(section.title) - .inlineNavigationTitleOnIOS() + sectionContent(for: section) + .navigationDestination(for: ApprovalRequest.ID.self) { requestID in + ApprovalDetailView(model: model, requestID: requestID, dismissOnResolve: true) + } .toolbar { - DashboardToolbar(model: model) + if section == .inbox { + InboxToolbar(model: model, isSearchPresented: $isSearchPresented) + } } } .tag(section) @@ -81,239 +76,130 @@ private struct CompactHomeContainer: View { } } } - .cleanTabBarOnIOS() + .idpTabBarChrome() } - private var compactLayout: Bool { - #if os(iOS) - horizontalSizeClass == .compact - #else - false - #endif + @ViewBuilder + private func sectionContent(for section: AppSection) -> some View { + switch section { + case .inbox: + InboxListView( + model: model, + selectedRequestID: $selectedRequestID, + searchText: $searchText, + isSearchPresented: $isSearchPresented, + usesSelection: false + ) + case .notifications: + NotificationCenterView(model: model) + case .devices: + DevicesView(model: model) + case .identity: + IdentityView(model: model) + case .settings: + SettingsView(model: model) + } } } private struct RegularHomeContainer: View { @ObservedObject var model: AppViewModel + @Binding var selectedRequestID: ApprovalRequest.ID? + @Binding var searchText: String + @Binding var isSearchPresented: Bool var body: some View { NavigationSplitView { - Sidebar(model: model) - .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 320) + SidebarView(model: model) + .navigationSplitViewColumnWidth(min: 250, ideal: 280, max: 320) + } content: { + contentColumn } detail: { - HomeSectionScreen(model: model, section: model.selectedSection, compactLayout: false) - .navigationTitle(model.selectedSection.title) - .toolbar { - DashboardToolbar(model: model) - } + detailColumn } .navigationSplitViewStyle(.balanced) } -} -private struct DashboardToolbar: ToolbarContent { - @ObservedObject var model: AppViewModel - - var body: some ToolbarContent { - ToolbarItemGroup(placement: .primaryAction) { - NotificationBellButton(model: model) - } - } -} - -struct NotificationBellFrameKey: PreferenceKey { - static var defaultValue: CGRect? = nil - - static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) { - value = nextValue() ?? value - } -} - -private struct NotificationBellBadgeOverlay: View { - let unreadCount: Int - let bellFrame: CGRect? - - var body: some View { - GeometryReader { proxy in - if unreadCount > 0, let bellFrame { - let rootFrame = proxy.frame(in: .global) - - Text("\(min(unreadCount, 9))") - .font(.caption2.weight(.bold)) - .foregroundStyle(.white) - .frame(minWidth: 18, minHeight: 18) - .padding(.horizontal, 3) - .background(Color.orange, in: Capsule()) - .position( - x: bellFrame.maxX - rootFrame.minX - 2, - y: bellFrame.minY - rootFrame.minY + 2 - ) - } - } - .allowsHitTesting(false) - } -} - -private struct HomeSectionScreen: View { - @ObservedObject var model: AppViewModel - let section: AppSection - let compactLayout: Bool - - @State private var focusedRequest: ApprovalRequest? - @State private var isOTPPresented = false - @StateObject private var identifyReader = NFCIdentifyReader() - - var body: some View { - AppScrollScreen( - compactLayout: compactLayout, - bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding - ) { - HomeTopActions( + @ViewBuilder + private var contentColumn: some View { + switch model.selectedSection { + case .inbox: + InboxListView( model: model, - identifyReader: identifyReader, - onScanQR: { model.isScannerPresented = true }, - onShowOTP: { isOTPPresented = true } + selectedRequestID: $selectedRequestID, + searchText: $searchText, + isSearchPresented: $isSearchPresented, + usesSelection: true ) + .toolbar { + InboxToolbar(model: model, isSearchPresented: $isSearchPresented) + } + case .notifications: + NotificationCenterView(model: model) + case .devices: + DevicesView(model: model) + case .identity: + IdentityView(model: model) + case .settings: + SettingsView(model: model) + } + } - switch section { - case .overview: - OverviewPanel(model: model, compactLayout: compactLayout) - case .requests: - RequestsPanel(model: model, compactLayout: compactLayout, onOpenRequest: { focusedRequest = $0 }) - case .activity: - ActivityPanel(model: model, compactLayout: compactLayout) - case .account: - AccountPanel(model: model, compactLayout: compactLayout) - } - } - .task { - identifyReader.onAuthenticationRequestDetected = { request in - Task { - await model.identifyWithNFC(request) - } - } - - identifyReader.onError = { message in - model.errorMessage = message - } - } - .sheet(item: $focusedRequest) { request in - RequestDetailSheet(request: request, model: model) - } - .sheet(isPresented: $model.isScannerPresented) { - QRScannerSheet( - seededPayload: model.session?.pairingCode ?? model.suggestedPairingPayload, - title: "Scan proof QR", - description: "Use the camera to scan an idp.global QR challenge from the site or device asking you to prove that it is really you.", - navigationTitle: "Scan Proof QR", - onCodeScanned: { payload in - Task { - await model.identifyWithPayload(payload, transport: .qr) - } - } + @ViewBuilder + private var detailColumn: some View { + switch model.selectedSection { + case .inbox: + ApprovalDetailView(model: model, requestID: selectedRequestID) + case .notifications: + EmptyPaneView( + title: "Notification history", + message: "Select the inbox to review request context side by side.", + systemImage: "bell" + ) + case .devices: + EmptyPaneView( + title: "Trusted hardware", + message: "Device trust and last-seen state appear here while you manage your passport.", + systemImage: "desktopcomputer" + ) + case .identity: + EmptyPaneView( + title: "Identity overview", + message: "Your profile, recovery status, and pairing state stay visible here.", + systemImage: "person.crop.rectangle.stack" + ) + case .settings: + EmptyPaneView( + title: "Preferences", + message: "Notification delivery and demo controls live in settings.", + systemImage: "gearshape" ) - } - .sheet(isPresented: $isOTPPresented) { - if let session = model.session { - OneTimePasscodeSheet(session: session) - } } } } -private struct HomeTopActions: View { - @ObservedObject var model: AppViewModel - @ObservedObject var identifyReader: NFCIdentifyReader - let onScanQR: () -> Void - let onShowOTP: () -> Void - - var body: some View { - LazyVGrid(columns: columns, spacing: 12) { - identifyButton - qrButton - otpButton - } - } - - private var columns: [GridItem] { - Array(repeating: GridItem(.flexible(), spacing: 12), count: 3) - } - - private var identifyButton: some View { - Button { - identifyReader.beginScanning() - } label: { - AppActionTile( - title: identifyReader.isScanning ? "Scanning NFC" : "Tap NFC", - systemImage: "dot.radiowaves.left.and.right", - tone: dashboardAccent, - isBusy: identifyReader.isScanning || model.isIdentifying - ) - } - .buttonStyle(.plain) - .disabled(identifyReader.isScanning || !identifyReader.isSupported || model.isIdentifying) - } - - private var qrButton: some View { - Button { - onScanQR() - } label: { - AppActionTile( - title: "Scan QR", - systemImage: "qrcode.viewfinder", - tone: dashboardAccent - ) - } - .buttonStyle(.plain) - } - - private var otpButton: some View { - Button { - onShowOTP() - } label: { - AppActionTile( - title: "OTP", - systemImage: "number.square.fill", - tone: dashboardGold - ) - } - .buttonStyle(.plain) - } -} - -private struct Sidebar: View { +struct SidebarView: View { @ObservedObject var model: AppViewModel var body: some View { List { - Section { - SidebarStatusCard( - profile: model.profile, - pendingCount: model.pendingRequests.count, - unreadCount: model.unreadNotificationCount - ) - } - - Section("Workspace") { - ForEach(AppSection.allCases) { section in - Button { - model.selectedSection = section - } label: { - HStack { - Label(section.title, systemImage: section.systemImage) - Spacer() - if badgeCount(for: section) > 0 { - AppStatusTag(title: "\(badgeCount(for: section))", tone: dashboardAccent) - } + ForEach(Array(AppSection.allCases.enumerated()), id: \.element.id) { index, section in + Button { + model.selectedSection = section + Haptics.selection() + } label: { + HStack(spacing: 12) { + Label(section.title, systemImage: section.systemImage) + Spacer() + if badgeCount(for: section) > 0 { + StatusPill(title: "\(badgeCount(for: section))", color: IdP.tint) } } - .buttonStyle(.plain) - .listRowBackground( - model.selectedSection == section - ? dashboardAccent.opacity(0.10) - : Color.clear - ) + .padding(.vertical, 6) } + .buttonStyle(.plain) + .listRowBackground(model.selectedSection == section ? IdP.tint.opacity(0.08) : Color.clear) + .keyboardShortcut(shortcut(for: index), modifiers: .command) } } .navigationTitle("idp.global") @@ -321,36 +207,57 @@ private struct Sidebar: View { private func badgeCount(for section: AppSection) -> Int { switch section { - case .overview: - 0 - case .requests: + case .inbox: model.pendingRequests.count - case .activity: + case .notifications: model.unreadNotificationCount - case .account: + case .devices: + max((model.profile?.deviceCount ?? 1) - 1, 0) + case .identity, .settings: 0 } } -} -private struct SidebarStatusCard: View { - let profile: MemberProfile? - let pendingCount: Int - let unreadCount: Int - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text("Digital Passport") - .font(.headline) - - Text(profile?.handle ?? "No passport active") - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - AppStatusTag(title: "\(pendingCount) pending", tone: dashboardAccent) - AppStatusTag(title: "\(unreadCount) unread", tone: dashboardGold) - } - } - .padding(.vertical, 6) + private func shortcut(for index: Int) -> KeyEquivalent { + let value = max(1, min(index + 1, 9)) + return KeyEquivalent(Character("\(value)")) + } +} + +private struct InboxToolbar: ToolbarContent { + @ObservedObject var model: AppViewModel + @Binding var isSearchPresented: Bool + + var body: some ToolbarContent { + ToolbarItem(placement: .idpTrailingToolbar) { + HStack(spacing: 8) { + Button { + isSearchPresented = true + } label: { + Image(systemName: "magnifyingglass") + .font(.headline) + .foregroundStyle(.primary) + } + .accessibilityLabel("Search inbox") + + Button { + model.selectedSection = .identity + } label: { + MonogramAvatar(title: model.profile?.name ?? "idp.global", size: 28) + } + .accessibilityLabel("Open identity") + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.clear) + .idpGlassChrome() + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 1) + ) + } } } diff --git a/swift/Sources/Features/Home/HomeSheets.swift b/swift/Sources/Features/Home/HomeSheets.swift index 6f9efbf..b7ff904 100644 --- a/swift/Sources/Features/Home/HomeSheets.swift +++ b/swift/Sources/Features/Home/HomeSheets.swift @@ -1,122 +1,299 @@ import SwiftUI +struct ApprovalDetailView: View { + @ObservedObject var model: AppViewModel + let requestID: ApprovalRequest.ID? + var dismissOnResolve = false + + @Environment(\.dismiss) private var dismiss + + private var request: ApprovalRequest? { + guard let requestID else { return nil } + return model.requests.first(where: { $0.id == requestID }) + } + + var body: some View { + Group { + if let request { + VStack(spacing: 0) { + RequestHeroCard( + request: request, + handle: model.profile?.handle ?? "@you" + ) + .padding(.horizontal, 16) + .padding(.top, 16) + + Form { + Section("Context") { + LabeledContent("From device", value: request.deviceSummary) + LabeledContent("Location", value: request.locationSummary) + LabeledContent("Network", value: request.networkSummary) + LabeledContent("IP") { + Text(request.ipSummary) + .monospacedDigit() + } + } + + Section("Will share") { + ForEach(request.scopes, id: \.self) { scope in + Label(scope, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + + Section("Trust signals") { + TrustSignalBanner(request: request) + } + } + .scrollContentBackground(.hidden) + .background(Color.idpGroupedBackground) + } + .background(Color.idpGroupedBackground) + .navigationTitle(request.appDisplayName) + .idpInlineNavigationTitle() + .toolbar { + ToolbarItem(placement: .idpTrailingToolbar) { + IdPGlassCapsule(padding: EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) { + Text(request.expiresAt, style: .timer) + .font(.caption.weight(.semibold)) + .monospacedDigit() + } + } + } + .safeAreaInset(edge: .bottom) { + if request.status == .pending { + HStack(spacing: 12) { + Button("Deny") { + Task { + await performReject(request) + } + } + .buttonStyle(SecondaryActionStyle()) + + HoldToApproveButton(isBusy: model.activeRequestID == request.id) { + await performApprove(request) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background { + Rectangle() + .fill(.clear) + .idpGlassChrome() + } + } + } + .background { + keyboardShortcuts(for: request) + } + } else { + EmptyPaneView( + title: "Nothing selected", + message: "Choose a sign-in request from the inbox to review the full context.", + systemImage: "checkmark.circle" + ) + } + } + } + + @ViewBuilder + private func keyboardShortcuts(for request: ApprovalRequest) -> some View { + Group { + Button("Approve") { + Task { + await performApprove(request) + } + } + .keyboardShortcut(.return, modifiers: .command) + .hidden() + .accessibilityHidden(true) + + Button("Deny") { + Task { + await performReject(request) + } + } + .keyboardShortcut(.delete, modifiers: .command) + .hidden() + .accessibilityHidden(true) + } + } + + private func performApprove(_ request: ApprovalRequest) async { + guard model.activeRequestID != request.id else { return } + await model.approve(request) + if dismissOnResolve { + dismiss() + } + } + + private func performReject(_ request: ApprovalRequest) async { + guard model.activeRequestID != request.id else { return } + Haptics.warning() + await model.reject(request) + if dismissOnResolve { + dismiss() + } + } +} + struct RequestDetailSheet: View { let request: ApprovalRequest @ObservedObject var model: AppViewModel - @Environment(\.dismiss) private var dismiss - var body: some View { NavigationStack { - AppScrollScreen( - compactLayout: true, - bottomPadding: AppLayout.compactBottomDockPadding - ) { - RequestDetailHero(request: request) - - AppSectionCard(title: "Summary", compactLayout: true) { - AppKeyValue(label: "Source", value: request.source) - AppKeyValue(label: "Requested", value: request.createdAt.formatted(date: .abbreviated, time: .shortened)) - AppKeyValue(label: "Risk", value: request.risk.summary) - AppKeyValue(label: "Type", value: request.kind.title) - } - - AppSectionCard(title: "Proof details", compactLayout: true) { - if request.scopes.isEmpty { - Text("No explicit proof details were provided by the mock backend.") - .foregroundStyle(.secondary) - } else { - Text(request.scopes.joined(separator: "\n")) - .font(.body.monospaced()) - .foregroundStyle(.secondary) - } - } - - AppSectionCard(title: "Guidance", compactLayout: true) { - Text(request.trustDetail) - .foregroundStyle(.secondary) - - Text(request.risk.guidance) - .font(.headline) - } - - if request.status == .pending { - AppSectionCard(title: "Actions", compactLayout: true) { - VStack(spacing: 12) { - Button { - Task { - await model.approve(request) - dismiss() - } - } label: { - if model.activeRequestID == request.id { - ProgressView() - } else { - Label("Verify identity", systemImage: "checkmark.circle.fill") - .frame(maxWidth: .infinity) - } - } - .buttonStyle(.borderedProminent) - .disabled(model.activeRequestID == request.id) - - Button(role: .destructive) { - Task { - await model.reject(request) - dismiss() - } - } label: { - Label("Decline", systemImage: "xmark.circle.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .disabled(model.activeRequestID == request.id) - } - } - } - } - .navigationTitle("Review Proof") - .inlineNavigationTitleOnIOS() - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close") { - dismiss() - } - } - } + ApprovalDetailView(model: model, requestID: request.id, dismissOnResolve: true) } } } -private struct RequestDetailHero: View { - let request: ApprovalRequest +struct HoldToApproveButton: View { + var title = "Hold to approve" + var isBusy = false + let action: () async -> Void - private var accent: Color { - switch request.status { - case .approved: - .green - case .rejected: - .red - case .pending: - request.risk == .routine ? dashboardAccent : .orange + @State private var progress: CGFloat = 0 + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .fill(isBusy ? Color.secondary.opacity(0.24) : IdP.tint) + + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 1) + + label + .padding(.horizontal, 20) + .padding(.vertical, 14) + + GeometryReader { geometry in + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .trim(from: 0, to: progress) + .stroke(Color.white.opacity(0.85), style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .padding(2) + } + } + .frame(minHeight: 52) + .contentShape(RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)) + .onLongPressGesture(minimumDuration: 0.6, maximumDistance: 20, pressing: updateProgress) { + guard !isBusy else { return } + Task { + Haptics.success() + await action() + progress = 0 + } + } + .accessibilityAddTraits(.isButton) + .accessibilityLabel(title) + .accessibilityHint("Press and hold to approve this request.") + } + + @ViewBuilder + private var label: some View { + if isBusy { + ProgressView() + .tint(.white) + } else { + Text(title) + .font(.headline) + .foregroundStyle(.white) } } + private func updateProgress(_ isPressing: Bool) { + guard !isBusy else { return } + withAnimation(.linear(duration: isPressing ? 0.6 : 0.15)) { + progress = isPressing ? 1 : 0 + } + } +} + +struct NFCSheet: View { + var title = "Hold near reader" + var message = "Tap to confirm sign-in. Your location will be signed and sent." + var actionTitle = "Approve" + let onSubmit: (PairingAuthenticationRequest) async -> Void + + @Environment(\.dismiss) private var dismiss + @StateObject private var reader = NFCIdentifyReader() + @State private var pendingRequest: PairingAuthenticationRequest? + @State private var isSubmitting = false + @State private var pulse = false + + private var isPreview: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" + } + var body: some View { - AppPanel(compactLayout: true, radius: AppLayout.largeCardRadius) { - AppBadge(title: request.kind.title, tone: accent) + VStack(spacing: 24) { + ZStack { + ForEach(0..<3, id: \.self) { index in + Circle() + .stroke(IdP.tint.opacity(0.16), lineWidth: 1.5) + .frame(width: 88 + CGFloat(index * 34), height: 88 + CGFloat(index * 34)) + .scaleEffect(pulse ? 1.08 : 0.92) + .opacity(pulse ? 0.2 : 0.6) + .animation(.easeInOut(duration: 1.4).repeatForever().delay(Double(index) * 0.12), value: pulse) + } - Text(request.title) - .font(.system(size: 30, weight: .bold, design: .rounded)) - .lineLimit(3) + Image(systemName: "wave.3.right") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(IdP.tint) + } + .frame(height: 160) - Text(request.subtitle) - .foregroundStyle(.secondary) + VStack(spacing: 8) { + Text(title) + .font(.title3.weight(.semibold)) - HStack(spacing: 8) { - AppStatusTag(title: request.status.title, tone: accent) - AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + VStack(spacing: 12) { + Button("Cancel") { + dismiss() + } + .buttonStyle(SecondaryActionStyle()) + + Button(primaryTitle) { + guard let pendingRequest else { return } + Task { + isSubmitting = true + await onSubmit(pendingRequest) + isSubmitting = false + dismiss() + } + } + .buttonStyle(PrimaryActionStyle()) + .disabled(pendingRequest == nil || isSubmitting) } } + .padding(24) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + .task { + pulse = true + reader.onAuthenticationRequestDetected = { request in + pendingRequest = request + Haptics.selection() + } + reader.onError = { _ in } + + guard !isPreview else { return } + reader.beginScanning() + } + } + + private var primaryTitle: String { + if isSubmitting { + return "Approving…" + } + return pendingRequest == nil ? "Waiting…" : actionTitle } } @@ -124,7 +301,6 @@ struct OneTimePasscodeSheet: View { let session: AuthSession @Environment(\.dismiss) private var dismiss - @Environment(\.horizontalSizeClass) private var horizontalSizeClass var body: some View { NavigationStack { @@ -132,42 +308,32 @@ struct OneTimePasscodeSheet: View { let code = OneTimePasscodeGenerator.code(for: session.pairingCode, at: context.date) let secondsRemaining = OneTimePasscodeGenerator.renewalCountdown(at: context.date) - AppScrollScreen(compactLayout: compactLayout) { - AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) { - AppBadge(title: "One-time passcode", tone: dashboardGold) + VStack(alignment: .leading, spacing: 18) { + Text("One-time pairing code") + .font(.title3.weight(.semibold)) - Text("OTP") - .font(.system(size: compactLayout ? 32 : 40, weight: .bold, design: .rounded)) + Text("Use this code on the next device you want to pair with your idp.global passport.") + .font(.subheadline) + .foregroundStyle(.secondary) - Text("Share this code only with the site or device asking you to prove that it is really you.") - .font(.subheadline) - .foregroundStyle(.secondary) + Text(code) + .font(.system(size: 42, weight: .bold, design: .rounded).monospacedDigit()) + .tracking(5) + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .background(Color.idpSecondaryGroupedBackground, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) - Text(code) - .font(.system(size: compactLayout ? 42 : 54, weight: .bold, design: .rounded).monospacedDigit()) - .tracking(compactLayout ? 4 : 6) - .frame(maxWidth: .infinity) - .padding(.vertical, compactLayout ? 16 : 20) - .background(AppTheme.mutedFill, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(AppTheme.border, lineWidth: 1) - ) - - HStack(spacing: 8) { - AppStatusTag(title: "Renews in \(secondsRemaining)s", tone: dashboardGold) - AppStatusTag(title: session.originHost, tone: dashboardAccent) - } - - Divider() - - AppKeyValue(label: "Client", value: session.deviceName) - AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened)) + HStack { + StatusPill(title: "Renews in \(secondsRemaining)s", color: IdP.tint) + StatusPill(title: session.originHost, color: .secondary) } + + Spacer() } + .padding(24) } - .navigationTitle("OTP") - .inlineNavigationTitleOnIOS() + .navigationTitle("Pair Device") + .idpInlineNavigationTitle() .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { @@ -177,12 +343,170 @@ struct OneTimePasscodeSheet: View { } } } +} - private var compactLayout: Bool { - #if os(iOS) - horizontalSizeClass == .compact - #else - false - #endif +struct MenuBarPopover: View { + @ObservedObject var model: AppViewModel + @State private var notificationsPaused = false + @State private var isPairingCodePresented = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + header + + if let request = model.pendingRequests.first { + RequestHeroCard(request: request, handle: model.profile?.handle ?? "@you") + } else { + EmptyPaneView( + title: "Inbox clear", + message: "New sign-in requests will appear here.", + systemImage: "shield" + ) + .approvalCard() + } + + if model.pendingRequests.count > 1 { + VStack(alignment: .leading, spacing: 6) { + Text("Queued") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + ForEach(model.pendingRequests.dropFirst().prefix(3)) { request in + ApprovalRow(request: request, handle: model.profile?.handle ?? "@you", compact: true) + } + } + } + + Divider() + + VStack(spacing: 8) { + Button { + model.selectedSection = .inbox + } label: { + MenuRowLabel(title: "Open inbox", systemImage: "tray.full") + } + .buttonStyle(.plain) + .keyboardShortcut("o", modifiers: .command) + + Button { + isPairingCodePresented = true + } label: { + MenuRowLabel(title: "Pair new device", systemImage: "plus.viewfinder") + } + .buttonStyle(.plain) + .keyboardShortcut("n", modifiers: .command) + + Button { + notificationsPaused.toggle() + Haptics.selection() + } label: { + MenuRowLabel(title: notificationsPaused ? "Resume notifications" : "Pause notifications", systemImage: notificationsPaused ? "bell.badge" : "bell.slash") + } + .buttonStyle(.plain) + + Button { + model.selectedSection = .settings + } label: { + MenuRowLabel(title: "Preferences", systemImage: "gearshape") + } + .buttonStyle(.plain) + .keyboardShortcut(",", modifiers: .command) + } + } + .padding(20) + .sheet(isPresented: $isPairingCodePresented) { + if let session = model.session { + OneTimePasscodeSheet(session: session) + } + } + } + + private var header: some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "shield.lefthalf.filled") + .font(.title2) + .foregroundStyle(IdP.tint) + + VStack(alignment: .leading, spacing: 2) { + Text("idp.global") + .font(.headline) + + StatusPill(title: "Connected", color: .green) + } + + Spacer() + } + } +} + +private struct MenuRowLabel: View { + let title: String + let systemImage: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: systemImage) + .frame(width: 18) + .foregroundStyle(IdP.tint) + Text(title) + Spacer() + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } +} + +#Preview("Approval Detail Light") { + NavigationStack { + ApprovalDetailPreviewHost() + } +} + +#Preview("Approval Detail Dark") { + NavigationStack { + ApprovalDetailPreviewHost() + } + .preferredColorScheme(.dark) +} + +#Preview("NFC Sheet Light") { + NFCSheet { _ in } +} + +#Preview("NFC Sheet Dark") { + NFCSheet { _ in } + .preferredColorScheme(.dark) +} + +#Preview("Request Hero Card Light") { + RequestHeroCard(request: PreviewFixtures.requests[0], handle: PreviewFixtures.profile.handle) + .padding() +} + +#Preview("Request Hero Card Dark") { + RequestHeroCard(request: PreviewFixtures.requests[0], handle: PreviewFixtures.profile.handle) + .padding() + .preferredColorScheme(.dark) +} + +#if os(macOS) +#Preview("Menu Bar Popover Light") { + MenuBarPopover(model: PreviewFixtures.model()) + .frame(width: 420) +} + +#Preview("Menu Bar Popover Dark") { + MenuBarPopover(model: PreviewFixtures.model()) + .frame(width: 420) + .preferredColorScheme(.dark) +} +#endif + +@MainActor +private struct ApprovalDetailPreviewHost: View { + @State private var model = PreviewFixtures.model() + + var body: some View { + ApprovalDetailView(model: model, requestID: PreviewFixtures.requests.first?.id) } } diff --git a/swift/WatchApp/Design/ButtonStyles.swift b/swift/WatchApp/Design/ButtonStyles.swift new file mode 100644 index 0000000..da17ec0 --- /dev/null +++ b/swift/WatchApp/Design/ButtonStyles.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct PrimaryActionStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .fill(IdP.tint) + ) + .foregroundStyle(.white) + .opacity(configuration.isPressed ? 0.92 : 1) + } +} + +struct SecondaryActionStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .fill(Color.idpSecondaryGroupedBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .stroke(Color.idpSeparator, lineWidth: 1) + ) + .foregroundStyle(.white) + .opacity(configuration.isPressed ? 0.92 : 1) + } +} + +struct DestructiveStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .fill(Color.red.opacity(0.18)) + ) + .overlay( + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .stroke(Color.red.opacity(0.25), lineWidth: 1) + ) + .foregroundStyle(.red) + .opacity(configuration.isPressed ? 0.92 : 1) + } +} diff --git a/swift/WatchApp/Design/Cards.swift b/swift/WatchApp/Design/Cards.swift new file mode 100644 index 0000000..20a480a --- /dev/null +++ b/swift/WatchApp/Design/Cards.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct ApprovalCardModifier: ViewModifier { + var highlighted = false + + func body(content: Content) -> some View { + content + .padding(14) + .background( + RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) + .fill(Color.idpSecondaryGroupedBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) + .stroke(highlighted ? IdP.tint.opacity(0.75) : Color.idpSeparator, lineWidth: highlighted ? 1.5 : 1) + ) + } +} + +extension View { + func approvalCard(highlighted: Bool = false) -> some View { + modifier(ApprovalCardModifier(highlighted: highlighted)) + } +} + +struct RequestHeroCard: View { + let request: ApprovalRequest + let handle: String + + var body: some View { + HStack(spacing: 12) { + MonogramAvatar(title: request.source, size: 40) + + VStack(alignment: .leading, spacing: 4) { + Text(request.source) + .font(.headline) + .foregroundStyle(.white) + Text(handle) + .font(.footnote) + .foregroundStyle(IdP.tint) + } + } + .approvalCard(highlighted: true) + } +} + +struct MonogramAvatar: View { + let title: String + var size: CGFloat = 22 + + private var monogram: String { + String(title.trimmingCharacters(in: .whitespacesAndNewlines).first ?? "I").uppercased() + } + + var body: some View { + RoundedRectangle(cornerRadius: size * 0.34, style: .continuous) + .fill(IdP.tint.opacity(0.2)) + .frame(width: size, height: size) + .overlay { + Text(monogram) + .font(.system(size: size * 0.48, weight: .semibold, design: .rounded)) + .foregroundStyle(IdP.tint) + } + } +} diff --git a/swift/WatchApp/Design/GlassChrome.swift b/swift/WatchApp/Design/GlassChrome.swift new file mode 100644 index 0000000..e704dda --- /dev/null +++ b/swift/WatchApp/Design/GlassChrome.swift @@ -0,0 +1,8 @@ +import SwiftUI + +public extension View { + @ViewBuilder + func idpGlassChrome() -> some View { + self.background(.thinMaterial) + } +} diff --git a/swift/WatchApp/Design/Haptics.swift b/swift/WatchApp/Design/Haptics.swift new file mode 100644 index 0000000..1da6bed --- /dev/null +++ b/swift/WatchApp/Design/Haptics.swift @@ -0,0 +1,16 @@ +import SwiftUI +import WatchKit + +enum Haptics { + static func success() { + WKInterfaceDevice.current().play(.success) + } + + static func warning() { + WKInterfaceDevice.current().play(.failure) + } + + static func selection() { + WKInterfaceDevice.current().play(.click) + } +} diff --git a/swift/WatchApp/Design/IdPTokens.swift b/swift/WatchApp/Design/IdPTokens.swift new file mode 100644 index 0000000..e7c4f51 --- /dev/null +++ b/swift/WatchApp/Design/IdPTokens.swift @@ -0,0 +1,15 @@ +import SwiftUI + +public enum IdP { + public static let tint = Color("IdPTint") + public static let cardRadius: CGFloat = 20 + public static let controlRadius: CGFloat = 14 + public static let badgeRadius: CGFloat = 8 +} + +extension Color { + static var idpGroupedBackground: Color { .black } + static var idpSecondaryGroupedBackground: Color { Color.white.opacity(0.08) } + static var idpTertiaryFill: Color { Color.white.opacity(0.12) } + static var idpSeparator: Color { Color.white.opacity(0.14) } +} diff --git a/swift/WatchApp/Design/StatusDot.swift b/swift/WatchApp/Design/StatusDot.swift new file mode 100644 index 0000000..a344e44 --- /dev/null +++ b/swift/WatchApp/Design/StatusDot.swift @@ -0,0 +1,11 @@ +import SwiftUI + +struct StatusDot: View { + let color: Color + + var body: some View { + Circle() + .fill(color) + .frame(width: 8, height: 8) + } +} diff --git a/swift/WatchApp/Features/WatchRootView.swift b/swift/WatchApp/Features/WatchRootView.swift index 55a69bd..ee44642 100644 --- a/swift/WatchApp/Features/WatchRootView.swift +++ b/swift/WatchApp/Features/WatchRootView.swift @@ -1,11 +1,8 @@ -import Foundation import SwiftUI -private let watchAccent = AppTheme.accent -private let watchGold = AppTheme.warmAccent - struct WatchRootView: View { @ObservedObject var model: AppViewModel + @State private var showsQueue = false var body: some View { NavigationStack { @@ -13,12 +10,21 @@ struct WatchRootView: View { if model.session == nil { WatchPairingView(model: model) } else { - WatchDashboardView(model: model) + if showsQueue { + WatchQueueView(model: model) + } else { + WatchHomeView(model: model) + } } } - .navigationBarTitleDisplayMode(.inline) + .background(Color.idpGroupedBackground.ignoresSafeArea()) + } + .tint(IdP.tint) + .onOpenURL { url in + if (url.host ?? url.lastPathComponent).lowercased() == "inbox" { + showsQueue = true + } } - .tint(watchAccent) } } @@ -26,395 +32,148 @@ private struct WatchPairingView: View { @ObservedObject var model: AppViewModel var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 10) { - AppBadge(title: "Preview passport", tone: watchAccent) + VStack(alignment: .leading, spacing: 12) { + Text("Link your watch") + .font(.headline) + .foregroundStyle(.white) - Text("Prove identity from your wrist") - .font(.title3.weight(.semibold)) - .foregroundStyle(.white) + Text("Use the shared demo passport so approvals stay visible on your wrist.") + .font(.footnote) + .foregroundStyle(.white.opacity(0.72)) - Text("Link this watch to the preview passport so identity checks and alerts stay visible on your wrist.") - .font(.footnote) - .foregroundStyle(.white.opacity(0.72)) - - HStack(spacing: 8) { - AppStatusTag(title: "Wrist-ready", tone: watchAccent) - AppStatusTag(title: "Proof focus", tone: watchGold) - } + Button("Use demo payload") { + Task { + await model.signInWithSuggestedPayload() } - .watchCard() - - if model.isBootstrapping { - HStack(spacing: 8) { - ProgressView() - .tint(watchAccent) - Text("Preparing preview passport...") - .font(.footnote) - .foregroundStyle(.white.opacity(0.72)) - } - .frame(maxWidth: .infinity, alignment: .leading) - .watchCard() - } - - Button { - Task { - await model.signInWithSuggestedPayload() - } - } label: { - if model.isAuthenticating { - ProgressView() - .frame(maxWidth: .infinity) - } else { - Label("Link Preview Passport", systemImage: "applewatch") - .frame(maxWidth: .infinity) - } - } - .buttonStyle(.borderedProminent) - .tint(watchAccent) - .disabled(model.isBootstrapping || model.suggestedPairingPayload.isEmpty || model.isAuthenticating) - - VStack(alignment: .leading, spacing: 10) { - Text("What this watch does") - .font(.headline) - .foregroundStyle(.white) - - WatchSetupFeatureRow( - systemImage: "checkmark.shield", - title: "Review identity checks", - subtitle: "See pending proof prompts quickly." - ) - - WatchSetupFeatureRow( - systemImage: "bell.badge", - title: "Surface important alerts", - subtitle: "Keep passport activity visible at a glance." - ) - - WatchSetupFeatureRow( - systemImage: "iphone.radiowaves.left.and.right", - title: "Stay in sync with the phone preview", - subtitle: "Use the same mocked passport context." - ) - } - .watchCard() } - .padding(.horizontal, 8) - .padding(.top, 6) - .padding(.bottom, 20) + .buttonStyle(PrimaryActionStyle()) } - .background(Color.black.ignoresSafeArea()) - .navigationTitle("Link Watch") + .approvalCard(highlighted: true) + .padding(10) + .navigationTitle("idp.global") } } -private struct WatchSetupFeatureRow: View { - let systemImage: String - let title: String - let subtitle: String +private struct WatchHomeView: View { + @ObservedObject var model: AppViewModel var body: some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: systemImage) - .font(.footnote.weight(.semibold)) - .foregroundStyle(watchAccent) - .frame(width: 18, height: 18) + Group { + if let request = model.pendingRequests.first { + WatchApprovalView(model: model, requestID: request.id) + } else { + WatchQueueView(model: model) + } + } + } +} + +struct WatchApprovalView: 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) { + MonogramAvatar(title: request.watchAppDisplayName, size: 42) + + Text("Sign in as \(model.profile?.handle ?? "@you")?") + .font(.headline) + .foregroundStyle(.white) + + Text(request.watchLocationSummary) + .font(.footnote) + .foregroundStyle(.white.opacity(0.72)) + + HStack(spacing: 8) { + Button { + Task { + Haptics.warning() + await model.reject(request) + } + } label: { + Image(systemName: "xmark") + .frame(maxWidth: .infinity) + } + .buttonStyle(SecondaryActionStyle()) + .frame(maxWidth: .infinity) + + WatchHoldToApproveButton(isBusy: model.activeRequestID == request.id) { + await model.approve(request) + } + .frame(maxWidth: .infinity) + } + } + .approvalCard(highlighted: true) + .padding(10) + } + .navigationTitle("Approve") + .toolbar { + ToolbarItem(placement: .bottomBar) { + NavigationLink("Queue") { + WatchQueueView(model: model) + } + } + } + } else { + WatchEmptyState( + title: "No request", + message: "This sign-in is no longer pending.", + systemImage: "checkmark.circle" + ) + } + } + } +} + +private struct WatchQueueView: View { + @ObservedObject var model: AppViewModel + + var body: some View { + List { + if model.requests.isEmpty { + WatchEmptyState( + title: "All clear", + message: "New sign-in requests will appear on your watch here.", + systemImage: "shield" + ) + } else { + ForEach(model.requests) { request in + NavigationLink { + WatchRequestDetailView(model: model, requestID: request.id) + } label: { + WatchQueueRow(request: request) + } + } + } + } + .navigationTitle("Queue") + } +} + +private struct WatchQueueRow: View { + let request: ApprovalRequest + + var body: some View { + HStack(spacing: 8) { + MonogramAvatar(title: request.watchAppDisplayName, size: 22) VStack(alignment: .leading, spacing: 2) { - Text(title) + Text(request.watchAppDisplayName) .font(.footnote.weight(.semibold)) .foregroundStyle(.white) - - Text(subtitle) + Text(request.createdAt, style: .time) .font(.caption2) .foregroundStyle(.white.opacity(0.68)) } } - } -} - -private extension View { - func watchCard() -> some View { - padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(Color.white.opacity(0.10), lineWidth: 1) - ) - } -} - -private struct WatchDashboardView: View { - @ObservedObject var model: AppViewModel - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - WatchPassportCard(model: model) - .watchCard() - - WatchSectionHeader( - title: "Pending", - detail: model.pendingRequests.isEmpty ? nil : "\(model.pendingRequests.count)" - ) - - if model.pendingRequests.isEmpty { - VStack(alignment: .leading, spacing: 10) { - Text("No checks waiting.") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.white) - - Text("New identity checks will appear here when a site or device asks you to prove it is really you.") - .font(.caption2) - .foregroundStyle(.white.opacity(0.68)) - - Button("Seed Identity Check") { - Task { - await model.simulateIncomingRequest() - } - } - .buttonStyle(.bordered) - .tint(watchAccent) - } - .watchCard() - } else { - ForEach(model.pendingRequests) { request in - NavigationLink { - WatchRequestDetailView(model: model, requestID: request.id) - } label: { - WatchRequestRow(request: request) - .watchCard() - } - .buttonStyle(.plain) - } - } - - WatchSectionHeader(title: "Activity") - - if model.notifications.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("No recent alerts.") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.white) - - Text("Passport activity and security events will show up here.") - .font(.caption2) - .foregroundStyle(.white.opacity(0.68)) - } - .watchCard() - } else { - ForEach(model.notifications.prefix(3)) { notification in - NavigationLink { - WatchNotificationDetailView(model: model, notificationID: notification.id) - } label: { - WatchNotificationRow(notification: notification) - .watchCard() - } - .buttonStyle(.plain) - } - } - - WatchSectionHeader(title: "Actions") - - VStack(alignment: .leading, spacing: 10) { - Button("Refresh") { - Task { - await model.refreshDashboard() - } - } - .buttonStyle(.bordered) - .tint(watchAccent) - .disabled(model.isRefreshing) - - Button("Send Test Alert") { - Task { - await model.sendTestNotification() - } - } - .buttonStyle(.bordered) - - if model.notificationPermission == .unknown || model.notificationPermission == .denied { - Button("Enable Alerts") { - Task { - await model.requestNotificationAccess() - } - } - .buttonStyle(.bordered) - } - - Button("Sign Out", role: .destructive) { - model.signOut() - } - .buttonStyle(.bordered) - } - .watchCard() - - if let profile = model.profile { - WatchSectionHeader(title: "Identity") - - VStack(alignment: .leading, spacing: 8) { - Text(profile.handle) - .font(.footnote.weight(.semibold)) - .foregroundStyle(.white) - - Text(profile.organization) - .font(.caption2) - .foregroundStyle(.white.opacity(0.68)) - - Text("Notifications: \(model.notificationPermission.title)") - .font(.caption2) - .foregroundStyle(.white.opacity(0.68)) - } - .watchCard() - } - } - .padding(.horizontal, 8) - .padding(.top, 12) - .padding(.bottom, 20) - } - .background(Color.black.ignoresSafeArea()) - .navigationTitle("Passport") - .refreshable { - await model.refreshDashboard() - } - } -} - -private struct WatchSectionHeader: View { - let title: String - var detail: String? = nil - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(title) - .font(.headline) - .foregroundStyle(.white) - - if let detail, !detail.isEmpty { - Text(detail) - .font(.caption2.weight(.semibold)) - .foregroundStyle(.white.opacity(0.58)) - } - } - .padding(.horizontal, 2) - } -} - -private struct WatchPassportCard: View { - @ObservedObject var model: AppViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - AppBadge(title: "Passport active", tone: watchAccent) - - VStack(alignment: .leading, spacing: 2) { - Text(model.profile?.name ?? "Preview Session") - .font(.headline) - .foregroundStyle(.white) - Text(model.pairedDeviceSummary) - .font(.footnote) - .foregroundStyle(.white.opacity(0.72)) - if let session = model.session { - Text("Via \(session.pairingTransport.title)") - .font(.caption2) - .foregroundStyle(.white.opacity(0.58)) - } - } - - HStack(spacing: 8) { - WatchMetricPill(title: "Pending", value: "\(model.pendingRequests.count)", accent: watchAccent) - WatchMetricPill(title: "Unread", value: "\(model.unreadNotificationCount)", accent: watchGold) - } - } - .padding(.vertical, 6) - } -} - -private struct WatchMetricPill: View { - let title: String - let value: String - let accent: Color - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(value) - .font(.headline.monospacedDigit()) - .foregroundStyle(.white) - Text(title) - .font(.caption2) - .foregroundStyle(.white.opacity(0.68)) - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - } -} - -private struct WatchRequestRow: View { - let request: ApprovalRequest - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .top, spacing: 6) { - Text(request.title) - .font(.headline) - .lineLimit(2) - .foregroundStyle(.white) - - Spacer(minLength: 6) - - Image(systemName: request.risk == .elevated ? "exclamationmark.shield.fill" : "checkmark.shield.fill") - .foregroundStyle(request.risk == .elevated ? .orange : watchAccent) - } - - Text(request.source) - .font(.footnote) - .foregroundStyle(.white.opacity(0.72)) - - HStack(spacing: 8) { - AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? watchAccent : .orange) - AppStatusTag(title: request.status.title, tone: request.status == .pending ? .orange : watchAccent) - } - - Text(request.createdAt.watchRelativeString) - .font(.caption2) - .foregroundStyle(.white.opacity(0.58)) - } - } -} - -private struct WatchNotificationRow: View { - let notification: AppNotification - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .top, spacing: 6) { - Text(notification.title) - .font(.headline) - .lineLimit(2) - .foregroundStyle(.white) - - Spacer(minLength: 6) - - if notification.isUnread { - Circle() - .fill(watchAccent) - .frame(width: 8, height: 8) - } - } - - Text(notification.message) - .font(.footnote) - .foregroundStyle(.white.opacity(0.72)) - .lineLimit(2) - - Text(notification.sentAt.watchRelativeString) - .font(.caption2) - .foregroundStyle(.white.opacity(0.58)) - } + .padding(.vertical, 2) } } @@ -431,159 +190,202 @@ private struct WatchRequestDetailView: View { if let request { ScrollView { VStack(alignment: .leading, spacing: 12) { - detailHeader( - title: request.title, - subtitle: request.source, - badge: request.status.title - ) + RequestHeroCard(request: request, handle: model.profile?.handle ?? "@you") - Text(request.subtitle) + Text(request.watchTrustExplanation) .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) - } - } - } + .foregroundStyle(.white.opacity(0.72)) if request.status == .pending { - if model.activeRequestID == request.id { - ProgressView("Updating proof...") - } else { - Button("Verify") { - Task { - await model.approve(request) - } - } - .buttonStyle(.borderedProminent) + WatchHoldToApproveButton(isBusy: model.activeRequestID == request.id) { + await model.approve(request) + } - Button("Decline", role: .destructive) { - Task { - await model.reject(request) - } + Button("Deny") { + Task { + Haptics.warning() + await model.reject(request) } } + .buttonStyle(SecondaryActionStyle()) } } - .padding(.horizontal, 8) - .padding(.bottom, 20) + .padding(10) } } else { - Text("This request is no longer available.") - .foregroundStyle(.secondary) + WatchEmptyState( + title: "No request", + message: "This sign-in is no longer pending.", + systemImage: "shield" + ) } } - .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()) - } + .navigationTitle("Details") } } -private struct WatchNotificationDetailView: View { - @ObservedObject var model: AppViewModel - let notificationID: AppNotification.ID +private struct WatchHoldToApproveButton: View { + var isBusy = false + let action: () async -> Void - private var notification: AppNotification? { - model.notifications.first(where: { $0.id == notificationID }) - } + @State private var progress: CGFloat = 0 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) - } + ZStack { + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .fill(isBusy ? Color.white.opacity(0.18) : IdP.tint) - Text(notification.message) - .font(.footnote) - .foregroundStyle(.secondary) + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 1) - 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)) + Text(isBusy ? "Working…" : "Approve") + .font(.headline) + .foregroundStyle(.white) + .padding(.vertical, 12) - 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) + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .trim(from: 0, to: progress) + .stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .padding(2) + } + .contentShape(RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)) + .onLongPressGesture(minimumDuration: 0.6, maximumDistance: 18, pressing: updateProgress) { + guard !isBusy else { return } + Task { + Haptics.success() + await action() + progress = 0 } } - .navigationTitle("Activity") + .watchPrimaryActionGesture() + .accessibilityAddTraits(.isButton) + .accessibilityHint("Press and hold to approve the sign-in request.") + } + + private func updateProgress(_ isPressing: Bool) { + guard !isBusy else { return } + withAnimation(.linear(duration: isPressing ? 0.6 : 0.15)) { + progress = isPressing ? 1 : 0 + } } } -private extension Date { - var watchRelativeString: String { - WatchFormatters.relative.localizedString(for: self, relativeTo: .now) +private extension View { + @ViewBuilder + func watchPrimaryActionGesture() -> some View { + if #available(watchOS 11.0, *) { + self.handGestureShortcut(.primaryAction) + } else { + self + } } } -private enum WatchFormatters { - static let relative: RelativeDateTimeFormatter = { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter - }() +private extension ApprovalRequest { + var watchAppDisplayName: String { + source.replacingOccurrences(of: "auth.", with: "") + } + + var watchTrustExplanation: String { + risk == .elevated + ? "This request needs a higher-assurance proof before it can continue." + : "This request matches a familiar device and sign-in pattern." + } + + var watchLocationSummary: String { + "Berlin, DE" + } +} + +private struct WatchEmptyState: View { + let title: String + let message: String + let systemImage: String + + var body: some View { + ContentUnavailableView { + Label(title, systemImage: systemImage) + } description: { + Text(message) + } + } +} + +#Preview("Watch Approval Light") { + WatchApprovalPreviewHost() +} + +#Preview("Watch Approval Dark") { + WatchApprovalPreviewHost() + .preferredColorScheme(.dark) +} + +@MainActor +private struct WatchApprovalPreviewHost: View { + @State private var model = WatchPreviewFixtures.model() + + var body: some View { + WatchApprovalView(model: model, requestID: WatchPreviewFixtures.requests[0].id) + } +} + +private enum WatchPreviewFixtures { + static let profile = MemberProfile( + name: "Jurgen Meyer", + handle: "@jurgen", + organization: "idp.global", + deviceCount: 3, + recoverySummary: "Recovery kit healthy." + ) + + static let session = AuthSession( + deviceName: "Apple Watch", + originHost: "github.com", + pairedAt: .now.addingTimeInterval(-60 * 45), + tokenPreview: "berlin", + pairingCode: "idp.global://pair?token=swiftapp-demo-berlin&origin=github.com&device=Apple%20Watch", + pairingTransport: .preview + ) + + static let requests: [ApprovalRequest] = [ + ApprovalRequest( + title: "GitHub sign-in", + subtitle: "A sign-in request is waiting on your iPhone.", + source: "github.com", + createdAt: .now.addingTimeInterval(-60 * 2), + kind: .signIn, + risk: .routine, + scopes: ["profile", "email"], + status: .pending + ) + ] + + @MainActor + static func model() -> AppViewModel { + let model = AppViewModel( + service: MockIDPService.shared, + notificationCoordinator: WatchPreviewCoordinator(), + appStateStore: WatchPreviewStore(), + launchArguments: [] + ) + model.session = session + model.profile = profile + model.requests = requests + model.notifications = [] + model.notificationPermission = .allowed + return model + } +} + +private struct WatchPreviewCoordinator: NotificationCoordinating { + func authorizationStatus() async -> NotificationPermissionState { .allowed } + func requestAuthorization() async throws -> NotificationPermissionState { .allowed } + func scheduleTestNotification(title: String, body: String) async throws {} +} + +private struct WatchPreviewStore: AppStateStoring { + func load() -> PersistedAppState? { nil } + func save(_ state: PersistedAppState) {} + func clear() {} } diff --git a/swift/WatchApp/Widgets/IDPGlobalWidgetsBundle.swift b/swift/WatchApp/Widgets/IDPGlobalWidgetsBundle.swift new file mode 100644 index 0000000..6fe280f --- /dev/null +++ b/swift/WatchApp/Widgets/IDPGlobalWidgetsBundle.swift @@ -0,0 +1,292 @@ +import SwiftUI +import WidgetKit + +#if os(iOS) +import ActivityKit +import AppIntents +import UIKit +#endif + +struct ApprovalWidgetEntry: TimelineEntry { + let date: Date + let pendingCount: Int + let topPayload: ApprovalActivityPayload? +} + +struct ApprovalWidgetProvider: TimelineProvider { + func placeholder(in context: Context) -> ApprovalWidgetEntry { + ApprovalWidgetEntry( + date: .now, + pendingCount: 2, + topPayload: ApprovalActivityPayload( + requestID: UUID().uuidString, + title: "github.com wants to sign in", + appName: "github.com", + source: "github.com", + handle: "@jurgen", + location: "Berlin, DE", + createdAt: .now + ) + ) + } + + func getSnapshot(in context: Context, completion: @escaping (ApprovalWidgetEntry) -> Void) { + completion(makeEntry()) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = makeEntry() + completion(Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(60)))) + } + + private func makeEntry() -> ApprovalWidgetEntry { + let state = UserDefaultsAppStateStore().load() + let pendingRequests = (state?.requests ?? []) + .filter { $0.status == .pending } + .sorted { $0.createdAt > $1.createdAt } + let handle = state?.profile.handle ?? "@you" + + return ApprovalWidgetEntry( + date: .now, + pendingCount: pendingRequests.count, + topPayload: pendingRequests.first?.activityPayload(handle: handle) + ) + } +} + +@main +struct IDPGlobalWidgetsBundle: WidgetBundle { + var body: some Widget { + #if os(iOS) + ApprovalLiveActivityWidget() + #endif + + #if os(watchOS) + ApprovalAccessoryRectangularWidget() + ApprovalAccessoryCircularWidget() + ApprovalAccessoryCornerWidget() + #endif + } +} + +#if os(iOS) +struct ApproveLiveActivityIntent: LiveActivityIntent { + static var title: LocalizedStringResource = "Approve" + static var openAppWhenRun = false + + @Parameter(title: "Request ID") + var requestID: String + + init() {} + + init(requestID: String) { + self.requestID = requestID + } + + func perform() async throws -> some IntentResult { + guard let id = UUID(uuidString: requestID) else { + return .result() + } + + _ = try? await MockIDPService.shared.approveRequest(id: id) + await ApprovalLiveActivityActionHandler.complete(requestID: requestID, outcome: "Approved") + WidgetCenter.shared.reloadAllTimelines() + return .result() + } +} + +struct DenyLiveActivityIntent: LiveActivityIntent { + static var title: LocalizedStringResource = "Deny" + static var openAppWhenRun = false + + @Parameter(title: "Request ID") + var requestID: String + + init() {} + + init(requestID: String) { + self.requestID = requestID + } + + func perform() async throws -> some IntentResult { + guard let id = UUID(uuidString: requestID) else { + return .result() + } + + _ = try? await MockIDPService.shared.rejectRequest(id: id) + await ApprovalLiveActivityActionHandler.complete(requestID: requestID, outcome: "Denied") + WidgetCenter.shared.reloadAllTimelines() + return .result() + } +} + +private enum ApprovalLiveActivityActionHandler { + static func complete(requestID: String, outcome: String) async { + guard let activity = Activity.activities.first(where: { $0.attributes.requestID == requestID }) else { + return + } + + let state = ApprovalActivityAttributes.ContentState( + requestID: requestID, + title: outcome, + appName: activity.content.state.appName, + source: activity.content.state.source, + handle: activity.content.state.handle, + location: activity.content.state.location + ) + + await activity.end(ActivityContent(state: state, staleDate: .now), dismissalPolicy: .immediate) + } +} + +struct ApprovalLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: ApprovalActivityAttributes.self) { context in + VStack(alignment: .leading, spacing: 12) { + Text(context.state.title) + .font(.headline) + + Text("Sign in as \(Text(context.state.handle).foregroundStyle(.purple))") + .font(.subheadline) + + Text(context.state.location) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 10) { + Button(intent: DenyLiveActivityIntent(requestID: context.state.requestID)) { + Text("Deny") + } + .buttonStyle(.bordered) + + Button(intent: ApproveLiveActivityIntent(requestID: context.state.requestID)) { + Text("Approve") + } + .buttonStyle(.borderedProminent) + .tint(.purple) + } + } + .padding(16) + .activityBackgroundTint(Color(uiColor: .systemBackground)) + .activitySystemActionForegroundColor(.purple) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Image(systemName: "shield.lefthalf.filled") + .foregroundStyle(.purple) + } + DynamicIslandExpandedRegion(.trailing) { + MonogramBubble(title: context.state.appName) + } + DynamicIslandExpandedRegion(.center) { + VStack(alignment: .leading, spacing: 4) { + Text(context.state.title) + .font(.headline) + Text(context.state.handle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + DynamicIslandExpandedRegion(.bottom) { + HStack(spacing: 10) { + Button(intent: DenyLiveActivityIntent(requestID: context.state.requestID)) { + Text("Deny") + } + .buttonStyle(.bordered) + + Button(intent: ApproveLiveActivityIntent(requestID: context.state.requestID)) { + Text("Approve") + } + .buttonStyle(.borderedProminent) + .tint(.purple) + } + } + } compactLeading: { + Image(systemName: "shield.lefthalf.filled") + .foregroundStyle(.purple) + } compactTrailing: { + MonogramBubble(title: context.state.appName) + } minimal: { + Image(systemName: "shield") + .foregroundStyle(.purple) + } + } + } +} + +private struct MonogramBubble: View { + let title: String + + private var letter: String { + String(title.trimmingCharacters(in: .whitespacesAndNewlines).first ?? "I").uppercased() + } + + var body: some View { + ZStack { + Circle() + .fill(Color.purple.opacity(0.18)) + Text(letter) + .font(.caption.weight(.bold)) + .foregroundStyle(.purple) + } + .frame(width: 24, height: 24) + } +} +#endif + +#if os(watchOS) +struct ApprovalAccessoryRectangularWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "IDPGlobalAccessoryRectangular", provider: ApprovalWidgetProvider()) { entry in + VStack(alignment: .leading, spacing: 3) { + Label("idp.global", systemImage: "shield.lefthalf.filled") + .font(.caption2) + + Text("\(entry.pendingCount) requests") + .font(.headline) + + Text(entry.topPayload?.appName ?? "Inbox") + .font(.caption2) + .foregroundStyle(.secondary) + } + .widgetURL(URL(string: "idpglobal://inbox")) + } + .configurationDisplayName("Approval Queue") + .description("Pending sign-in requests.") + .supportedFamilies([.accessoryRectangular]) + } +} + +struct ApprovalAccessoryCircularWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "IDPGlobalAccessoryCircular", provider: ApprovalWidgetProvider()) { entry in + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 2) { + Image(systemName: "shield.lefthalf.filled") + Text("\(entry.pendingCount)") + .font(.caption2.weight(.bold)) + } + } + .widgetURL(URL(string: "idpglobal://inbox")) + } + .configurationDisplayName("Approval Count") + .supportedFamilies([.accessoryCircular]) + } +} + +struct ApprovalAccessoryCornerWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "IDPGlobalAccessoryCorner", provider: ApprovalWidgetProvider()) { entry in + Text("\(entry.pendingCount)") + .widgetCurvesContent() + .widgetLabel { + Image(systemName: "shield.lefthalf.filled") + } + .widgetURL(URL(string: "idpglobal://inbox")) + } + .configurationDisplayName("Approval Corner") + .supportedFamilies([.accessoryCorner]) + } +} +#endif diff --git a/swift/WatchApp/Widgets/Info.plist b/swift/WatchApp/Widgets/Info.plist new file mode 100644 index 0000000..c53e2da --- /dev/null +++ b/swift/WatchApp/Widgets/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + idp.global Widgets + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).IDPGlobalWidgetsBundle + + +