Overhaul native approval UX and add widget surfaces
Some checks failed
CI / test (push) Has been cancelled

Bring the SwiftUI app in line with the Apple-native mock and keep pending approvals actionable from Live Activities and watch complications.
This commit is contained in:
2026-04-19 16:29:13 +02:00
parent a6939453f8
commit 61a0cc1f7d
63 changed files with 3496 additions and 1769 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1006 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 776 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

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

View File

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

View File

@@ -2,6 +2,16 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.activitykit</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.global.idp.app</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
<string>NDEF</string>

View File

@@ -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 = "<group>"; };
B2000000000000000000001A /* IDPGlobalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IDPGlobalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
B2000000000000000000001B /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
B2000000000000000000001C /* IdPTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdPTokens.swift; sourceTree = "<group>"; };
B2000000000000000000001D /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = "<group>"; };
B2000000000000000000001E /* GlassChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassChrome.swift; sourceTree = "<group>"; };
B2000000000000000000001F /* Cards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cards.swift; sourceTree = "<group>"; };
B20000000000000000000020 /* StatusDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusDot.swift; sourceTree = "<group>"; };
B20000000000000000000021 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = "<group>"; };
B20000000000000000000022 /* IdPTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdPTokens.swift; sourceTree = "<group>"; };
B20000000000000000000023 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = "<group>"; };
B20000000000000000000024 /* GlassChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassChrome.swift; sourceTree = "<group>"; };
B20000000000000000000025 /* Cards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cards.swift; sourceTree = "<group>"; };
B20000000000000000000026 /* StatusDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusDot.swift; sourceTree = "<group>"; };
B20000000000000000000027 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = "<group>"; };
B20000000000000000000028 /* ApprovalActivityController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApprovalActivityController.swift; sourceTree = "<group>"; };
B20000000000000000000029 /* ApprovalActivityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApprovalActivityModels.swift; sourceTree = "<group>"; };
B2000000000000000000002A /* IDPGlobalWidgetsBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDPGlobalWidgetsBundle.swift; sourceTree = "<group>"; };
B2000000000000000000002B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>";
@@ -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 = "<group>";
@@ -273,6 +355,41 @@
path = Tests;
sourceTree = "<group>";
};
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 = "<group>";
};
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 = "<group>";
};
B40000000000000000000012 /* Widgets */ = {
isa = PBXGroup;
children = (
B2000000000000000000002A /* IDPGlobalWidgetsBundle.swift */,
B2000000000000000000002B /* Info.plist */,
);
path = Widgets;
sourceTree = "<group>";
};
/* 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 */;

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.global.idp.app</string>
</array>
</dict>
</plist>

View File

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

View File

@@ -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<ApprovalActivityAttributes>.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<ApprovalActivityAttributes>.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<ApprovalActivityAttributes>.activities {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
}
#else
enum ApprovalActivityController {
static func sync(requests: [ApprovalRequest], profile: MemberProfile?) async {}
static func endAll() async {}
}
#endif

View File

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

View File

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

View File

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

View File

@@ -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<Content: View>: 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)
)
}
}

View File

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

View File

@@ -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<String>, isPresented: Binding<Bool>) -> 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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
import SwiftUI
public extension View {
@ViewBuilder
func idpGlassChrome() -> some View {
self.background(.thinMaterial)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import SwiftUI
struct StatusDot: View {
let color: Color
var body: some View {
Circle()
.fill(color)
.frame(width: 8, height: 8)
}
}

View File

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

View File

@@ -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<ApprovalWidgetEntry>) -> 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<ApprovalActivityAttributes>.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

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>idp.global Widgets</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).IDPGlobalWidgetsBundle</string>
</dict>
</dict>
</plist>