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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <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> <key>com.apple.developer.nfc.readersession.formats</key>
<array> <array>
<string>NDEF</string> <string>NDEF</string>

View File

@@ -40,6 +40,29 @@
B1000000000000000000001F /* OneTimePasscodeGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */; }; B1000000000000000000001F /* OneTimePasscodeGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000018 /* OneTimePasscodeGeneratorTests.swift */; };
B10000000000000000000020 /* AppViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000019 /* AppViewModelTests.swift */; }; B10000000000000000000020 /* AppViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000019 /* AppViewModelTests.swift */; };
B10000000000000000000021 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B2000000000000000000001B /* XCTest.framework */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -57,6 +80,13 @@
remoteGlobalIDString = B50000000000000000000001; remoteGlobalIDString = B50000000000000000000001;
remoteInfo = IDPGlobal; remoteInfo = IDPGlobal;
}; };
B90000000000000000000005 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = B60000000000000000000001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = B50000000000000000000004;
remoteInfo = IDPGlobalWidgets;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@@ -71,6 +101,28 @@
name = "Embed Watch Content"; name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0; 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 */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -101,6 +153,23 @@
B20000000000000000000019 /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = "<group>"; }; 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; }; 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; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -126,6 +195,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
B3000000000000000000000C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -163,6 +239,7 @@
children = ( children = (
B20000000000000000000010 /* AppTheme.swift */, B20000000000000000000010 /* AppTheme.swift */,
B2000000000000000000000F /* AppComponents.swift */, B2000000000000000000000F /* AppComponents.swift */,
B20000000000000000000028 /* ApprovalActivityController.swift */,
B20000000000000000000001 /* IDPGlobalApp.swift */, B20000000000000000000001 /* IDPGlobalApp.swift */,
B20000000000000000000002 /* AppViewModel.swift */, B20000000000000000000002 /* AppViewModel.swift */,
); );
@@ -172,6 +249,7 @@
B40000000000000000000005 /* Core */ = { B40000000000000000000005 /* Core */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
B40000000000000000000010 /* Design */,
B40000000000000000000006 /* Models */, B40000000000000000000006 /* Models */,
B40000000000000000000007 /* Services */, B40000000000000000000007 /* Services */,
); );
@@ -182,6 +260,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
B20000000000000000000003 /* AppModels.swift */, B20000000000000000000003 /* AppModels.swift */,
B20000000000000000000029 /* ApprovalActivityModels.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -212,6 +291,7 @@
children = ( children = (
B20000000000000000000009 /* IDPGlobal.app */, B20000000000000000000009 /* IDPGlobal.app */,
B2000000000000000000000A /* IDPGlobalWatch.app */, B2000000000000000000000A /* IDPGlobalWatch.app */,
B2000000000000000000002C /* IDPGlobalWidgets.appex */,
B2000000000000000000001A /* IDPGlobalTests.xctest */, B2000000000000000000001A /* IDPGlobalTests.xctest */,
); );
name = Products; name = Products;
@@ -242,7 +322,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
B4000000000000000000000D /* App */, B4000000000000000000000D /* App */,
B40000000000000000000011 /* Design */,
B4000000000000000000000E /* Features */, B4000000000000000000000E /* Features */,
B40000000000000000000012 /* Widgets */,
); );
path = WatchApp; path = WatchApp;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -273,6 +355,41 @@
path = Tests; path = Tests;
sourceTree = "<group>"; 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 */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -284,11 +401,13 @@
B30000000000000000000001 /* Frameworks */, B30000000000000000000001 /* Frameworks */,
B30000000000000000000003 /* Resources */, B30000000000000000000003 /* Resources */,
B30000000000000000000004 /* Embed Watch Content */, B30000000000000000000004 /* Embed Watch Content */,
B3000000000000000000000B /* Embed App Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
B90000000000000000000002 /* PBXTargetDependency */, B90000000000000000000002 /* PBXTargetDependency */,
B90000000000000000000006 /* PBXTargetDependency */,
); );
name = IDPGlobal; name = IDPGlobal;
productName = IDPGlobal; productName = IDPGlobal;
@@ -302,10 +421,12 @@
B30000000000000000000007 /* Sources */, B30000000000000000000007 /* Sources */,
B30000000000000000000005 /* Frameworks */, B30000000000000000000005 /* Frameworks */,
B30000000000000000000006 /* Resources */, B30000000000000000000006 /* Resources */,
B3000000000000000000000F /* Embed Widget Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
B90000000000000000000007 /* PBXTargetDependency */,
); );
name = IDPGlobalWatch; name = IDPGlobalWatch;
productName = IDPGlobalWatch; productName = IDPGlobalWatch;
@@ -330,6 +451,23 @@
productReference = B2000000000000000000001A /* IDPGlobalTests.xctest */; productReference = B2000000000000000000001A /* IDPGlobalTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test"; 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 */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -350,6 +488,9 @@
CreatedOnToolsVersion = 26.0; CreatedOnToolsVersion = 26.0;
TestTargetID = B50000000000000000000001; TestTargetID = B50000000000000000000001;
}; };
B50000000000000000000004 = {
CreatedOnToolsVersion = 26.0;
};
}; };
}; };
buildConfigurationList = B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */; buildConfigurationList = B70000000000000000000001 /* Build configuration list for PBXProject "IDPGlobal" */;
@@ -368,6 +509,7 @@
B50000000000000000000001 /* IDPGlobal */, B50000000000000000000001 /* IDPGlobal */,
B50000000000000000000002 /* IDPGlobalWatch */, B50000000000000000000002 /* IDPGlobalWatch */,
B50000000000000000000003 /* IDPGlobalTests */, B50000000000000000000003 /* IDPGlobalTests */,
B50000000000000000000004 /* IDPGlobalWidgets */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -395,6 +537,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
B3000000000000000000000D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -402,15 +551,22 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
B1000000000000000000002E /* ApprovalActivityController.swift in Sources */,
B10000000000000000000030 /* ApprovalActivityModels.swift in Sources */,
B10000000000000000000015 /* AppStateStore.swift in Sources */, B10000000000000000000015 /* AppStateStore.swift in Sources */,
B10000000000000000000012 /* AppComponents.swift in Sources */, B10000000000000000000012 /* AppComponents.swift in Sources */,
B10000000000000000000014 /* AppTheme.swift in Sources */, B10000000000000000000014 /* AppTheme.swift in Sources */,
B10000000000000000000002 /* AppViewModel.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 */, B10000000000000000000019 /* HomeCards.swift in Sources */,
B10000000000000000000018 /* HomePanels.swift in Sources */, B10000000000000000000018 /* HomePanels.swift in Sources */,
B10000000000000000000008 /* HomeRootView.swift in Sources */, B10000000000000000000008 /* HomeRootView.swift in Sources */,
B1000000000000000000001A /* HomeSheets.swift in Sources */, B1000000000000000000001A /* HomeSheets.swift in Sources */,
B10000000000000000000001 /* IDPGlobalApp.swift in Sources */, B10000000000000000000001 /* IDPGlobalApp.swift in Sources */,
B10000000000000000000022 /* IdPTokens.swift in Sources */,
B10000000000000000000006 /* LoginRootView.swift in Sources */, B10000000000000000000006 /* LoginRootView.swift in Sources */,
B10000000000000000000004 /* MockIDPService.swift in Sources */, B10000000000000000000004 /* MockIDPService.swift in Sources */,
B10000000000000000000010 /* NFCPairingView.swift in Sources */, B10000000000000000000010 /* NFCPairingView.swift in Sources */,
@@ -419,6 +575,7 @@
B10000000000000000000003 /* AppModels.swift in Sources */, B10000000000000000000003 /* AppModels.swift in Sources */,
B10000000000000000000017 /* PairingPayloadParser.swift in Sources */, B10000000000000000000017 /* PairingPayloadParser.swift in Sources */,
B10000000000000000000007 /* QRScannerView.swift in Sources */, B10000000000000000000007 /* QRScannerView.swift in Sources */,
B10000000000000000000026 /* StatusDot.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -426,15 +583,22 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
B1000000000000000000002F /* ApprovalActivityController.swift in Sources */,
B1000000000000000000001C /* AppStateStore.swift in Sources */, B1000000000000000000001C /* AppStateStore.swift in Sources */,
B10000000000000000000013 /* AppComponents.swift in Sources */, B10000000000000000000013 /* AppComponents.swift in Sources */,
B1000000000000000000001B /* AppTheme.swift in Sources */, B1000000000000000000001B /* AppTheme.swift in Sources */,
B10000000000000000000009 /* AppViewModel.swift in Sources */, B10000000000000000000009 /* AppViewModel.swift in Sources */,
B1000000000000000000000A /* AppModels.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 */, B1000000000000000000000D /* IDPGlobalWatchApp.swift in Sources */,
B10000000000000000000028 /* IdPTokens.swift in Sources */,
B1000000000000000000000B /* MockIDPService.swift in Sources */, B1000000000000000000000B /* MockIDPService.swift in Sources */,
B1000000000000000000000C /* NotificationCoordinator.swift in Sources */, B1000000000000000000000C /* NotificationCoordinator.swift in Sources */,
B1000000000000000000001D /* PairingPayloadParser.swift in Sources */, B1000000000000000000001D /* PairingPayloadParser.swift in Sources */,
B1000000000000000000002C /* StatusDot.swift in Sources */,
B1000000000000000000000E /* WatchRootView.swift in Sources */, B1000000000000000000000E /* WatchRootView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -449,6 +613,19 @@
); );
runOnlyForDeploymentPostprocessing = 0; 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 */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
@@ -463,6 +640,18 @@
target = B50000000000000000000001 /* IDPGlobal */; target = B50000000000000000000001 /* IDPGlobal */;
targetProxy = B90000000000000000000003 /* PBXContainerItemProxy */; 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 */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@@ -485,8 +674,8 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 26.0;
SDKROOT = auto; SDKROOT = auto;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
}; };
@@ -512,8 +701,8 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 26.0;
SDKROOT = auto; SDKROOT = auto;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
@@ -533,11 +722,23 @@
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleURLTypes = (
{
CFBundleTypeRole = Editor;
CFBundleURLName = idpglobal;
CFBundleURLSchemes = (
idpglobal,
);
},
);
INFOPLIST_KEY_CFBundleDisplayName = "idp.global"; INFOPLIST_KEY_CFBundleDisplayName = "idp.global";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSCameraUsageDescription = "Scan pairing QR codes from the idp.global web portal."; 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_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_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_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -554,6 +755,8 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
}; };
name = Debug; name = Debug;
}; };
@@ -568,11 +771,23 @@
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleURLTypes = (
{
CFBundleTypeRole = Editor;
CFBundleURLName = idpglobal;
CFBundleURLSchemes = (
idpglobal,
);
},
);
INFOPLIST_KEY_CFBundleDisplayName = "idp.global"; INFOPLIST_KEY_CFBundleDisplayName = "idp.global";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
INFOPLIST_KEY_NSCameraUsageDescription = "Scan pairing QR codes from the idp.global web portal."; 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_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_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_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -588,6 +803,8 @@
SWIFT_OBSERVATION_ENABLED = YES; SWIFT_OBSERVATION_ENABLED = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MACOSX_DEPLOYMENT_TARGET = 26.0;
}; };
name = Release; name = Release;
}; };
@@ -595,11 +812,21 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CODE_SIGN_ENTITLEMENTS = IDPGlobalShared.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleURLTypes = (
{
CFBundleTypeRole = Editor;
CFBundleURLName = idpglobal;
CFBundleURLSchemes = (
idpglobal,
);
},
);
INFOPLIST_KEY_CFBundleDisplayName = "idp.global Watch"; INFOPLIST_KEY_CFBundleDisplayName = "idp.global Watch";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = global.idp.app; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = global.idp.app;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -616,7 +843,7 @@
SWIFT_OBSERVATION_ENABLED = YES; SWIFT_OBSERVATION_ENABLED = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4; TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0; WATCHOS_DEPLOYMENT_TARGET = 11.0;
}; };
name = Debug; name = Debug;
}; };
@@ -624,11 +851,21 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CODE_SIGN_ENTITLEMENTS = IDPGlobalShared.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleURLTypes = (
{
CFBundleTypeRole = Editor;
CFBundleURLName = idpglobal;
CFBundleURLSchemes = (
idpglobal,
);
},
);
INFOPLIST_KEY_CFBundleDisplayName = "idp.global Watch"; INFOPLIST_KEY_CFBundleDisplayName = "idp.global Watch";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = global.idp.app; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = global.idp.app;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -645,18 +882,23 @@
SWIFT_OBSERVATION_ENABLED = YES; SWIFT_OBSERVATION_ENABLED = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4; TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0; WATCHOS_DEPLOYMENT_TARGET = 11.0;
}; };
name = Release; name = Release;
}; };
B80000000000000000000007 /* Debug */ = { B80000000000000000000007 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal.debug.dylib";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; 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; MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.tests; PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.tests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -664,7 +906,6 @@
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = macosx; SUPPORTED_PLATFORMS = macosx;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal";
TEST_TARGET_NAME = IDPGlobal; TEST_TARGET_NAME = IDPGlobal;
}; };
name = Debug; name = Debug;
@@ -672,11 +913,16 @@
B80000000000000000000008 /* Release */ = { B80000000000000000000008 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal.debug.dylib";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; 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; MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.tests; PRODUCT_BUNDLE_IDENTIFIER = global.idp.app.tests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -684,11 +930,68 @@
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = macosx; SUPPORTED_PLATFORMS = macosx;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IDPGlobal.app/Contents/MacOS/IDPGlobal";
TEST_TARGET_NAME = IDPGlobal; TEST_TARGET_NAME = IDPGlobal;
}; };
name = Release; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -728,6 +1031,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
B70000000000000000000005 /* Build configuration list for PBXNativeTarget "IDPGlobalWidgets" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B80000000000000000000009 /* Debug */,
B8000000000000000000000A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = B60000000000000000000001 /* Project object */; 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 Combine
import Foundation import Foundation
import SwiftUI
#if canImport(WidgetKit)
import WidgetKit
#endif
@MainActor @MainActor
final class AppViewModel: ObservableObject { final class AppViewModel: ObservableObject {
@@ -10,12 +15,13 @@ final class AppViewModel: ObservableObject {
@Published var requests: [ApprovalRequest] = [] @Published var requests: [ApprovalRequest] = []
@Published var notifications: [AppNotification] = [] @Published var notifications: [AppNotification] = []
@Published var notificationPermission: NotificationPermissionState = .unknown @Published var notificationPermission: NotificationPermissionState = .unknown
@Published var selectedSection: AppSection = .overview @Published var selectedSection: AppSection = .inbox
@Published var isBootstrapping = false @Published var isBootstrapping = false
@Published var isAuthenticating = false @Published var isAuthenticating = false
@Published var isIdentifying = false @Published var isIdentifying = false
@Published var isRefreshing = false @Published var isRefreshing = false
@Published var isNotificationCenterPresented = false @Published var isNotificationCenterPresented = false
@Published var isShowingPairingSuccess = false
@Published var activeRequestID: ApprovalRequest.ID? @Published var activeRequestID: ApprovalRequest.ID?
@Published var isScannerPresented = false @Published var isScannerPresented = false
@Published var errorMessage: String? @Published var errorMessage: String?
@@ -32,14 +38,25 @@ final class AppViewModel: ObservableObject {
} }
let rawValue = String(argument.dropFirst("--mock-section=".count)) 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( init(
service: IDPServicing = MockIDPService(), service: IDPServicing = MockIDPService.shared,
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(), notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
appStateStore: AppStateStoring = UserDefaultsAppStateStore(), appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
launchArguments: [String] = ProcessInfo.processInfo.arguments launchArguments: [String] = ProcessInfo.processInfo.arguments
@@ -148,15 +165,28 @@ final class AppViewModel: ObservableObject {
isAuthenticating = true isAuthenticating = true
defer { isAuthenticating = false } defer { isAuthenticating = false }
let wasSignedOut = session == nil
do { do {
let result = try await service.signIn(with: normalizedRequest) let result = try await service.signIn(with: normalizedRequest)
session = result.session session = result.session
apply(snapshot: result.snapshot) apply(snapshot: result.snapshot)
persistCurrentState() persistCurrentState()
notificationPermission = await notificationCoordinator.authorizationStatus() notificationPermission = await notificationCoordinator.authorizationStatus()
selectedSection = .overview selectedSection = .inbox
errorMessage = nil errorMessage = nil
isScannerPresented = false 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 { } catch let error as AppError {
errorMessage = error.errorDescription errorMessage = error.errorDescription
} catch { } catch {
@@ -250,7 +280,7 @@ final class AppViewModel: ObservableObject {
let snapshot = try await service.simulateIncomingRequest() let snapshot = try await service.simulateIncomingRequest()
apply(snapshot: snapshot) apply(snapshot: snapshot)
persistCurrentState() persistCurrentState()
selectedSection = .requests selectedSection = .inbox
errorMessage = nil errorMessage = nil
} catch { } catch {
errorMessage = "Unable to create a mock identity check right now." errorMessage = "Unable to create a mock identity check right now."
@@ -296,9 +326,36 @@ final class AppViewModel: ObservableObject {
profile = nil profile = nil
requests = [] requests = []
notifications = [] notifications = []
selectedSection = .overview selectedSection = .inbox
manualPairingPayload = suggestedPairingPayload manualPairingPayload = suggestedPairingPayload
isShowingPairingSuccess = false
errorMessage = nil 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 { private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
@@ -352,8 +409,20 @@ final class AppViewModel: ObservableObject {
} }
private func apply(snapshot: DashboardSnapshot) { private func apply(snapshot: DashboardSnapshot) {
profile = snapshot.profile withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt } self.profile = snapshot.profile
notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt } 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() @StateObject private var model = AppViewModel()
var body: some Scene { var body: some Scene {
WindowGroup { #if os(macOS)
RootView(model: model) MenuBarExtra("idp.global", systemImage: "shield.lefthalf.filled") {
.tint(AppTheme.accent) 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 { .task {
await model.bootstrap() await model.bootstrap()
} }
@@ -19,8 +37,6 @@ struct IDPGlobalApp: App {
Text(model.errorMessage ?? "") Text(model.errorMessage ?? "")
} }
} }
#if os(macOS)
.defaultSize(width: 1380, height: 920)
#endif #endif
} }
@@ -36,19 +52,47 @@ struct IDPGlobalApp: App {
} }
} }
private struct RootView: View { private struct RootSceneContent: View {
@ObservedObject var model: AppViewModel @ObservedObject var model: AppViewModel
var body: some View { var body: some View {
Group { Group {
if model.session == nil { if model.session == nil {
LoginRootView(model: model) LoginRootView(model: model)
} else if model.isShowingPairingSuccess {
PairingSuccessView()
} else { } else {
#if os(macOS)
MenuBarPopover(model: model)
#else
HomeRootView(model: model) HomeRootView(model: model)
#endif
} }
} }
.background { .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 import Foundation
enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable { enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable {
case overview case inbox
case requests case notifications
case activity case devices
case account case identity
case settings
var id: String { rawValue } var id: String { rawValue }
var title: String { var title: String {
switch self { switch self {
case .overview: "Passport" case .inbox: "Inbox"
case .requests: "Requests" case .notifications: "Notifications"
case .activity: "Activity" case .devices: "Devices"
case .account: "Account" case .identity: "Identity"
case .settings: "Settings"
} }
} }
var systemImage: String { var systemImage: String {
switch self { switch self {
case .overview: "person.crop.square.fill" case .inbox: "tray.full.fill"
case .requests: "checklist.checked" case .notifications: "bell.badge.fill"
case .activity: "clock.arrow.trianglehead.counterclockwise.rotate.90" case .devices: "desktopcomputer"
case .account: "person.crop.circle.fill" case .identity: "person.crop.rectangle.stack.fill"
case .settings: "gearshape.fill"
} }
} }
} }
@@ -301,8 +304,8 @@ enum ApprovalStatus: String, Hashable, Codable {
var title: String { var title: String {
switch self { switch self {
case .pending: "Pending" case .pending: "Pending"
case .approved: "Verified" case .approved: "Approved"
case .rejected: "Declined" 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 import Foundation
enum SharedDefaults {
static let appGroupIdentifier = "group.global.idp.app"
static var userDefaults: UserDefaults {
UserDefaults(suiteName: appGroupIdentifier) ?? .standard
}
}
struct PersistedAppState: Codable, Equatable { struct PersistedAppState: Codable, Equatable {
let session: AuthSession let session: AuthSession
let profile: MemberProfile let profile: MemberProfile
@@ -19,7 +27,7 @@ final class UserDefaultsAppStateStore: AppStateStoring {
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() 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.defaults = defaults
self.storageKey = storageKey self.storageKey = storageKey
} }

View File

@@ -12,6 +12,8 @@ protocol IDPServicing {
} }
actor MockIDPService: IDPServicing { actor MockIDPService: IDPServicing {
static let shared = MockIDPService()
private let profile = MemberProfile( private let profile = MemberProfile(
name: "Phil Kunz", name: "Phil Kunz",
handle: "phil@idp.global", handle: "phil@idp.global",
@@ -20,15 +22,24 @@ actor MockIDPService: IDPServicing {
recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified." recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified."
) )
private let appStateStore: AppStateStoring
private var requests: [ApprovalRequest] = [] private var requests: [ApprovalRequest] = []
private var notifications: [AppNotification] = [] private var notifications: [AppNotification] = []
init() { init(appStateStore: AppStateStoring = UserDefaultsAppStateStore()) {
requests = Self.seedRequests() self.appStateStore = appStateStore
notifications = Self.seedNotifications()
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 { func bootstrap() async throws -> BootstrapContext {
restoreSharedState()
try await Task.sleep(for: .milliseconds(120)) try await Task.sleep(for: .milliseconds(120))
return BootstrapContext( return BootstrapContext(
suggestedPairingPayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP" suggestedPairingPayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
@@ -36,6 +47,7 @@ actor MockIDPService: IDPServicing {
} }
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult { func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
restoreSharedState()
try await Task.sleep(for: .milliseconds(260)) try await Task.sleep(for: .milliseconds(260))
try validateSignedGPSPosition(in: request) try validateSignedGPSPosition(in: request)
@@ -51,6 +63,8 @@ actor MockIDPService: IDPServicing {
at: 0 at: 0
) )
persistSharedStateIfAvailable()
return SignInResult( return SignInResult(
session: session, session: session,
snapshot: snapshot() snapshot: snapshot()
@@ -58,6 +72,7 @@ actor MockIDPService: IDPServicing {
} }
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot { func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(180)) try await Task.sleep(for: .milliseconds(180))
try validateSignedGPSPosition(in: request) try validateSignedGPSPosition(in: request)
@@ -73,15 +88,19 @@ actor MockIDPService: IDPServicing {
at: 0 at: 0
) )
persistSharedStateIfAvailable()
return snapshot() return snapshot()
} }
func refreshDashboard() async throws -> DashboardSnapshot { func refreshDashboard() async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(180)) try await Task.sleep(for: .milliseconds(180))
return snapshot() return snapshot()
} }
func approveRequest(id: UUID) async throws -> DashboardSnapshot { func approveRequest(id: UUID) async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(150)) try await Task.sleep(for: .milliseconds(150))
guard let index = requests.firstIndex(where: { $0.id == id }) else { guard let index = requests.firstIndex(where: { $0.id == id }) else {
@@ -100,10 +119,13 @@ actor MockIDPService: IDPServicing {
at: 0 at: 0
) )
persistSharedStateIfAvailable()
return snapshot() return snapshot()
} }
func rejectRequest(id: UUID) async throws -> DashboardSnapshot { func rejectRequest(id: UUID) async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(150)) try await Task.sleep(for: .milliseconds(150))
guard let index = requests.firstIndex(where: { $0.id == id }) else { guard let index = requests.firstIndex(where: { $0.id == id }) else {
@@ -122,10 +144,13 @@ actor MockIDPService: IDPServicing {
at: 0 at: 0
) )
persistSharedStateIfAvailable()
return snapshot() return snapshot()
} }
func simulateIncomingRequest() async throws -> DashboardSnapshot { func simulateIncomingRequest() async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(120)) try await Task.sleep(for: .milliseconds(120))
let syntheticRequest = ApprovalRequest( let syntheticRequest = ApprovalRequest(
@@ -151,10 +176,13 @@ actor MockIDPService: IDPServicing {
at: 0 at: 0
) )
persistSharedStateIfAvailable()
return snapshot() return snapshot()
} }
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot { func markNotificationRead(id: UUID) async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(80)) try await Task.sleep(for: .milliseconds(80))
guard let index = notifications.firstIndex(where: { $0.id == id }) else { guard let index = notifications.firstIndex(where: { $0.id == id }) else {
@@ -162,6 +190,7 @@ actor MockIDPService: IDPServicing {
} }
notifications[index].isUnread = false notifications[index].isUnread = false
persistSharedStateIfAvailable()
return snapshot() return snapshot()
} }
@@ -227,6 +256,30 @@ actor MockIDPService: IDPServicing {
return "An identity proof was completed for \(context.deviceName) on \(context.originHost)." 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] { private static func seedRequests() -> [ApprovalRequest] {
[ [
ApprovalRequest( ApprovalRequest(

View File

@@ -1,193 +1,126 @@
import SwiftUI import SwiftUI
private let loginAccent = AppTheme.accent
struct LoginRootView: View { struct LoginRootView: View {
@ObservedObject var model: AppViewModel @ObservedObject var model: AppViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
#if !os(macOS)
@State private var isNFCSheetPresented = false
#endif
var body: some View { var body: some View {
AppScrollScreen(compactLayout: compactLayout) { #if os(macOS)
LoginHeroPanel(model: model, compactLayout: compactLayout) MacPairingView(model: model)
PairingConsoleCard(model: model, compactLayout: compactLayout) #else
} NavigationStack {
.sheet(isPresented: $model.isScannerPresented) { ZStack(alignment: .top) {
QRScannerSheet( LiveQRScannerView { payload in
seededPayload: model.suggestedPairingPayload,
title: "Scan linking QR",
description: "Use the camera to scan the QR code from the web flow that activates this device as your passport.",
navigationTitle: "Scan Linking QR",
onCodeScanned: { payload in
model.manualPairingPayload = payload model.manualPairingPayload = payload
Task { Task {
await model.signIn(with: payload, transport: .qr) await model.signIn(with: payload, transport: .qr)
} }
} }
) .ignoresSafeArea()
}
}
private var compactLayout: Bool { VStack(spacing: 0) {
#if os(iOS) IdPGlassCapsule {
horizontalSizeClass == .compact VStack(alignment: .leading, spacing: 6) {
#else Text("Scan a pairing code")
false .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 #endif
} }
} }
private struct LoginHeroPanel: View { #if os(macOS)
private struct MacPairingView: View {
@ObservedObject var model: AppViewModel @ObservedObject var model: AppViewModel
let compactLayout: Bool
var body: some View { var body: some View {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) { VStack(alignment: .leading, spacing: 18) {
AppBadge(title: "Secure passport setup", tone: loginAccent) 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") VStack(alignment: .leading, spacing: 2) {
.font(.system(size: compactLayout ? 28 : 36, weight: .bold, design: .rounded)) Text("Set up idp.global")
.lineLimit(3) .font(.headline)
Text("Use the demo payload or paste a pairing link.")
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)
.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...")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
Text("NFC, QR, and OTP proof methods become available after this passport is active.") TextEditor(text: $model.manualPairingPayload)
.font(.footnote) .font(.footnote.monospaced())
.foregroundStyle(.secondary) .scrollContentBackground(.hidden)
.frame(minHeight: 140)
.padding(10)
.background(Color.idpSecondaryGroupedBackground, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
if compactLayout { VStack(spacing: 10) {
VStack(spacing: 12) { Button("Use demo payload") {
primaryButtons Task {
secondaryButtons await model.signInWithSuggestedPayload()
}
} else {
VStack(spacing: 12) {
HStack(spacing: 12) {
primaryButtons
} }
secondaryButtons
} }
} .buttonStyle(PrimaryActionStyle())
}
}
@ViewBuilder Button("Link with payload") {
private var primaryButtons: some View { Task {
Button { await model.signInWithManualPayload()
model.isScannerPresented = true }
} label: { }
Label("Scan QR", systemImage: "qrcode.viewfinder") .buttonStyle(SecondaryActionStyle())
.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
} }
} }
} .padding(20)
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)
} }
} }
#endif

View File

@@ -40,7 +40,7 @@ final class NFCIdentifyReader: NSObject, ObservableObject, @preconcurrency NFCND
helperText = Self.scanningHelperText helperText = Self.scanningHelperText
let session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: true) 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 self.session = session
session.begin() session.begin()
} }
@@ -161,11 +161,11 @@ final class NFCIdentifyReader: NSObject, ObservableObject, @preconcurrency NFCND
return "NFC identify could not be completed on this device." 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 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 it is identified." 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 for NFC identify." private static let signingLocationHelperText = "Tag detected. Capturing and signing the current GPS position before approval is sent."
private static let unavailableHelperText = "NFC identify is unavailable on this device." private static let unavailableHelperText = "NFC approval is unavailable on this device."
private static let unavailableErrorMessage = "Tap to identify requires supported iPhone hardware with NFC enabled." 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 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." 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 AVFoundation
import Combine import Combine
import SwiftUI import SwiftUI
#if os(iOS) #if os(iOS)
import UIKit import UIKit
#elseif os(macOS) #elseif os(macOS)
@@ -15,7 +16,6 @@ struct QRScannerSheet: View {
let onCodeScanned: (String) -> Void let onCodeScanned: (String) -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var manualFallback = "" @State private var manualFallback = ""
init( init(
@@ -34,33 +34,59 @@ struct QRScannerSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
AppScrollScreen(compactLayout: compactLayout) { ZStack(alignment: .top) {
AppSectionCard(title: title, compactLayout: compactLayout) { LiveQRScannerView { payload in
Text(description) onCodeScanned(payload)
.font(.subheadline) dismiss()
.foregroundStyle(.secondary)
LiveQRScannerView(onCodeScanned: onCodeScanned)
.frame(minHeight: 340)
} }
.ignoresSafeArea()
AppSectionCard(title: "Manual fallback", compactLayout: compactLayout) { VStack(spacing: 12) {
AppTextEditorField(text: $manualFallback, minHeight: 120) IdPGlassCapsule {
VStack(alignment: .leading, spacing: 6) {
if compactLayout { Text(title)
VStack(spacing: 12) { .font(.headline)
useFallbackButton Text(description)
useSeededButton .font(.subheadline)
.foregroundStyle(.secondary)
} }
} else { .frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 12) { }
useFallbackButton
useSeededButton 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) .navigationTitle(navigationTitleText)
.applyInlineNavigationTitleDisplayMode()
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Close") { Button("Close") {
@@ -73,85 +99,74 @@ struct QRScannerSheet: View {
} }
} }
} }
}
private var compactLayout: Bool { private extension View {
#if os(iOS) @ViewBuilder
horizontalSizeClass == .compact func applyInlineNavigationTitleDisplayMode() -> some View {
#if os(macOS)
self
#else #else
false navigationBarTitleDisplayMode(.inline)
#endif #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 let onCodeScanned: (String) -> Void
@StateObject private var scanner = QRScannerViewModel() @StateObject private var scanner = QRScannerViewModel()
@State private var didDetectCode = false
var body: some View { var body: some View {
ZStack(alignment: .bottomLeading) { ZStack(alignment: .bottomLeading) {
Group { Group {
if scanner.isPreviewAvailable { if scanner.isPreviewAvailable {
ScannerPreview(session: scanner.captureSession) ScannerPreview(session: scanner.captureSession)
.clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous))
} else { } else {
RoundedRectangle(cornerRadius: 30, style: .continuous) Color.black
.fill(Color.black.opacity(0.86))
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 10) {
Image(systemName: "video.slash.fill") Image(systemName: "video.slash.fill")
.font(.system(size: 28, weight: .semibold)) .font(.system(size: 24, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
Text("Live camera preview unavailable") Text("Camera preview unavailable")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
Text(scanner.statusMessage) Text(scanner.statusMessage)
.foregroundStyle(.white.opacity(0.78)) .foregroundStyle(.white.opacity(0.78))
} }
.padding(24) .padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} }
} }
RoundedRectangle(cornerRadius: 30, style: .continuous) Color.black.opacity(0.18)
.strokeBorder(.white.opacity(0.22), lineWidth: 1.5) .ignoresSafeArea()
VStack(alignment: .leading, spacing: 8) { ScanFrameOverlay(detected: didDetectCode)
Text("Camera Scanner") .padding(40)
VStack(alignment: .leading, spacing: 6) {
Text("Point the camera at the pairing QR")
.font(.headline.weight(.semibold)) .font(.headline.weight(.semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
Text(scanner.statusMessage) Text(scanner.statusMessage)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.84)) .foregroundStyle(.white.opacity(0.84))
} }
.padding(22) .padding(22)
ScanFrameOverlay()
.padding(40)
} }
.task { .task {
scanner.onCodeScanned = { payload in 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() await scanner.start()
} }
@@ -162,19 +177,46 @@ private struct LiveQRScannerView: View {
} }
private struct ScanFrameOverlay: View { private struct ScanFrameOverlay: View {
let detected: Bool
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
let size = min(geometry.size.width, geometry.size.height) * 0.5 let size = min(geometry.size.width, geometry.size.height) * 0.5
let inset = detected ? 18.0 : 0
RoundedRectangle(cornerRadius: 28, style: .continuous) ZStack {
.strokeBorder(.white.opacity(0.82), style: StrokeStyle(lineWidth: 3, dash: [10, 8])) CornerTick(rotation: .degrees(0))
.frame(width: size, height: size) .frame(width: size, height: size)
.position(x: geometry.size.width / 2, y: geometry.size.height / 2) 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) .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 { private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate {
@Published var isPreviewAvailable = false @Published var isPreviewAvailable = false
@Published var statusMessage = "Point the camera at the QR code from the idp.global web portal." @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) #if os(iOS) && targetEnvironment(simulator)
await MainActor.run { await MainActor.run {
isPreviewAvailable = false 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 #endif
#if !(os(iOS) && targetEnvironment(simulator)) #if !(os(iOS) && targetEnvironment(simulator))
@@ -207,7 +248,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
await MainActor.run { await MainActor.run {
self.statusMessage = granted self.statusMessage = granted
? "Point the camera at the QR code from the idp.global web portal." ? "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 } guard granted else { return }
await configureIfNeeded() await configureIfNeeded()
@@ -215,7 +256,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
case .denied, .restricted: case .denied, .restricted:
await MainActor.run { await MainActor.run {
isPreviewAvailable = false isPreviewAvailable = false
statusMessage = "Camera access is unavailable. Use the fallback payload below." statusMessage = "Camera access is unavailable. Use the manual fallback instead."
} }
@unknown default: @unknown default:
await MainActor.run { await MainActor.run {
@@ -285,7 +326,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
guard let device = AVCaptureDevice.default(for: .video) else { guard let device = AVCaptureDevice.default(for: .video) else {
DispatchQueue.main.async { DispatchQueue.main.async {
self.isPreviewAvailable = false 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 return
} }
@@ -293,7 +334,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
guard let input = try? AVCaptureDeviceInput(device: device) else { guard let input = try? AVCaptureDeviceInput(device: device) else {
DispatchQueue.main.async { DispatchQueue.main.async {
self.isPreviewAvailable = false 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 return
} }
@@ -301,7 +342,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
guard self.captureSession.canAddInput(input) else { guard self.captureSession.canAddInput(input) else {
DispatchQueue.main.async { DispatchQueue.main.async {
self.isPreviewAvailable = false 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 return
} }
@@ -313,7 +354,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
self.captureSession.removeInput(input) self.captureSession.removeInput(input)
DispatchQueue.main.async { DispatchQueue.main.async {
self.isPreviewAvailable = false 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 return
} }
@@ -327,7 +368,7 @@ private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMet
self.captureSession.removeInput(input) self.captureSession.removeInput(input)
DispatchQueue.main.async { DispatchQueue.main.async {
self.isPreviewAvailable = false 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 return
} }

View File

@@ -1,330 +1,346 @@
import SwiftUI import SwiftUI
struct RequestList: View { extension ApprovalRequest {
let requests: [ApprovalRequest] var appDisplayName: String {
let compactLayout: Bool source
let activeRequestID: ApprovalRequest.ID? .replacingOccurrences(of: "auth.", with: "")
let onApprove: ((ApprovalRequest) -> Void)? .replacingOccurrences(of: ".idp.global", with: ".idp.global")
let onReject: ((ApprovalRequest) -> Void)? }
let onOpenRequest: (ApprovalRequest) -> Void
var body: some View { var inboxTitle: String {
VStack(spacing: 14) { "Sign in to \(appDisplayName)"
ForEach(requests) { request in }
RequestCard(
request: request, var locationSummary: String {
compactLayout: compactLayout, "Berlin, DE"
isBusy: activeRequestID == request.id, }
onApprove: onApprove == nil ? nil : { onApprove?(request) },
onReject: onReject == nil ? nil : { onReject?(request) }, var deviceSummary: String {
onOpenRequest: { onOpenRequest(request) } 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 { extension AppNotification {
let request: ApprovalRequest fileprivate var presentationStatus: NotificationPresentationStatus {
let compactLayout: Bool let haystack = "\(title) \(message)".lowercased()
let isBusy: Bool if haystack.contains("declined") || haystack.contains("denied") {
let onApprove: (() -> Void)? return .denied
let onReject: (() -> Void)? }
let onOpenRequest: () -> Void 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 { var body: some View {
VStack(alignment: .leading, spacing: 12) { Text(title)
HStack(alignment: .top, spacing: 12) { .font(.caption.weight(.semibold))
Image(systemName: request.kind.systemImage) .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) .font(.headline)
.foregroundStyle(requestAccent) .lineLimit(2)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) { Text(notification.message)
Text(request.title) .font(.subheadline)
.font(.headline)
.multilineTextAlignment(.leading)
Text(request.source)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
AppStatusTag(title: request.status.title, tone: statusTone)
}
Text(request.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
HStack(spacing: 8) {
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange)
Text(request.scopeSummary)
.font(.footnote)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
Text(request.createdAt, style: .relative)
.font(.footnote)
.foregroundStyle(.secondary)
}
if !request.scopes.isEmpty {
Text("Proof details: \(request.scopes.joined(separator: ", "))")
.font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2) .lineLimit(2)
} }
controls Spacer(minLength: 8)
}
.padding(compactLayout ? 18 : 20)
.appSurface(radius: 24)
}
@ViewBuilder StatusPill(title: notification.presentationStatus.title, color: notification.presentationStatus.color)
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
} }
.padding(.vertical, 8)
.accessibilityElement(children: .combine)
} }
} }
struct NotificationList: View { struct NotificationPermissionCard: View {
let notifications: [AppNotification] @ObservedObject var model: AppViewModel
let compactLayout: Bool
let onMarkRead: (AppNotification) -> Void
var body: some View { var body: some View {
VStack(spacing: 14) { VStack(alignment: .leading, spacing: 14) {
ForEach(notifications) { notification in Label("Allow sign-in alerts", systemImage: model.notificationPermission.systemImage)
NotificationCard( .font(.headline)
notification: notification,
compactLayout: compactLayout,
onMarkRead: { onMarkRead(notification) }
)
}
}
}
}
private struct NotificationCard: View { Text(model.notificationPermission.summary)
let notification: AppNotification
let compactLayout: Bool
let onMarkRead: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: notification.kind.systemImage)
.font(.headline)
.foregroundStyle(accentColor)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 4) {
Text(notification.title)
.font(.headline)
HStack(spacing: 8) {
AppStatusTag(title: notification.kind.title, tone: accentColor)
if notification.isUnread {
AppStatusTag(title: "Unread", tone: .orange)
}
}
}
Spacer(minLength: 0)
}
Text(notification.message)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if compactLayout { VStack(spacing: 10) {
VStack(alignment: .leading, spacing: 10) { Button("Enable Notifications") {
timestamp Task {
if notification.isUnread { await model.requestNotificationAccess()
markReadButton
} }
} }
} else { .buttonStyle(PrimaryActionStyle())
HStack {
timestamp Button("Send Test Alert") {
Spacer(minLength: 0) Task {
if notification.isUnread { await model.sendTestNotification()
markReadButton
} }
} }
.buttonStyle(SecondaryActionStyle())
} }
} }
.padding(compactLayout ? 18 : 20) .approvalCard()
.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
}
} }
} }
struct NotificationBellButton: View { struct DevicePresentation: Identifiable, Hashable {
@ObservedObject var model: AppViewModel 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 { var body: some View {
Button { HStack(spacing: 12) {
model.isNotificationCenterPresented = true Image(systemName: device.systemImage)
} label: {
Image(systemName: imageName)
.font(.headline) .font(.headline)
.foregroundStyle(iconTone) .foregroundStyle(IdP.tint)
.frame(width: 28, height: 28, alignment: .center) .frame(width: 28)
.background(alignment: .center) {
#if os(iOS) VStack(alignment: .leading, spacing: 3) {
GeometryReader { proxy in Text(device.name)
Color.clear .font(.body.weight(.medium))
.preference(key: NotificationBellFrameKey.self, value: proxy.frame(in: .global))
} Text(device.isCurrent ? "This device" : "Seen \(device.lastSeen, style: .relative)")
#endif .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") .deviceRowStyle()
} .accessibilityElement(children: .combine)
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
} }
} }
struct NotificationCenterSheet: View { struct TrustSignalBanner: View {
@ObservedObject var model: AppViewModel let request: ApprovalRequest
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View { var body: some View {
NavigationStack { HStack(alignment: .top, spacing: 12) {
AppScrollScreen( Image(systemName: symbolName)
compactLayout: compactLayout, .font(.headline)
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding .foregroundStyle(request.trustColor)
) {
NotificationsPanel(model: model, compactLayout: compactLayout) VStack(alignment: .leading, spacing: 4) {
} Text(request.trustHeadline)
.navigationTitle("Notifications") .font(.subheadline.weight(.semibold))
.toolbar {
ToolbarItem(placement: .cancellationAction) { Text(request.trustExplanation)
Button("Done") { .font(.subheadline)
dismiss() .foregroundStyle(.secondary)
}
}
} }
} }
#if os(iOS) .padding(.vertical, 8)
.presentationDetents(compactLayout ? [.large] : [.medium, .large])
#endif
} }
private var compactLayout: Bool { private var symbolName: String {
#if os(iOS) switch request.trustColor {
horizontalSizeClass == .compact case .green:
#else return "checkmark.shield.fill"
false case .yellow:
#endif 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 import SwiftUI
struct OverviewPanel: View { struct InboxListView: View {
@ObservedObject var model: AppViewModel @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 { private var filteredRequests: [ApprovalRequest] {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { guard !searchText.isEmpty else {
if let profile = model.profile, let session = model.session { return model.requests
OverviewHero( }
profile: profile,
session: session, return model.requests.filter {
pendingCount: model.pendingRequests.count, $0.inboxTitle.localizedCaseInsensitiveContains(searchText)
unreadCount: model.unreadNotificationCount, || $0.source.localizedCaseInsensitiveContains(searchText)
compactLayout: compactLayout || $0.subtitle.localizedCaseInsensitiveContains(searchText)
)
}
} }
} }
}
struct RequestsPanel: View { private var recentRequests: [ApprovalRequest] {
@ObservedObject var model: AppViewModel filteredRequests.filter { Date.now.timeIntervalSince($0.createdAt) <= 60 * 30 }
let compactLayout: Bool }
let onOpenRequest: (ApprovalRequest) -> Void
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 { var body: some View {
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) { List {
if model.requests.isEmpty { if filteredRequests.isEmpty {
AppPanel(compactLayout: compactLayout) { EmptyPaneView(
EmptyStateCopy( title: "No sign-in requests",
title: "No checks waiting", message: "New approval requests will appear here as soon as a relying party asks for proof.",
systemImage: "checkmark.circle", systemImage: "tray"
message: "Identity proof requests from sites and devices appear here." )
) .listRowBackground(Color.clear)
}
} else { } else {
RequestList( ForEach(recentRequests) { request in
requests: model.requests, row(for: request, compact: false)
compactLayout: compactLayout, .transition(.move(edge: .top).combined(with: .opacity))
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."
)
} }
} else {
NotificationList(
notifications: model.notifications,
compactLayout: compactLayout,
onMarkRead: { notification in
Task { await model.markNotificationRead(notification) }
}
)
}
}
}
}
struct NotificationsPanel: View { if !earlierRequests.isEmpty {
@ObservedObject var model: AppViewModel Section {
let compactLayout: Bool ForEach(earlierRequests) { request in
row(for: request, compact: true)
var body: some View { .transition(.move(edge: .top).combined(with: .opacity))
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) }
} }
) } header: {
} Text("Earlier today")
} .textCase(nil)
} }
}
}
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
} }
} }
} }
.listStyle(.plain)
.navigationTitle("Inbox")
.animation(.spring(response: 0.35, dampingFraction: 0.88), value: filteredRequests.map(\.id))
.idpSearchable(text: $searchText, isPresented: $isSearchPresented)
} }
@ViewBuilder @ViewBuilder
private var permissionButtons: some View { private func row(for request: ApprovalRequest, compact: Bool) -> some View {
Button { if usesSelection {
Task { await model.requestNotificationAccess() } Button {
} label: { selectedRequestID = request.id
Label("Enable notifications", systemImage: "bell.and.waves.left.and.right.fill") Haptics.selection()
.frame(maxWidth: .infinity) } label: {
} ApprovalRow(
.buttonStyle(.borderedProminent) request: request,
handle: model.profile?.handle ?? "@you",
Button { compact: compact,
Task { await model.sendTestNotification() } highlighted: highlightedRequestID == request.id
} 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
) )
} }
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 { struct NotificationCenterView: View {
let title: String @ObservedObject var model: AppViewModel
let systemImage: String
let message: String 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 { var body: some View {
ContentUnavailableView( Group {
title, if model.notifications.isEmpty {
systemImage: systemImage, EmptyPaneView(
description: Text(message) title: "All clear",
) message: "You'll see new sign-in requests here.",
.frame(maxWidth: .infinity) systemImage: "shield"
.padding(.vertical, 10) )
} 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 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 { struct HomeRootView: View {
@ObservedObject var model: AppViewModel @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 { var body: some View {
Group { Group {
if usesCompactNavigation { if usesRegularNavigation {
CompactHomeContainer(model: model) RegularHomeContainer(
} else { model: model,
RegularHomeContainer(model: model) selectedRequestID: $selectedRequestID,
} searchText: $searchText,
} isSearchPresented: $isSearchPresented
.onPreferenceChange(NotificationBellFrameKey.self) { notificationBellFrame = $0 } )
.overlay(alignment: .topLeading) { } else {
if usesCompactNavigation { CompactHomeContainer(
NotificationBellBadgeOverlay( model: model,
unreadCount: model.unreadNotificationCount, selectedRequestID: $selectedRequestID,
bellFrame: notificationBellFrame searchText: $searchText,
isSearchPresented: $isSearchPresented
) )
.ignoresSafeArea()
} }
} }
.sheet(isPresented: $model.isNotificationCenterPresented) { .onAppear(perform: syncSelection)
NotificationCenterSheet(model: model) .onChange(of: model.requests.map(\.id)) { _, _ in
syncSelection()
} }
} }
private var usesCompactNavigation: Bool { private var usesRegularNavigation: Bool {
#if os(iOS) #if os(iOS)
true horizontalSizeClass == .regular
#else #else
false false
#endif #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 { private struct CompactHomeContainer: View {
@ObservedObject var model: AppViewModel @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 { var body: some View {
TabView(selection: $model.selectedSection) { TabView(selection: $model.selectedSection) {
ForEach(AppSection.allCases) { section in ForEach(AppSection.allCases) { section in
NavigationStack { NavigationStack {
HomeSectionScreen(model: model, section: section, compactLayout: compactLayout) sectionContent(for: section)
.navigationTitle(section.title) .navigationDestination(for: ApprovalRequest.ID.self) { requestID in
.inlineNavigationTitleOnIOS() ApprovalDetailView(model: model, requestID: requestID, dismissOnResolve: true)
}
.toolbar { .toolbar {
DashboardToolbar(model: model) if section == .inbox {
InboxToolbar(model: model, isSearchPresented: $isSearchPresented)
}
} }
} }
.tag(section) .tag(section)
@@ -81,239 +76,130 @@ private struct CompactHomeContainer: View {
} }
} }
} }
.cleanTabBarOnIOS() .idpTabBarChrome()
} }
private var compactLayout: Bool { @ViewBuilder
#if os(iOS) private func sectionContent(for section: AppSection) -> some View {
horizontalSizeClass == .compact switch section {
#else case .inbox:
false InboxListView(
#endif 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 { private struct RegularHomeContainer: View {
@ObservedObject var model: AppViewModel @ObservedObject var model: AppViewModel
@Binding var selectedRequestID: ApprovalRequest.ID?
@Binding var searchText: String
@Binding var isSearchPresented: Bool
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
Sidebar(model: model) SidebarView(model: model)
.navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 320) .navigationSplitViewColumnWidth(min: 250, ideal: 280, max: 320)
} content: {
contentColumn
} detail: { } detail: {
HomeSectionScreen(model: model, section: model.selectedSection, compactLayout: false) detailColumn
.navigationTitle(model.selectedSection.title)
.toolbar {
DashboardToolbar(model: model)
}
} }
.navigationSplitViewStyle(.balanced) .navigationSplitViewStyle(.balanced)
} }
}
private struct DashboardToolbar: ToolbarContent { @ViewBuilder
@ObservedObject var model: AppViewModel private var contentColumn: some View {
switch model.selectedSection {
var body: some ToolbarContent { case .inbox:
ToolbarItemGroup(placement: .primaryAction) { InboxListView(
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(
model: model, model: model,
identifyReader: identifyReader, selectedRequestID: $selectedRequestID,
onScanQR: { model.isScannerPresented = true }, searchText: $searchText,
onShowOTP: { isOTPPresented = true } 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 { @ViewBuilder
case .overview: private var detailColumn: some View {
OverviewPanel(model: model, compactLayout: compactLayout) switch model.selectedSection {
case .requests: case .inbox:
RequestsPanel(model: model, compactLayout: compactLayout, onOpenRequest: { focusedRequest = $0 }) ApprovalDetailView(model: model, requestID: selectedRequestID)
case .activity: case .notifications:
ActivityPanel(model: model, compactLayout: compactLayout) EmptyPaneView(
case .account: title: "Notification history",
AccountPanel(model: model, compactLayout: compactLayout) message: "Select the inbox to review request context side by side.",
} systemImage: "bell"
} )
.task { case .devices:
identifyReader.onAuthenticationRequestDetected = { request in EmptyPaneView(
Task { title: "Trusted hardware",
await model.identifyWithNFC(request) message: "Device trust and last-seen state appear here while you manage your passport.",
} systemImage: "desktopcomputer"
} )
case .identity:
identifyReader.onError = { message in EmptyPaneView(
model.errorMessage = message title: "Identity overview",
} message: "Your profile, recovery status, and pairing state stay visible here.",
} systemImage: "person.crop.rectangle.stack"
.sheet(item: $focusedRequest) { request in )
RequestDetailSheet(request: request, model: model) case .settings:
} EmptyPaneView(
.sheet(isPresented: $model.isScannerPresented) { title: "Preferences",
QRScannerSheet( message: "Notification delivery and demo controls live in settings.",
seededPayload: model.session?.pairingCode ?? model.suggestedPairingPayload, systemImage: "gearshape"
title: "Scan proof QR",
description: "Use the camera to scan an idp.global QR challenge from the site or device asking you to prove that it is really you.",
navigationTitle: "Scan Proof QR",
onCodeScanned: { payload in
Task {
await model.identifyWithPayload(payload, transport: .qr)
}
}
) )
}
.sheet(isPresented: $isOTPPresented) {
if let session = model.session {
OneTimePasscodeSheet(session: session)
}
} }
} }
} }
private struct HomeTopActions: View { struct SidebarView: 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 {
@ObservedObject var model: AppViewModel @ObservedObject var model: AppViewModel
var body: some View { var body: some View {
List { List {
Section { ForEach(Array(AppSection.allCases.enumerated()), id: \.element.id) { index, section in
SidebarStatusCard( Button {
profile: model.profile, model.selectedSection = section
pendingCount: model.pendingRequests.count, Haptics.selection()
unreadCount: model.unreadNotificationCount } label: {
) HStack(spacing: 12) {
} Label(section.title, systemImage: section.systemImage)
Spacer()
Section("Workspace") { if badgeCount(for: section) > 0 {
ForEach(AppSection.allCases) { section in StatusPill(title: "\(badgeCount(for: section))", color: IdP.tint)
Button {
model.selectedSection = section
} label: {
HStack {
Label(section.title, systemImage: section.systemImage)
Spacer()
if badgeCount(for: section) > 0 {
AppStatusTag(title: "\(badgeCount(for: section))", tone: dashboardAccent)
}
} }
} }
.buttonStyle(.plain) .padding(.vertical, 6)
.listRowBackground(
model.selectedSection == section
? dashboardAccent.opacity(0.10)
: Color.clear
)
} }
.buttonStyle(.plain)
.listRowBackground(model.selectedSection == section ? IdP.tint.opacity(0.08) : Color.clear)
.keyboardShortcut(shortcut(for: index), modifiers: .command)
} }
} }
.navigationTitle("idp.global") .navigationTitle("idp.global")
@@ -321,36 +207,57 @@ private struct Sidebar: View {
private func badgeCount(for section: AppSection) -> Int { private func badgeCount(for section: AppSection) -> Int {
switch section { switch section {
case .overview: case .inbox:
0
case .requests:
model.pendingRequests.count model.pendingRequests.count
case .activity: case .notifications:
model.unreadNotificationCount model.unreadNotificationCount
case .account: case .devices:
max((model.profile?.deviceCount ?? 1) - 1, 0)
case .identity, .settings:
0 0
} }
} }
}
private struct SidebarStatusCard: View { private func shortcut(for index: Int) -> KeyEquivalent {
let profile: MemberProfile? let value = max(1, min(index + 1, 9))
let pendingCount: Int return KeyEquivalent(Character("\(value)"))
let unreadCount: Int }
}
var body: some View {
VStack(alignment: .leading, spacing: 10) { private struct InboxToolbar: ToolbarContent {
Text("Digital Passport") @ObservedObject var model: AppViewModel
.font(.headline) @Binding var isSearchPresented: Bool
Text(profile?.handle ?? "No passport active") var body: some ToolbarContent {
.foregroundStyle(.secondary) ToolbarItem(placement: .idpTrailingToolbar) {
HStack(spacing: 8) {
HStack(spacing: 8) { Button {
AppStatusTag(title: "\(pendingCount) pending", tone: dashboardAccent) isSearchPresented = true
AppStatusTag(title: "\(unreadCount) unread", tone: dashboardGold) } label: {
} Image(systemName: "magnifyingglass")
} .font(.headline)
.padding(.vertical, 6) .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 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 { struct RequestDetailSheet: View {
let request: ApprovalRequest let request: ApprovalRequest
@ObservedObject var model: AppViewModel @ObservedObject var model: AppViewModel
@Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
NavigationStack { NavigationStack {
AppScrollScreen( ApprovalDetailView(model: model, requestID: request.id, dismissOnResolve: true)
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()
}
}
}
} }
} }
} }
private struct RequestDetailHero: View { struct HoldToApproveButton: View {
let request: ApprovalRequest var title = "Hold to approve"
var isBusy = false
let action: () async -> Void
private var accent: Color { @State private var progress: CGFloat = 0
switch request.status {
case .approved: var body: some View {
.green ZStack {
case .rejected: RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.red .fill(isBusy ? Color.secondary.opacity(0.24) : IdP.tint)
case .pending:
request.risk == .routine ? dashboardAccent : .orange 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 { var body: some View {
AppPanel(compactLayout: true, radius: AppLayout.largeCardRadius) { VStack(spacing: 24) {
AppBadge(title: request.kind.title, tone: accent) 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) Image(systemName: "wave.3.right")
.font(.system(size: 30, weight: .bold, design: .rounded)) .font(.system(size: 34, weight: .semibold))
.lineLimit(3) .foregroundStyle(IdP.tint)
}
.frame(height: 160)
Text(request.subtitle) VStack(spacing: 8) {
.foregroundStyle(.secondary) Text(title)
.font(.title3.weight(.semibold))
HStack(spacing: 8) { Text(message)
AppStatusTag(title: request.status.title, tone: accent) .font(.subheadline)
AppStatusTag(title: request.risk.title, tone: request.risk == .routine ? dashboardAccent : .orange) .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 let session: AuthSession
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -132,42 +308,32 @@ struct OneTimePasscodeSheet: View {
let code = OneTimePasscodeGenerator.code(for: session.pairingCode, at: context.date) let code = OneTimePasscodeGenerator.code(for: session.pairingCode, at: context.date)
let secondsRemaining = OneTimePasscodeGenerator.renewalCountdown(at: context.date) let secondsRemaining = OneTimePasscodeGenerator.renewalCountdown(at: context.date)
AppScrollScreen(compactLayout: compactLayout) { VStack(alignment: .leading, spacing: 18) {
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) { Text("One-time pairing code")
AppBadge(title: "One-time passcode", tone: dashboardGold) .font(.title3.weight(.semibold))
Text("OTP") Text("Use this code on the next device you want to pair with your idp.global passport.")
.font(.system(size: compactLayout ? 32 : 40, weight: .bold, design: .rounded)) .font(.subheadline)
.foregroundStyle(.secondary)
Text("Share this code only with the site or device asking you to prove that it is really you.") Text(code)
.font(.subheadline) .font(.system(size: 42, weight: .bold, design: .rounded).monospacedDigit())
.foregroundStyle(.secondary) .tracking(5)
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(Color.idpSecondaryGroupedBackground, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous))
Text(code) HStack {
.font(.system(size: compactLayout ? 42 : 54, weight: .bold, design: .rounded).monospacedDigit()) StatusPill(title: "Renews in \(secondsRemaining)s", color: IdP.tint)
.tracking(compactLayout ? 4 : 6) StatusPill(title: session.originHost, color: .secondary)
.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))
} }
Spacer()
} }
.padding(24)
} }
.navigationTitle("OTP") .navigationTitle("Pair Device")
.inlineNavigationTitleOnIOS() .idpInlineNavigationTitle()
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Close") { Button("Close") {
@@ -177,12 +343,170 @@ struct OneTimePasscodeSheet: View {
} }
} }
} }
}
private var compactLayout: Bool { struct MenuBarPopover: View {
#if os(iOS) @ObservedObject var model: AppViewModel
horizontalSizeClass == .compact @State private var notificationsPaused = false
#else @State private var isPairingCodePresented = false
false
#endif 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 import SwiftUI
private let watchAccent = AppTheme.accent
private let watchGold = AppTheme.warmAccent
struct WatchRootView: View { struct WatchRootView: View {
@ObservedObject var model: AppViewModel @ObservedObject var model: AppViewModel
@State private var showsQueue = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -13,12 +10,21 @@ struct WatchRootView: View {
if model.session == nil { if model.session == nil {
WatchPairingView(model: model) WatchPairingView(model: model)
} else { } 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 @ObservedObject var model: AppViewModel
var body: some View { var body: some View {
ScrollView { VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 12) { Text("Link your watch")
VStack(alignment: .leading, spacing: 10) { .font(.headline)
AppBadge(title: "Preview passport", tone: watchAccent) .foregroundStyle(.white)
Text("Prove identity from your wrist") Text("Use the shared demo passport so approvals stay visible on your wrist.")
.font(.title3.weight(.semibold)) .font(.footnote)
.foregroundStyle(.white) .foregroundStyle(.white.opacity(0.72))
Text("Link this watch to the preview passport so identity checks and alerts stay visible on your wrist.") Button("Use demo payload") {
.font(.footnote) Task {
.foregroundStyle(.white.opacity(0.72)) await model.signInWithSuggestedPayload()
HStack(spacing: 8) {
AppStatusTag(title: "Wrist-ready", tone: watchAccent)
AppStatusTag(title: "Proof focus", tone: watchGold)
}
} }
.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) .buttonStyle(PrimaryActionStyle())
.padding(.top, 6)
.padding(.bottom, 20)
} }
.background(Color.black.ignoresSafeArea()) .approvalCard(highlighted: true)
.navigationTitle("Link Watch") .padding(10)
.navigationTitle("idp.global")
} }
} }
private struct WatchSetupFeatureRow: View { private struct WatchHomeView: View {
let systemImage: String @ObservedObject var model: AppViewModel
let title: String
let subtitle: String
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 10) { Group {
Image(systemName: systemImage) if let request = model.pendingRequests.first {
.font(.footnote.weight(.semibold)) WatchApprovalView(model: model, requestID: request.id)
.foregroundStyle(watchAccent) } else {
.frame(width: 18, height: 18) 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) { VStack(alignment: .leading, spacing: 2) {
Text(title) Text(request.watchAppDisplayName)
.font(.footnote.weight(.semibold)) .font(.footnote.weight(.semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
Text(request.createdAt, style: .time)
Text(subtitle)
.font(.caption2) .font(.caption2)
.foregroundStyle(.white.opacity(0.68)) .foregroundStyle(.white.opacity(0.68))
} }
} }
} .padding(.vertical, 2)
}
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))
}
} }
} }
@@ -431,159 +190,202 @@ private struct WatchRequestDetailView: View {
if let request { if let request {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
detailHeader( RequestHeroCard(request: request, handle: model.profile?.handle ?? "@you")
title: request.title,
subtitle: request.source,
badge: request.status.title
)
Text(request.subtitle) Text(request.watchTrustExplanation)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.white.opacity(0.72))
VStack(alignment: .leading, spacing: 6) {
Text("Trust Summary")
.font(.headline)
Text(request.trustHeadline)
.font(.subheadline.weight(.semibold))
Text(request.trustDetail)
.font(.footnote)
.foregroundStyle(.secondary)
Text(request.risk.guidance)
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
if !request.scopes.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Scopes")
.font(.headline)
ForEach(request.scopes, id: \.self) { scope in
Label(scope, systemImage: "checkmark.seal.fill")
.font(.footnote)
}
}
}
if request.status == .pending { if request.status == .pending {
if model.activeRequestID == request.id { WatchHoldToApproveButton(isBusy: model.activeRequestID == request.id) {
ProgressView("Updating proof...") await model.approve(request)
} else { }
Button("Verify") {
Task {
await model.approve(request)
}
}
.buttonStyle(.borderedProminent)
Button("Decline", role: .destructive) { Button("Deny") {
Task { Task {
await model.reject(request) Haptics.warning()
} await model.reject(request)
} }
} }
.buttonStyle(SecondaryActionStyle())
} }
} }
.padding(.horizontal, 8) .padding(10)
.padding(.bottom, 20)
} }
} else { } else {
Text("This request is no longer available.") WatchEmptyState(
.foregroundStyle(.secondary) title: "No request",
message: "This sign-in is no longer pending.",
systemImage: "shield"
)
} }
} }
.navigationTitle("Identity Check") .navigationTitle("Details")
}
private func detailHeader(title: String, subtitle: String, badge: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
Text(badge)
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(watchAccent.opacity(0.14), in: Capsule())
}
} }
} }
private struct WatchNotificationDetailView: View { private struct WatchHoldToApproveButton: View {
@ObservedObject var model: AppViewModel var isBusy = false
let notificationID: AppNotification.ID let action: () async -> Void
private var notification: AppNotification? { @State private var progress: CGFloat = 0
model.notifications.first(where: { $0.id == notificationID })
}
var body: some View { var body: some View {
Group { ZStack {
if let notification { RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
ScrollView { .fill(isBusy ? Color.white.opacity(0.18) : IdP.tint)
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text(notification.title)
.font(.headline)
Text(notification.kind.title)
.font(.footnote.weight(.semibold))
.foregroundStyle(watchAccent)
Text(notification.sentAt.watchRelativeString)
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(notification.message) RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
.font(.footnote) .stroke(Color.white.opacity(0.16), lineWidth: 1)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) { Text(isBusy ? "Working…" : "Approve")
Text("Alert posture") .font(.headline)
.font(.headline) .foregroundStyle(.white)
Text(model.notificationPermission.summary) .padding(.vertical, 12)
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
if notification.isUnread { RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)
Button("Mark Read") { .trim(from: 0, to: progress)
Task { .stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
await model.markNotificationRead(notification) .rotationEffect(.degrees(-90))
} .padding(2)
} }
} .contentShape(RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous))
} .onLongPressGesture(minimumDuration: 0.6, maximumDistance: 18, pressing: updateProgress) {
.padding(.horizontal, 8) guard !isBusy else { return }
.padding(.bottom, 20) Task {
} Haptics.success()
} else { await action()
Text("This activity item has already been cleared.") progress = 0
.foregroundStyle(.secondary)
} }
} }
.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 { private extension View {
var watchRelativeString: String { @ViewBuilder
WatchFormatters.relative.localizedString(for: self, relativeTo: .now) func watchPrimaryActionGesture() -> some View {
if #available(watchOS 11.0, *) {
self.handGestureShortcut(.primaryAction)
} else {
self
}
} }
} }
private enum WatchFormatters { private extension ApprovalRequest {
static let relative: RelativeDateTimeFormatter = { var watchAppDisplayName: String {
let formatter = RelativeDateTimeFormatter() source.replacingOccurrences(of: "auth.", with: "")
formatter.unitsStyle = .abbreviated }
return formatter
}() 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>