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