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