Commit 8d5cd9e707
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +172 −4
| @@ -10,6 +10,7 @@ | |||
| 10 | 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; }; | 10 | 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; }; |
| 11 | 8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; }; | 11 | 8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; }; |
| 12 | 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; }; | 12 | 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; }; |
| 13 | 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | ||
| 13 | /* End PBXBuildFile section */ | 14 | /* End PBXBuildFile section */ |
| 14 | 15 | ||
| 15 | /* Begin PBXContainerItemProxy section */ | 16 | /* Begin PBXContainerItemProxy section */ |
| @@ -20,6 +21,13 @@ | |||
| 20 | remoteGlobalIDString = 8B4B28D02F6704280045FA19; | 21 | remoteGlobalIDString = 8B4B28D02F6704280045FA19; |
| 21 | remoteInfo = Hutch; | 22 | remoteInfo = Hutch; |
| 22 | }; | 23 | }; |
| 24 | 8BE082022F80000100000001 /* PBXContainerItemProxy */ = { | ||
| 25 | isa = PBXContainerItemProxy; | ||
| 26 | containerPortal = 8B4B28C92F6704280045FA19 /* Project object */; | ||
| 27 | proxyType = 1; | ||
| 28 | remoteGlobalIDString = 8BE081F72F80000100000001; | ||
| 29 | remoteInfo = HutchWidgetExtension; | ||
| 30 | }; | ||
| 23 | /* End PBXContainerItemProxy section */ | 31 | /* End PBXContainerItemProxy section */ |
| 24 | 32 | ||
| 25 | /* Begin PBXFileReference section */ | 33 | /* Begin PBXFileReference section */ |
| @@ -30,6 +38,7 @@ | |||
| 30 | 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HutchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; | 38 | 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HutchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 31 | 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; }; | 39 | 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; }; |
| 32 | 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; }; | 40 | 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; }; |
| 41 | 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; | ||
| 33 | /* End PBXFileReference section */ | 42 | /* End PBXFileReference section */ |
| 34 | 43 | ||
| 35 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ | 44 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ |
| @@ -40,6 +49,13 @@ | |||
| 40 | ); | 49 | ); |
| 41 | target = 8B4B28D02F6704280045FA19 /* Hutch */; | 50 | target = 8B4B28D02F6704280045FA19 /* Hutch */; |
| 42 | }; | 51 | }; |
| 52 | 8BE082032F80000100000001 /* Exceptions for "HutchWidgetExtension" folder in "HutchWidgetExtension" target */ = { | ||
| 53 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | ||
| 54 | membershipExceptions = ( | ||
| 55 | Info.plist, | ||
| 56 | ); | ||
| 57 | target = 8BE081F72F80000100000001 /* HutchWidgetExtension */; | ||
| 58 | }; | ||
| 43 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ | 59 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ |
| 44 | 60 | ||
| 45 | /* Begin PBXFileSystemSynchronizedRootGroup section */ | 61 | /* Begin PBXFileSystemSynchronizedRootGroup section */ |
| @@ -56,8 +72,35 @@ | |||
| 56 | path = HutchTests; | 72 | path = HutchTests; |
| 57 | sourceTree = "<group>"; | 73 | sourceTree = "<group>"; |
| 58 | }; | 74 | }; |
| 75 | 8BE081F42F80000100000001 /* Shared */ = { | ||
| 76 | isa = PBXFileSystemSynchronizedRootGroup; | ||
| 77 | path = Shared; | ||
| 78 | sourceTree = "<group>"; | ||
| 79 | }; | ||
| 80 | 8BE081F52F80000100000001 /* HutchWidgetExtension */ = { | ||
| 81 | isa = PBXFileSystemSynchronizedRootGroup; | ||
| 82 | exceptions = ( | ||
| 83 | 8BE082032F80000100000001 /* Exceptions for "HutchWidgetExtension" folder in "HutchWidgetExtension" target */, | ||
| 84 | ); | ||
| 85 | path = HutchWidgetExtension; | ||
| 86 | sourceTree = "<group>"; | ||
| 87 | }; | ||
| 59 | /* End PBXFileSystemSynchronizedRootGroup section */ | 88 | /* End PBXFileSystemSynchronizedRootGroup section */ |
| 60 | 89 | ||
| 90 | /* Begin PBXCopyFilesBuildPhase section */ | ||
| 91 | 8BE081FA2F80000100000001 /* Embed App Extensions */ = { | ||
| 92 | isa = PBXCopyFilesBuildPhase; | ||
| 93 | buildActionMask = 2147483647; | ||
| 94 | dstPath = ""; | ||
| 95 | dstSubfolderSpec = 13; | ||
| 96 | files = ( | ||
| 97 | 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed App Extensions */, | ||
| 98 | ); | ||
| 99 | name = "Embed App Extensions"; | ||
| 100 | runOnlyForDeploymentPostprocessing = 0; | ||
| 101 | }; | ||
| 102 | /* End PBXCopyFilesBuildPhase section */ | ||
| 103 | |||
| 61 | /* Begin PBXFrameworksBuildPhase section */ | 104 | /* Begin PBXFrameworksBuildPhase section */ |
| 62 | 8B4B28CE2F6704280045FA19 /* Frameworks */ = { | 105 | 8B4B28CE2F6704280045FA19 /* Frameworks */ = { |
| 63 | isa = PBXFrameworksBuildPhase; | 106 | isa = PBXFrameworksBuildPhase; |
| @@ -73,6 +116,13 @@ | |||
| 73 | ); | 116 | ); |
| 74 | runOnlyForDeploymentPostprocessing = 0; | 117 | runOnlyForDeploymentPostprocessing = 0; |
| 75 | }; | 118 | }; |
| 119 | 8BE081F92F80000100000001 /* Frameworks */ = { | ||
| 120 | isa = PBXFrameworksBuildPhase; | ||
| 121 | buildActionMask = 2147483647; | ||
| 122 | files = ( | ||
| 123 | ); | ||
| 124 | runOnlyForDeploymentPostprocessing = 0; | ||
| 125 | }; | ||
| 76 | /* End PBXFrameworksBuildPhase section */ | 126 | /* End PBXFrameworksBuildPhase section */ |
| 77 | 127 | ||
| 78 | /* Begin PBXGroup section */ | 128 | /* Begin PBXGroup section */ |
| @@ -85,6 +135,8 @@ | |||
| 85 | 8B2F89642F69DEB900FC0253 /* README.md */, | 135 | 8B2F89642F69DEB900FC0253 /* README.md */, |
| 86 | 8B2F89652F69DEB900FC0253 /* SECURITY.md */, | 136 | 8B2F89652F69DEB900FC0253 /* SECURITY.md */, |
| 87 | 8B4B28D32F6704280045FA19 /* Hutch */, | 137 | 8B4B28D32F6704280045FA19 /* Hutch */, |
| 138 | 8BE081F42F80000100000001 /* Shared */, | ||
| 139 | 8BE081F52F80000100000001 /* HutchWidgetExtension */, | ||
| 88 | 8B8182B82F6B73F3000AE049 /* HutchTests */, | 140 | 8B8182B82F6B73F3000AE049 /* HutchTests */, |
| 89 | 8B4B28D22F6704280045FA19 /* Products */, | 141 | 8B4B28D22F6704280045FA19 /* Products */, |
| 90 | ); | 142 | ); |
| @@ -95,6 +147,7 @@ | |||
| 95 | children = ( | 147 | children = ( |
| 96 | 8B4B28D12F6704280045FA19 /* Hutch.app */, | 148 | 8B4B28D12F6704280045FA19 /* Hutch.app */, |
| 97 | 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */, | 149 | 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */, |
| 150 | 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */, | ||
| 98 | ); | 151 | ); |
| 99 | name = Products; | 152 | name = Products; |
| 100 | sourceTree = "<group>"; | 153 | sourceTree = "<group>"; |
| @@ -109,13 +162,16 @@ | |||
| 109 | 8B4B28CD2F6704280045FA19 /* Sources */, | 162 | 8B4B28CD2F6704280045FA19 /* Sources */, |
| 110 | 8B4B28CE2F6704280045FA19 /* Frameworks */, | 163 | 8B4B28CE2F6704280045FA19 /* Frameworks */, |
| 111 | 8B4B28CF2F6704280045FA19 /* Resources */, | 164 | 8B4B28CF2F6704280045FA19 /* Resources */, |
| 165 | 8BE081FA2F80000100000001 /* Embed App Extensions */, | ||
| 112 | ); | 166 | ); |
| 113 | buildRules = ( | 167 | buildRules = ( |
| 114 | ); | 168 | ); |
| 115 | dependencies = ( | 169 | dependencies = ( |
| 170 | 8BE082042F80000100000001 /* PBXTargetDependency */, | ||
| 116 | ); | 171 | ); |
| 117 | fileSystemSynchronizedGroups = ( | 172 | fileSystemSynchronizedGroups = ( |
| 118 | 8B4B28D32F6704280045FA19 /* Hutch */, | 173 | 8B4B28D32F6704280045FA19 /* Hutch */, |
| 174 | 8BE081F42F80000100000001 /* Shared */, | ||
| 119 | ); | 175 | ); |
| 120 | name = Hutch; | 176 | name = Hutch; |
| 121 | packageProductDependencies = ( | 177 | packageProductDependencies = ( |
| @@ -147,6 +203,29 @@ | |||
| 147 | productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */; | 203 | productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */; |
| 148 | productType = "com.apple.product-type.bundle.unit-test"; | 204 | productType = "com.apple.product-type.bundle.unit-test"; |
| 149 | }; | 205 | }; |
| 206 | 8BE081F72F80000100000001 /* HutchWidgetExtension */ = { | ||
| 207 | isa = PBXNativeTarget; | ||
| 208 | buildConfigurationList = 8BE081FE2F80000100000001 /* Build configuration list for PBXNativeTarget "HutchWidgetExtension" */; | ||
| 209 | buildPhases = ( | ||
| 210 | 8BE081F82F80000100000001 /* Sources */, | ||
| 211 | 8BE081F92F80000100000001 /* Frameworks */, | ||
| 212 | 8BE081FB2F80000100000001 /* Resources */, | ||
| 213 | ); | ||
| 214 | buildRules = ( | ||
| 215 | ); | ||
| 216 | dependencies = ( | ||
| 217 | ); | ||
| 218 | fileSystemSynchronizedGroups = ( | ||
| 219 | 8BE081F42F80000100000001 /* Shared */, | ||
| 220 | 8BE081F52F80000100000001 /* HutchWidgetExtension */, | ||
| 221 | ); | ||
| 222 | name = HutchWidgetExtension; | ||
| 223 | packageProductDependencies = ( | ||
| 224 | ); | ||
| 225 | productName = HutchWidgetExtension; | ||
| 226 | productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; | ||
| 227 | productType = "com.apple.product-type.app-extension"; | ||
| 228 | }; | ||
| 150 | /* End PBXNativeTarget section */ | 229 | /* End PBXNativeTarget section */ |
| 151 | 230 | ||
| 152 | /* Begin PBXProject section */ | 231 | /* Begin PBXProject section */ |
| @@ -182,6 +261,7 @@ | |||
| 182 | targets = ( | 261 | targets = ( |
| 183 | 8B4B28D02F6704280045FA19 /* Hutch */, | 262 | 8B4B28D02F6704280045FA19 /* Hutch */, |
| 184 | 8B8182B62F6B73F3000AE049 /* HutchTests */, | 263 | 8B8182B62F6B73F3000AE049 /* HutchTests */, |
| 264 | 8BE081F72F80000100000001 /* HutchWidgetExtension */, | ||
| 185 | ); | 265 | ); |
| 186 | }; | 266 | }; |
| 187 | /* End PBXProject section */ | 267 | /* End PBXProject section */ |
| @@ -204,6 +284,13 @@ | |||
| 204 | ); | 284 | ); |
| 205 | runOnlyForDeploymentPostprocessing = 0; | 285 | runOnlyForDeploymentPostprocessing = 0; |
| 206 | }; | 286 | }; |
| 287 | 8BE081FB2F80000100000001 /* Resources */ = { | ||
| 288 | isa = PBXResourcesBuildPhase; | ||
| 289 | buildActionMask = 2147483647; | ||
| 290 | files = ( | ||
| 291 | ); | ||
| 292 | runOnlyForDeploymentPostprocessing = 0; | ||
| 293 | }; | ||
| 207 | /* End PBXResourcesBuildPhase section */ | 294 | /* End PBXResourcesBuildPhase section */ |
| 208 | 295 | ||
| 209 | /* Begin PBXSourcesBuildPhase section */ | 296 | /* Begin PBXSourcesBuildPhase section */ |
| @@ -221,6 +308,13 @@ | |||
| 221 | ); | 308 | ); |
| 222 | runOnlyForDeploymentPostprocessing = 0; | 309 | runOnlyForDeploymentPostprocessing = 0; |
| 223 | }; | 310 | }; |
| 311 | 8BE081F82F80000100000001 /* Sources */ = { | ||
| 312 | isa = PBXSourcesBuildPhase; | ||
| 313 | buildActionMask = 2147483647; | ||
| 314 | files = ( | ||
| 315 | ); | ||
| 316 | runOnlyForDeploymentPostprocessing = 0; | ||
| 317 | }; | ||
| 224 | /* End PBXSourcesBuildPhase section */ | 318 | /* End PBXSourcesBuildPhase section */ |
| 225 | 319 | ||
| 226 | /* Begin PBXTargetDependency section */ | 320 | /* Begin PBXTargetDependency section */ |
| @@ -229,6 +323,11 @@ | |||
| 229 | target = 8B4B28D02F6704280045FA19 /* Hutch */; | 323 | target = 8B4B28D02F6704280045FA19 /* Hutch */; |
| 230 | targetProxy = 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */; | 324 | targetProxy = 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */; |
| 231 | }; | 325 | }; |
| 326 | 8BE082042F80000100000001 /* PBXTargetDependency */ = { | ||
| 327 | isa = PBXTargetDependency; | ||
| 328 | target = 8BE081F72F80000100000001 /* HutchWidgetExtension */; | ||
| 329 | targetProxy = 8BE082022F80000100000001 /* PBXContainerItemProxy */; | ||
| 330 | }; | ||
| 232 | /* End PBXTargetDependency section */ | 331 | /* End PBXTargetDependency section */ |
| 233 | 332 | ||
| 234 | /* Begin XCBuildConfiguration section */ | 333 | /* Begin XCBuildConfiguration section */ |
| @@ -358,8 +457,9 @@ | |||
| 358 | buildSettings = { | 457 | buildSettings = { |
| 359 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 458 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 360 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 459 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 460 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | ||
| 361 | CODE_SIGN_STYLE = Automatic; | 461 | CODE_SIGN_STYLE = Automatic; |
| 362 | CURRENT_PROJECT_VERSION = 9; | 462 | CURRENT_PROJECT_VERSION = 10; |
| 363 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 463 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 364 | ENABLE_PREVIEWS = YES; | 464 | ENABLE_PREVIEWS = YES; |
| 365 | GENERATE_INFOPLIST_FILE = YES; | 465 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -376,7 +476,7 @@ | |||
| 376 | "$(inherited)", | 476 | "$(inherited)", |
| 377 | "@executable_path/Frameworks", | 477 | "@executable_path/Frameworks", |
| 378 | ); | 478 | ); |
| 379 | MARKETING_VERSION = 2.3.1; | 479 | MARKETING_VERSION = 2.4.0; |
| 380 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 480 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 381 | PRODUCT_NAME = "$(TARGET_NAME)"; | 481 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 382 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 482 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -394,8 +494,9 @@ | |||
| 394 | buildSettings = { | 494 | buildSettings = { |
| 395 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 495 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 396 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 496 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 497 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | ||
| 397 | CODE_SIGN_STYLE = Automatic; | 498 | CODE_SIGN_STYLE = Automatic; |
| 398 | CURRENT_PROJECT_VERSION = 9; | 499 | CURRENT_PROJECT_VERSION = 10; |
| 399 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 500 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 400 | ENABLE_PREVIEWS = YES; | 501 | ENABLE_PREVIEWS = YES; |
| 401 | GENERATE_INFOPLIST_FILE = YES; | 502 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -412,7 +513,7 @@ | |||
| 412 | "$(inherited)", | 513 | "$(inherited)", |
| 413 | "@executable_path/Frameworks", | 514 | "@executable_path/Frameworks", |
| 414 | ); | 515 | ); |
| 415 | MARKETING_VERSION = 2.3.1; | 516 | MARKETING_VERSION = 2.4.0; |
| 416 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 517 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 417 | PRODUCT_NAME = "$(TARGET_NAME)"; | 518 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 418 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 519 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -469,6 +570,64 @@ | |||
| 469 | }; | 570 | }; |
| 470 | name = Release; | 571 | name = Release; |
| 471 | }; | 572 | }; |
| 573 | 8BE081FC2F80000100000001 /* Debug */ = { | ||
| 574 | isa = XCBuildConfiguration; | ||
| 575 | buildSettings = { | ||
| 576 | APPLICATION_EXTENSION_API_ONLY = YES; | ||
| 577 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | ||
| 578 | CODE_SIGN_STYLE = Automatic; | ||
| 579 | CURRENT_PROJECT_VERSION = 10; | ||
| 580 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | ||
| 581 | GENERATE_INFOPLIST_FILE = NO; | ||
| 582 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | ||
| 583 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; | ||
| 584 | LD_RUNPATH_SEARCH_PATHS = ( | ||
| 585 | "$(inherited)", | ||
| 586 | "@executable_path/Frameworks", | ||
| 587 | "@executable_path/../../Frameworks", | ||
| 588 | ); | ||
| 589 | MARKETING_VERSION = 2.4.0; | ||
| 590 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | ||
| 591 | PRODUCT_NAME = "$(TARGET_NAME)"; | ||
| 592 | SKIP_INSTALL = YES; | ||
| 593 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | ||
| 594 | SWIFT_APPROACHABLE_CONCURRENCY = YES; | ||
| 595 | SWIFT_EMIT_LOC_STRINGS = YES; | ||
| 596 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; | ||
| 597 | SWIFT_VERSION = 5.0; | ||
| 598 | TARGETED_DEVICE_FAMILY = "1,2"; | ||
| 599 | }; | ||
| 600 | name = Debug; | ||
| 601 | }; | ||
| 602 | 8BE081FD2F80000100000001 /* Release */ = { | ||
| 603 | isa = XCBuildConfiguration; | ||
| 604 | buildSettings = { | ||
| 605 | APPLICATION_EXTENSION_API_ONLY = YES; | ||
| 606 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | ||
| 607 | CODE_SIGN_STYLE = Automatic; | ||
| 608 | CURRENT_PROJECT_VERSION = 10; | ||
| 609 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | ||
| 610 | GENERATE_INFOPLIST_FILE = NO; | ||
| 611 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | ||
| 612 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; | ||
| 613 | LD_RUNPATH_SEARCH_PATHS = ( | ||
| 614 | "$(inherited)", | ||
| 615 | "@executable_path/Frameworks", | ||
| 616 | "@executable_path/../../Frameworks", | ||
| 617 | ); | ||
| 618 | MARKETING_VERSION = 2.4.0; | ||
| 619 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | ||
| 620 | PRODUCT_NAME = "$(TARGET_NAME)"; | ||
| 621 | SKIP_INSTALL = YES; | ||
| 622 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | ||
| 623 | SWIFT_APPROACHABLE_CONCURRENCY = YES; | ||
| 624 | SWIFT_EMIT_LOC_STRINGS = YES; | ||
| 625 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; | ||
| 626 | SWIFT_VERSION = 5.0; | ||
| 627 | TARGETED_DEVICE_FAMILY = "1,2"; | ||
| 628 | }; | ||
| 629 | name = Release; | ||
| 630 | }; | ||
| 472 | /* End XCBuildConfiguration section */ | 631 | /* End XCBuildConfiguration section */ |
| 473 | 632 | ||
| 474 | /* Begin XCConfigurationList section */ | 633 | /* Begin XCConfigurationList section */ |
| @@ -499,6 +658,15 @@ | |||
| 499 | defaultConfigurationIsVisible = 0; | 658 | defaultConfigurationIsVisible = 0; |
| 500 | defaultConfigurationName = Release; | 659 | defaultConfigurationName = Release; |
| 501 | }; | 660 | }; |
| 661 | 8BE081FE2F80000100000001 /* Build configuration list for PBXNativeTarget "HutchWidgetExtension" */ = { | ||
| 662 | isa = XCConfigurationList; | ||
| 663 | buildConfigurations = ( | ||
| 664 | 8BE081FC2F80000100000001 /* Debug */, | ||
| 665 | 8BE081FD2F80000100000001 /* Release */, | ||
| 666 | ); | ||
| 667 | defaultConfigurationIsVisible = 0; | ||
| 668 | defaultConfigurationName = Release; | ||
| 669 | }; | ||
| 502 | /* End XCConfigurationList section */ | 670 | /* End XCConfigurationList section */ |
| 503 | }; | 671 | }; |
| 504 | rootObject = 8B4B28C92F6704280045FA19 /* Project object */; | 672 | rootObject = 8B4B28C92F6704280045FA19 /* Project object */; |
Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata +8
| @@ -4,4 +4,12 @@ | |||
| 4 | <FileRef | 4 | <FileRef |
| 5 | location = "self:"> | 5 | location = "self:"> |
| 6 | </FileRef> | 6 | </FileRef> |
| 7 | <Group | ||
| 8 | location = "group:Shared" | ||
| 9 | name = "Shared"> | ||
| 10 | </Group> | ||
| 11 | <Group | ||
| 12 | location = "group:HutchWidgetExtension" | ||
| 13 | name = "HutchWidgetExtension"> | ||
| 14 | </Group> | ||
| 7 | </Workspace> | 15 | </Workspace> |
Hutch/App/AppState.swift +15
| @@ -77,11 +77,13 @@ final class AppState { | |||
| 77 | let user = try await fetchMe() | 77 | let user = try await fetchMe() |
| 78 | currentUser = user | 78 | currentUser = user |
| 79 | authPhase = .authenticated | 79 | authPhase = .authenticated |
| 80 | await refreshNeedsAttentionSnapshot() | ||
| 80 | } catch { | 81 | } catch { |
| 81 | try? KeychainHelper.deleteToken() | 82 | try? KeychainHelper.deleteToken() |
| 82 | client.setToken(nil) | 83 | client.setToken(nil) |
| 83 | currentUser = nil | 84 | currentUser = nil |
| 84 | authPhase = .unauthenticated | 85 | authPhase = .unauthenticated |
| 86 | NeedsAttentionSnapshotStore.clear() | ||
| 85 | } | 87 | } |
| 86 | } | 88 | } |
| 87 | 89 | ||
| @@ -98,6 +100,7 @@ final class AppState { | |||
| 98 | try KeychainHelper.saveToken(token) | 100 | try KeychainHelper.saveToken(token) |
| 99 | currentUser = user | 101 | currentUser = user |
| 100 | authPhase = .authenticated | 102 | authPhase = .authenticated |
| 103 | await refreshNeedsAttentionSnapshot() | ||
| 101 | } catch { | 104 | } catch { |
| 102 | // Roll back — don't leave an invalid token in the client. | 105 | // Roll back — don't leave an invalid token in the client. |
| 103 | client.setToken(nil) | 106 | client.setToken(nil) |
| @@ -111,6 +114,7 @@ final class AppState { | |||
| 111 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } | 114 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } |
| 112 | await clearWebData() | 115 | await clearWebData() |
| 113 | clearWebContentRenderCaches() | 116 | clearWebContentRenderCaches() |
| 117 | NeedsAttentionSnapshotStore.clear() | ||
| 114 | authPhase = .unauthenticated | 118 | authPhase = .unauthenticated |
| 115 | selectedTab = .home | 119 | selectedTab = .home |
| 116 | } | 120 | } |
| @@ -125,6 +129,7 @@ final class AppState { | |||
| 125 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } | 129 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } |
| 126 | await clearWebData() | 130 | await clearWebData() |
| 127 | clearWebContentRenderCaches() | 131 | clearWebContentRenderCaches() |
| 132 | NeedsAttentionSnapshotStore.clear() | ||
| 128 | 133 | ||
| 129 | authPhase = .unauthenticated | 134 | authPhase = .unauthenticated |
| 130 | selectedTab = .home | 135 | selectedTab = .home |
| @@ -270,6 +275,16 @@ final class AppState { | |||
| 270 | selectedTab = .home | 275 | selectedTab = .home |
| 271 | } | 276 | } |
| 272 | 277 | ||
| 278 | private func refreshNeedsAttentionSnapshot() async { | ||
| 279 | guard let currentUser else { | ||
| 280 | NeedsAttentionSnapshotStore.clear() | ||
| 281 | return | ||
| 282 | } | ||
| 283 | |||
| 284 | let viewModel = HomeViewModel(currentUser: currentUser, client: client) | ||
| 285 | await viewModel.loadDashboard() | ||
| 286 | } | ||
| 287 | |||
| 273 | private func clearWebData() async { | 288 | private func clearWebData() async { |
| 274 | await withCheckedContinuation { continuation in | 289 | await withCheckedContinuation { continuation in |
| 275 | let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes() | 290 | let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes() |
Hutch/App/DeepLink.swift +4
| @@ -2,6 +2,7 @@ import Foundation | |||
| 2 | 2 | ||
| 3 | /// Represents a parsed `hutch://` deep link. | 3 | /// Represents a parsed `hutch://` deep link. |
| 4 | enum DeepLink: Equatable { | 4 | enum DeepLink: Equatable { |
| 5 | case home | ||
| 5 | /// hutch://git/<owner>/<repo> | 6 | /// hutch://git/<owner>/<repo> |
| 6 | case repository(owner: String, repo: String) | 7 | case repository(owner: String, repo: String) |
| 7 | /// hutch://todo/<owner>/<tracker>/<ticketId> | 8 | /// hutch://todo/<owner>/<tracker>/<ticketId> |
| @@ -19,6 +20,9 @@ enum DeepLink: Equatable { | |||
| 19 | let components = url.pathComponents(fromScheme: "hutch") | 20 | let components = url.pathComponents(fromScheme: "hutch") |
| 20 | 21 | ||
| 21 | switch components.first { | 22 | switch components.first { |
| 23 | case "home", nil: | ||
| 24 | self = .home | ||
| 25 | |||
| 22 | case "git" where components.count >= 3: | 26 | case "git" where components.count >= 3: |
| 23 | let owner = components[1] | 27 | let owner = components[1] |
| 24 | let repo = components[2] | 28 | let repo = components[2] |
Hutch/App/RootView.swift +7
| @@ -161,6 +161,10 @@ struct RootView: View { | |||
| 161 | guard appState.isAuthenticated else { return } | 161 | guard appState.isAuthenticated else { return } |
| 162 | 162 | ||
| 163 | switch link { | 163 | switch link { |
| 164 | case .home: | ||
| 165 | homePath = NavigationPath() | ||
| 166 | appState.selectedTab = .home | ||
| 167 | |||
| 164 | case .repository(let owner, let repo): | 168 | case .repository(let owner, let repo): |
| 165 | resolveRepositoryLink(owner: owner, repo: repo) | 169 | resolveRepositoryLink(owner: owner, repo: repo) |
| 166 | 170 | ||
| @@ -284,12 +288,15 @@ private struct MoreNavigationRoot: View { | |||
| 284 | thread: thread, | 288 | thread: thread, |
| 285 | onViewed: { | 289 | onViewed: { |
| 286 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | 290 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) |
| 291 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | ||
| 287 | }, | 292 | }, |
| 288 | onMarkRead: { | 293 | onMarkRead: { |
| 289 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | 294 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) |
| 295 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | ||
| 290 | }, | 296 | }, |
| 291 | onMarkUnread: { | 297 | onMarkUnread: { |
| 292 | InboxReadStateStore.markUnread(for: thread.id) | 298 | InboxReadStateStore.markUnread(for: thread.id) |
| 299 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | ||
| 293 | } | 300 | } |
| 294 | ) | 301 | ) |
| 295 | } | 302 | } |
Hutch/Hutch.entitlements added +10
| @@ -0,0 +1,10 @@ | |||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| 3 | <plist version="1.0"> | ||
| 4 | <dict> | ||
| 5 | <key>com.apple.security.application-groups</key> | ||
| 6 | <array> | ||
| 7 | <string>group.net.cleberg.Hutch</string> | ||
| 8 | </array> | ||
| 9 | </dict> | ||
| 10 | </plist> | ||
Hutch/Views/Home/HomeViewModel.swift +92 −35
| @@ -59,6 +59,7 @@ private struct HomeInboxMailingListThreads: Decodable, Sendable { | |||
| 59 | 59 | ||
| 60 | private struct HomeInboxThreadPage: Decodable, Sendable { | 60 | private struct HomeInboxThreadPage: Decodable, Sendable { |
| 61 | let results: [HomeInboxThreadPayload] | 61 | let results: [HomeInboxThreadPayload] |
| 62 | let cursor: String? | ||
| 62 | } | 63 | } |
| 63 | 64 | ||
| 64 | private struct HomeInboxThreadPayload: Decodable, Sendable { | 65 | private struct HomeInboxThreadPayload: Decodable, Sendable { |
| @@ -143,6 +144,7 @@ final class HomeViewModel { | |||
| 143 | var assignedTickets: [HomeAssignedTicket] = [] | 144 | var assignedTickets: [HomeAssignedTicket] = [] |
| 144 | var recentBuilds: [HomeBuildItem] = [] | 145 | var recentBuilds: [HomeBuildItem] = [] |
| 145 | private(set) var hasUnreadInboxThreads = false | 146 | private(set) var hasUnreadInboxThreads = false |
| 147 | private(set) var unreadInboxThreadCount: Int? | ||
| 146 | private(set) var isLoadingProjects = false | 148 | private(set) var isLoadingProjects = false |
| 147 | private(set) var isLoadingAssignedTickets = false | 149 | private(set) var isLoadingAssignedTickets = false |
| 148 | private(set) var isLoadingRecentBuilds = false | 150 | private(set) var isLoadingRecentBuilds = false |
| @@ -154,7 +156,6 @@ final class HomeViewModel { | |||
| 154 | private let projectService: ProjectService | 156 | private let projectService: ProjectService |
| 155 | private let ticketFetchConcurrencyLimit = 6 | 157 | private let ticketFetchConcurrencyLimit = 6 |
| 156 | private let inboxUnreadConcurrencyLimit = 4 | 158 | private let inboxUnreadConcurrencyLimit = 4 |
| 157 | private let inboxUnreadThreadPreviewLimit = 10 | ||
| 158 | 159 | ||
| 159 | private static let jobsQuery = """ | 160 | private static let jobsQuery = """ |
| 160 | query jobs { | 161 | query jobs { |
| @@ -232,13 +233,14 @@ final class HomeViewModel { | |||
| 232 | """ | 233 | """ |
| 233 | 234 | ||
| 234 | private static let inboxListThreadsQuery = """ | 235 | private static let inboxListThreadsQuery = """ |
| 235 | query inboxListThreads($rid: ID!) { | 236 | query inboxListThreads($rid: ID!, $cursor: Cursor) { |
| 236 | list(rid: $rid) { | 237 | list(rid: $rid) { |
| 237 | threads { | 238 | threads(cursor: $cursor) { |
| 238 | results { | 239 | results { |
| 239 | updated | 240 | updated |
| 240 | subject | 241 | subject |
| 241 | } | 242 | } |
| 243 | cursor | ||
| 242 | } | 244 | } |
| 243 | } | 245 | } |
| 244 | } | 246 | } |
| @@ -280,7 +282,7 @@ final class HomeViewModel { | |||
| 280 | async let projectsTask = loadProjects() | 282 | async let projectsTask = loadProjects() |
| 281 | async let jobsTask = loadRecentJobs() | 283 | async let jobsTask = loadRecentJobs() |
| 282 | async let assignedTicketsTask = loadAssignedTickets() | 284 | async let assignedTicketsTask = loadAssignedTickets() |
| 283 | async let inboxUnreadTask = loadInboxUnreadState() | 285 | async let inboxUnreadTask = loadInboxUnreadCount() |
| 284 | 286 | ||
| 285 | let projectsResult = await projectsTask | 287 | let projectsResult = await projectsTask |
| 286 | switch projectsResult { | 288 | switch projectsResult { |
| @@ -316,7 +318,9 @@ final class HomeViewModel { | |||
| 316 | } | 318 | } |
| 317 | isLoadingAssignedTickets = false | 319 | isLoadingAssignedTickets = false |
| 318 | 320 | ||
| 319 | hasUnreadInboxThreads = (await inboxUnreadTask) ?? false | 321 | unreadInboxThreadCount = await inboxUnreadTask |
| 322 | hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 | ||
| 323 | persistNeedsAttentionSnapshot() | ||
| 320 | } | 324 | } |
| 321 | 325 | ||
| 322 | func resolveTicket(_ ticket: HomeAssignedTicket) async { | 326 | func resolveTicket(_ ticket: HomeAssignedTicket) async { |
| @@ -347,6 +351,7 @@ final class HomeViewModel { | |||
| 347 | responseType: UnassignResponse.self | 351 | responseType: UnassignResponse.self |
| 348 | ) | 352 | ) |
| 349 | assignedTickets.removeAll { $0.id == ticket.id } | 353 | assignedTickets.removeAll { $0.id == ticket.id } |
| 354 | persistNeedsAttentionSnapshot() | ||
| 350 | } catch { | 355 | } catch { |
| 351 | homeLogger.error("Unassign from me failed: \(error, privacy: .public)") | 356 | homeLogger.error("Unassign from me failed: \(error, privacy: .public)") |
| 352 | } | 357 | } |
| @@ -380,6 +385,7 @@ final class HomeViewModel { | |||
| 380 | repositoryOwner: build.repositoryOwner | 385 | repositoryOwner: build.repositoryOwner |
| 381 | ) | 386 | ) |
| 382 | } | 387 | } |
| 388 | persistNeedsAttentionSnapshot() | ||
| 383 | } catch { | 389 | } catch { |
| 384 | homeLogger.error("Cancel build failed: \(error, privacy: .public)") | 390 | homeLogger.error("Cancel build failed: \(error, privacy: .public)") |
| 385 | } | 391 | } |
| @@ -406,19 +412,21 @@ final class HomeViewModel { | |||
| 406 | } | 412 | } |
| 407 | } | 413 | } |
| 408 | 414 | ||
| 409 | private func loadInboxUnreadState() async -> Bool? { | 415 | private func loadInboxUnreadCount() async -> Int? { |
| 410 | do { | 416 | do { |
| 411 | return try await fetchHasUnreadInboxThreads() | 417 | return try await fetchUnreadInboxThreadCount() |
| 412 | } catch { | 418 | } catch { |
| 413 | return nil | 419 | return nil |
| 414 | } | 420 | } |
| 415 | } | 421 | } |
| 416 | 422 | ||
| 417 | private func fetchHasUnreadInboxThreads() async throws -> Bool { | 423 | private func fetchUnreadInboxThreadCount() async throws -> Int { |
| 418 | let mailingLists = try await fetchInboxMailingLists() | 424 | let mailingLists = try await fetchInboxMailingLists() |
| 419 | guard !mailingLists.isEmpty else { return false } | 425 | guard !mailingLists.isEmpty else { return 0 } |
| 420 | 426 | ||
| 421 | var startIndex = mailingLists.startIndex | 427 | var startIndex = mailingLists.startIndex |
| 428 | var unreadCount = 0 | ||
| 429 | var successfulFetchCount = 0 | ||
| 422 | while startIndex < mailingLists.endIndex { | 430 | while startIndex < mailingLists.endIndex { |
| 423 | let endIndex = mailingLists.index( | 431 | let endIndex = mailingLists.index( |
| 424 | startIndex, | 432 | startIndex, |
| @@ -427,30 +435,41 @@ final class HomeViewModel { | |||
| 427 | ) ?? mailingLists.endIndex | 435 | ) ?? mailingLists.endIndex |
| 428 | let batch = Array(mailingLists[startIndex..<endIndex]) | 436 | let batch = Array(mailingLists[startIndex..<endIndex]) |
| 429 | 437 | ||
| 430 | let batchHasUnread = await withTaskGroup(of: Bool.self) { group in | 438 | let batchResult = await withTaskGroup(of: Result<Int, Error>.self) { group in |
| 431 | for mailingList in batch { | 439 | for mailingList in batch { |
| 432 | group.addTask { | 440 | group.addTask { |
| 433 | (try? await self.fetchHasUnreadThreads(for: mailingList)) ?? false | 441 | do { |
| 442 | return .success(try await self.fetchUnreadThreadCount(for: mailingList)) | ||
| 443 | } catch { | ||
| 444 | return .failure(error) | ||
| 445 | } | ||
| 434 | } | 446 | } |
| 435 | } | 447 | } |
| 436 | 448 | ||
| 437 | for await hasUnread in group { | 449 | var counts: [Int] = [] |
| 438 | if hasUnread { | 450 | var errors: [Error] = [] |
| 439 | group.cancelAll() | 451 | for await result in group { |
| 440 | return true | 452 | switch result { |
| 453 | case .success(let count): | ||
| 454 | counts.append(count) | ||
| 455 | case .failure(let error): | ||
| 456 | errors.append(error) | ||
| 441 | } | 457 | } |
| 442 | } | 458 | } |
| 443 | return false | 459 | return (counts, errors) |
| 444 | } | 460 | } |
| 445 | 461 | ||
| 446 | if batchHasUnread { | 462 | unreadCount += batchResult.0.reduce(0, +) |
| 447 | return true | 463 | successfulFetchCount += batchResult.0.count |
| 448 | } | ||
| 449 | 464 | ||
| 450 | startIndex = endIndex | 465 | startIndex = endIndex |
| 451 | } | 466 | } |
| 452 | 467 | ||
| 453 | return false | 468 | guard successfulFetchCount > 0 else { |
| 469 | throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)]) | ||
| 470 | } | ||
| 471 | |||
| 472 | return unreadCount | ||
| 454 | } | 473 | } |
| 455 | 474 | ||
| 456 | private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] { | 475 | private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] { |
| @@ -481,23 +500,40 @@ final class HomeViewModel { | |||
| 481 | return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted } | 500 | return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted } |
| 482 | } | 501 | } |
| 483 | 502 | ||
| 484 | private func fetchHasUnreadThreads(for mailingList: InboxMailingListReference) async throws -> Bool { | 503 | private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int { |
| 485 | let response = try await client.execute( | 504 | var unreadCount = 0 |
| 486 | service: .lists, | 505 | var cursor: String? |
| 487 | query: Self.inboxListThreadsQuery, | 506 | |
| 488 | variables: ["rid": mailingList.rid], | 507 | while true { |
| 489 | responseType: HomeInboxListThreadsResponse.self | 508 | var variables: [String: any Sendable] = ["rid": mailingList.rid] |
| 490 | ) | 509 | if let cursor { |
| 510 | variables["cursor"] = cursor | ||
| 511 | } | ||
| 491 | 512 | ||
| 492 | return response.list.threads.results.prefix(inboxUnreadThreadPreviewLimit).contains { thread in | 513 | let response = try await client.execute( |
| 493 | let normalizedSubject = thread.subject | 514 | service: .lists, |
| 494 | .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) | 515 | query: Self.inboxListThreadsQuery, |
| 495 | .trimmingCharacters(in: .whitespacesAndNewlines) | 516 | variables: variables, |
| 496 | .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]) | 517 | responseType: HomeInboxListThreadsResponse.self |
| 497 | .lowercased() | 518 | ) |
| 498 | let threadID = "\(mailingList.rid)#\(normalizedSubject)" | 519 | |
| 499 | return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) | 520 | unreadCount += response.list.threads.results.filter { thread in |
| 521 | let normalizedSubject = thread.subject | ||
| 522 | .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) | ||
| 523 | .trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 524 | .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]) | ||
| 525 | .lowercased() | ||
| 526 | let threadID = "\(mailingList.rid)#\(normalizedSubject)" | ||
| 527 | return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) | ||
| 528 | }.count | ||
| 529 | |||
| 530 | guard let nextCursor = response.list.threads.cursor else { | ||
| 531 | break | ||
| 532 | } | ||
| 533 | cursor = nextCursor | ||
| 500 | } | 534 | } |
| 535 | |||
| 536 | return unreadCount | ||
| 501 | } | 537 | } |
| 502 | 538 | ||
| 503 | private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { | 539 | private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { |
| @@ -616,11 +652,32 @@ final class HomeViewModel { | |||
| 616 | responseType: StatusEventResponse.self | 652 | responseType: StatusEventResponse.self |
| 617 | ) | 653 | ) |
| 618 | assignedTickets.removeAll { $0.id == ticket.id } | 654 | assignedTickets.removeAll { $0.id == ticket.id } |
| 655 | persistNeedsAttentionSnapshot() | ||
| 619 | } catch { | 656 | } catch { |
| 620 | homeLogger.error("Ticket status update failed: \(error, privacy: .public)") | 657 | homeLogger.error("Ticket status update failed: \(error, privacy: .public)") |
| 621 | } | 658 | } |
| 622 | } | 659 | } |
| 623 | 660 | ||
| 661 | private func persistNeedsAttentionSnapshot() { | ||
| 662 | NeedsAttentionSnapshotStore.save( | ||
| 663 | NeedsAttentionSnapshot( | ||
| 664 | unreadInboxThreads: unreadInboxThreadCount, | ||
| 665 | assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil, | ||
| 666 | failedBuilds: recentBuildsError == nil | ||
| 667 | ? recentBuilds.filter { | ||
| 668 | switch $0.job.status { | ||
| 669 | case .failed, .timeout: | ||
| 670 | true | ||
| 671 | default: | ||
| 672 | false | ||
| 673 | } | ||
| 674 | }.count | ||
| 675 | : nil, | ||
| 676 | updatedAt: .now | ||
| 677 | ) | ||
| 678 | ) | ||
| 679 | } | ||
| 680 | |||
| 624 | nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { | 681 | nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { |
| 625 | jobs.map { job in | 682 | jobs.map { job in |
| 626 | let repository = primaryRepositoryReference(in: job.manifest) | 683 | let repository = primaryRepositoryReference(in: job.manifest) |
Hutch/Views/Inbox/InboxViewModel.swift +3
| @@ -136,6 +136,7 @@ final class InboxViewModel { | |||
| 136 | } | 136 | } |
| 137 | return lhs.lastActivityAt > rhs.lastActivityAt | 137 | return lhs.lastActivityAt > rhs.lastActivityAt |
| 138 | } | 138 | } |
| 139 | NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count) | ||
| 139 | } catch { | 140 | } catch { |
| 140 | inboxListLogger.error("Inbox request failed") | 141 | inboxListLogger.error("Inbox request failed") |
| 141 | self.error = "Failed to load inbox" | 142 | self.error = "Failed to load inbox" |
| @@ -146,11 +147,13 @@ final class InboxViewModel { | |||
| 146 | let viewedAt = max(Date(), thread.lastActivityAt) | 147 | let viewedAt = max(Date(), thread.lastActivityAt) |
| 147 | InboxReadStateStore.markViewed(viewedAt, for: thread.id) | 148 | InboxReadStateStore.markViewed(viewedAt, for: thread.id) |
| 148 | threads.removeAll { $0.id == thread.id } | 149 | threads.removeAll { $0.id == thread.id } |
| 150 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | ||
| 149 | } | 151 | } |
| 150 | 152 | ||
| 151 | func markThreadUnread(_ thread: InboxThreadSummary) { | 153 | func markThreadUnread(_ thread: InboxThreadSummary) { |
| 152 | InboxReadStateStore.markUnread(for: thread.id) | 154 | InboxReadStateStore.markUnread(for: thread.id) |
| 153 | updateThread(thread, isUnread: true) | 155 | updateThread(thread, isUnread: true) |
| 156 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | ||
| 154 | } | 157 | } |
| 155 | 158 | ||
| 156 | func toggleThreadReadState(_ thread: InboxThreadSummary) { | 159 | func toggleThreadReadState(_ thread: InboxThreadSummary) { |
Hutch/Views/Projects/ProjectMailingListView.swift +2
| @@ -92,11 +92,13 @@ final class MailingListDetailViewModel { | |||
| 92 | let viewedAt = max(Date(), thread.lastActivityAt) | 92 | let viewedAt = max(Date(), thread.lastActivityAt) |
| 93 | InboxReadStateStore.markViewed(viewedAt, for: thread.id) | 93 | InboxReadStateStore.markViewed(viewedAt, for: thread.id) |
| 94 | updateThread(thread, isUnread: false) | 94 | updateThread(thread, isUnread: false) |
| 95 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | ||
| 95 | } | 96 | } |
| 96 | 97 | ||
| 97 | func markThreadUnread(_ thread: InboxThreadSummary) { | 98 | func markThreadUnread(_ thread: InboxThreadSummary) { |
| 98 | InboxReadStateStore.markUnread(for: thread.id) | 99 | InboxReadStateStore.markUnread(for: thread.id) |
| 99 | updateThread(thread, isUnread: true) | 100 | updateThread(thread, isUnread: true) |
| 101 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | ||
| 100 | } | 102 | } |
| 101 | 103 | ||
| 102 | private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary { | 104 | private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary { |
HutchWidgetExtension/HutchWidgetExtension.entitlements added +10
| @@ -0,0 +1,10 @@ | |||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| 3 | <plist version="1.0"> | ||
| 4 | <dict> | ||
| 5 | <key>com.apple.security.application-groups</key> | ||
| 6 | <array> | ||
| 7 | <string>group.net.cleberg.Hutch</string> | ||
| 8 | </array> | ||
| 9 | </dict> | ||
| 10 | </plist> | ||
HutchWidgetExtension/Info.plist added +29
| @@ -0,0 +1,29 @@ | |||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| 3 | <plist version="1.0"> | ||
| 4 | <dict> | ||
| 5 | <key>CFBundleDevelopmentRegion</key> | ||
| 6 | <string>$(DEVELOPMENT_LANGUAGE)</string> | ||
| 7 | <key>CFBundleDisplayName</key> | ||
| 8 | <string>Needs Attention</string> | ||
| 9 | <key>CFBundleExecutable</key> | ||
| 10 | <string>$(EXECUTABLE_NAME)</string> | ||
| 11 | <key>CFBundleIdentifier</key> | ||
| 12 | <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> | ||
| 13 | <key>CFBundleInfoDictionaryVersion</key> | ||
| 14 | <string>6.0</string> | ||
| 15 | <key>CFBundleName</key> | ||
| 16 | <string>$(PRODUCT_NAME)</string> | ||
| 17 | <key>CFBundlePackageType</key> | ||
| 18 | <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> | ||
| 19 | <key>CFBundleShortVersionString</key> | ||
| 20 | <string>$(MARKETING_VERSION)</string> | ||
| 21 | <key>CFBundleVersion</key> | ||
| 22 | <string>$(CURRENT_PROJECT_VERSION)</string> | ||
| 23 | <key>NSExtension</key> | ||
| 24 | <dict> | ||
| 25 | <key>NSExtensionPointIdentifier</key> | ||
| 26 | <string>com.apple.widgetkit-extension</string> | ||
| 27 | </dict> | ||
| 28 | </dict> | ||
| 29 | </plist> | ||
HutchWidgetExtension/NeedsAttentionWidget.swift added +226
| @@ -0,0 +1,226 @@ | |||
| 1 | import SwiftUI | ||
| 2 | import WidgetKit | ||
| 3 | |||
| 4 | struct NeedsAttentionEntry: TimelineEntry { | ||
| 5 | let date: Date | ||
| 6 | let snapshot: NeedsAttentionSnapshot? | ||
| 7 | } | ||
| 8 | |||
| 9 | struct NeedsAttentionTimelineProvider: TimelineProvider { | ||
| 10 | func placeholder(in context: Context) -> NeedsAttentionEntry { | ||
| 11 | NeedsAttentionEntry( | ||
| 12 | date: .now, | ||
| 13 | snapshot: NeedsAttentionSnapshot( | ||
| 14 | unreadInboxThreads: 3, | ||
| 15 | assignedOpenTickets: 2, | ||
| 16 | failedBuilds: 1, | ||
| 17 | updatedAt: .now | ||
| 18 | ) | ||
| 19 | ) | ||
| 20 | } | ||
| 21 | |||
| 22 | func getSnapshot(in context: Context, completion: @escaping (NeedsAttentionEntry) -> Void) { | ||
| 23 | completion( | ||
| 24 | NeedsAttentionEntry( | ||
| 25 | date: .now, | ||
| 26 | snapshot: NeedsAttentionSnapshotStore.load() | ||
| 27 | ) | ||
| 28 | ) | ||
| 29 | } | ||
| 30 | |||
| 31 | func getTimeline(in context: Context, completion: @escaping (Timeline<NeedsAttentionEntry>) -> Void) { | ||
| 32 | let refreshDate = Calendar.current.date(byAdding: .minute, value: 20, to: .now) ?? .now.addingTimeInterval(1200) | ||
| 33 | let entry = NeedsAttentionEntry(date: .now, snapshot: NeedsAttentionSnapshotStore.load()) | ||
| 34 | completion(Timeline(entries: [entry], policy: .after(refreshDate))) | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | struct NeedsAttentionWidget: Widget { | ||
| 39 | static let kind = NeedsAttentionWidgetConfiguration.kind | ||
| 40 | |||
| 41 | var body: some WidgetConfiguration { | ||
| 42 | StaticConfiguration(kind: Self.kind, provider: NeedsAttentionTimelineProvider()) { entry in | ||
| 43 | NeedsAttentionWidgetView(entry: entry) | ||
| 44 | } | ||
| 45 | .configurationDisplayName("Needs Attention") | ||
| 46 | .description("Unread inbox threads, assigned tickets, and failed builds.") | ||
| 47 | .supportedFamilies([.systemSmall, .systemMedium]) | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | private struct NeedsAttentionWidgetView: View { | ||
| 52 | let entry: NeedsAttentionEntry | ||
| 53 | @Environment(\.widgetFamily) private var family | ||
| 54 | |||
| 55 | var body: some View { | ||
| 56 | Group { | ||
| 57 | if let snapshot = entry.snapshot, snapshot.hasAnyData { | ||
| 58 | if snapshot.allCountsAreZero { | ||
| 59 | clearState | ||
| 60 | } else { | ||
| 61 | switch family { | ||
| 62 | case .systemMedium: | ||
| 63 | mediumView(snapshot: snapshot) | ||
| 64 | default: | ||
| 65 | smallView(snapshot: snapshot) | ||
| 66 | } | ||
| 67 | } | ||
| 68 | } else { | ||
| 69 | fallbackState | ||
| 70 | } | ||
| 71 | } | ||
| 72 | .widgetURL(URL(string: "hutch://home")) | ||
| 73 | .containerBackground(for: .widget) { | ||
| 74 | Color(.systemBackground) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | private var header: some View { | ||
| 79 | VStack(alignment: .leading, spacing: 1) { | ||
| 80 | Text("Hutch") | ||
| 81 | .font(.caption.weight(.semibold)) | ||
| 82 | .foregroundStyle(.secondary) | ||
| 83 | Text("Needs Attention") | ||
| 84 | .font(.headline) | ||
| 85 | .lineLimit(1) | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | private func smallView(snapshot: NeedsAttentionSnapshot) -> some View { | ||
| 90 | VStack(alignment: .leading, spacing: 12) { | ||
| 91 | Spacer(minLength: 0) | ||
| 92 | VStack(alignment: .leading, spacing: 8) { | ||
| 93 | compactMetric( | ||
| 94 | count: snapshot.unreadInboxThreads, | ||
| 95 | label: "Unread", | ||
| 96 | tint: unreadTint(for: snapshot.unreadInboxThreads) | ||
| 97 | ) | ||
| 98 | compactMetric(count: snapshot.assignedOpenTickets, label: "Assigned") | ||
| 99 | compactMetric( | ||
| 100 | count: snapshot.failedBuilds, | ||
| 101 | label: "Failed", | ||
| 102 | tint: failedTint(for: snapshot.failedBuilds) | ||
| 103 | ) | ||
| 104 | } | ||
| 105 | Spacer(minLength: 0) | ||
| 106 | } | ||
| 107 | .padding() | ||
| 108 | } | ||
| 109 | |||
| 110 | private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View { | ||
| 111 | VStack(alignment: .leading, spacing: 18) { | ||
| 112 | header | ||
| 113 | .padding(.top, 6) | ||
| 114 | HStack(alignment: .top, spacing: 12) { | ||
| 115 | metricColumn( | ||
| 116 | count: snapshot.unreadInboxThreads, | ||
| 117 | label: "Unread", | ||
| 118 | systemImage: "tray", | ||
| 119 | tint: unreadTint(for: snapshot.unreadInboxThreads) | ||
| 120 | ) | ||
| 121 | metricColumn( | ||
| 122 | count: snapshot.assignedOpenTickets, | ||
| 123 | label: "Assigned", | ||
| 124 | systemImage: "ticket" | ||
| 125 | ) | ||
| 126 | metricColumn( | ||
| 127 | count: snapshot.failedBuilds, | ||
| 128 | label: "Failed", | ||
| 129 | systemImage: "hammer", | ||
| 130 | tint: failedTint(for: snapshot.failedBuilds) | ||
| 131 | ) | ||
| 132 | } | ||
| 133 | Spacer(minLength: 0) | ||
| 134 | } | ||
| 135 | .padding(.horizontal, 16) | ||
| 136 | .padding(.top, 18) | ||
| 137 | .padding(.bottom, 16) | ||
| 138 | } | ||
| 139 | |||
| 140 | private var clearState: some View { | ||
| 141 | VStack(alignment: .leading, spacing: 12) { | ||
| 142 | header | ||
| 143 | Spacer(minLength: 0) | ||
| 144 | Text("All clear") | ||
| 145 | .font(.title3.weight(.semibold)) | ||
| 146 | Text("No unread threads, assigned tickets, or failed builds.") | ||
| 147 | .font(.caption) | ||
| 148 | .foregroundStyle(.secondary) | ||
| 149 | .fixedSize(horizontal: false, vertical: true) | ||
| 150 | } | ||
| 151 | .padding() | ||
| 152 | } | ||
| 153 | |||
| 154 | private var fallbackState: some View { | ||
| 155 | VStack(alignment: .leading, spacing: 12) { | ||
| 156 | header | ||
| 157 | Spacer(minLength: 0) | ||
| 158 | Text("Open Hutch") | ||
| 159 | .font(.title3.weight(.semibold)) | ||
| 160 | Text("Unable to load recent counts.") | ||
| 161 | .font(.caption) | ||
| 162 | .foregroundStyle(.secondary) | ||
| 163 | } | ||
| 164 | .padding() | ||
| 165 | } | ||
| 166 | |||
| 167 | private func compactMetric(count: Int?, label: String, tint: Color = .primary) -> some View { | ||
| 168 | HStack(alignment: .center, spacing: 6) { | ||
| 169 | Text(displayCount(count)) | ||
| 170 | .font(.title2.weight(.semibold)) | ||
| 171 | .monospacedDigit() | ||
| 172 | .foregroundStyle(tint) | ||
| 173 | Text(label) | ||
| 174 | .font(.subheadline) | ||
| 175 | .foregroundStyle(.secondary) | ||
| 176 | Spacer(minLength: 0) | ||
| 177 | } | ||
| 178 | } | ||
| 179 | |||
| 180 | private func metricColumn(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View { | ||
| 181 | VStack(alignment: .leading, spacing: 8) { | ||
| 182 | HStack(alignment: .center, spacing: 10) { | ||
| 183 | Image(systemName: systemImage) | ||
| 184 | .font(.title3.weight(.semibold)) | ||
| 185 | .foregroundStyle(tint) | ||
| 186 | .frame(width: 24, alignment: .leading) | ||
| 187 | .padding(.trailing, 4) | ||
| 188 | Text(displayCount(count)) | ||
| 189 | .font(.system(size: 28, weight: .semibold, design: .rounded)) | ||
| 190 | .monospacedDigit() | ||
| 191 | .lineLimit(1) | ||
| 192 | .minimumScaleFactor(0.8) | ||
| 193 | .foregroundStyle(tint) | ||
| 194 | } | ||
| 195 | Text(label) | ||
| 196 | .font(.caption) | ||
| 197 | .foregroundStyle(.secondary) | ||
| 198 | .lineLimit(1) | ||
| 199 | .minimumScaleFactor(0.85) | ||
| 200 | .frame(height: 16, alignment: .topLeading) | ||
| 201 | } | ||
| 202 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) | ||
| 203 | } | ||
| 204 | |||
| 205 | private func displayCount(_ count: Int?) -> String { | ||
| 206 | guard let count else { return "—" } | ||
| 207 | return "\(count)" | ||
| 208 | } | ||
| 209 | |||
| 210 | private func unreadTint(for count: Int?) -> Color { | ||
| 211 | guard let count, count > 0 else { return .primary } | ||
| 212 | return .blue | ||
| 213 | } | ||
| 214 | |||
| 215 | private func failedTint(for count: Int?) -> Color { | ||
| 216 | guard let count else { return .primary } | ||
| 217 | return count > 0 ? .red : .green | ||
| 218 | } | ||
| 219 | } | ||
| 220 | |||
| 221 | @main | ||
| 222 | struct HutchWidgets: WidgetBundle { | ||
| 223 | var body: some Widget { | ||
| 224 | NeedsAttentionWidget() | ||
| 225 | } | ||
| 226 | } | ||
Shared/NeedsAttentionSnapshot.swift added +109
| @@ -0,0 +1,109 @@ | |||
| 1 | import Foundation | ||
| 2 | #if canImport(WidgetKit) | ||
| 3 | import WidgetKit | ||
| 4 | #endif | ||
| 5 | |||
| 6 | enum HutchAppGroup { | ||
| 7 | static let identifier = "group.net.cleberg.Hutch" | ||
| 8 | } | ||
| 9 | |||
| 10 | enum NeedsAttentionWidgetConfiguration { | ||
| 11 | static let kind = "NeedsAttentionWidget" | ||
| 12 | } | ||
| 13 | |||
| 14 | struct NeedsAttentionSnapshot: Codable, Sendable { | ||
| 15 | let unreadInboxThreads: Int? | ||
| 16 | let assignedOpenTickets: Int? | ||
| 17 | let failedBuilds: Int? | ||
| 18 | let updatedAt: Date | ||
| 19 | |||
| 20 | static let unavailable = NeedsAttentionSnapshot( | ||
| 21 | unreadInboxThreads: nil, | ||
| 22 | assignedOpenTickets: nil, | ||
| 23 | failedBuilds: nil, | ||
| 24 | updatedAt: .now | ||
| 25 | ) | ||
| 26 | |||
| 27 | var hasAnyData: Bool { | ||
| 28 | unreadInboxThreads != nil || assignedOpenTickets != nil || failedBuilds != nil | ||
| 29 | } | ||
| 30 | |||
| 31 | var allCountsAreZero: Bool { | ||
| 32 | let counts = [unreadInboxThreads, assignedOpenTickets, failedBuilds].compactMap { $0 } | ||
| 33 | return !counts.isEmpty && counts.allSatisfy { $0 == 0 } | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | enum NeedsAttentionSnapshotStore { | ||
| 38 | private static let snapshotKey = "needsAttention.snapshot" | ||
| 39 | |||
| 40 | static func load(defaults: UserDefaults? = sharedDefaults()) -> NeedsAttentionSnapshot? { | ||
| 41 | guard let defaults, | ||
| 42 | let data = defaults.data(forKey: snapshotKey) else { | ||
| 43 | return nil | ||
| 44 | } | ||
| 45 | |||
| 46 | return try? JSONDecoder().decode(NeedsAttentionSnapshot.self, from: data) | ||
| 47 | } | ||
| 48 | |||
| 49 | static func save(_ snapshot: NeedsAttentionSnapshot, defaults: UserDefaults? = sharedDefaults()) { | ||
| 50 | guard let defaults, | ||
| 51 | let data = try? JSONEncoder().encode(snapshot) else { | ||
| 52 | return | ||
| 53 | } | ||
| 54 | |||
| 55 | defaults.set(data, forKey: snapshotKey) | ||
| 56 | reloadWidgetTimelines() | ||
| 57 | } | ||
| 58 | |||
| 59 | static func update( | ||
| 60 | unreadInboxThreads: Int? = nil, | ||
| 61 | assignedOpenTickets: Int? = nil, | ||
| 62 | failedBuilds: Int? = nil, | ||
| 63 | defaults: UserDefaults? = sharedDefaults() | ||
| 64 | ) { | ||
| 65 | let existing = load(defaults: defaults) | ||
| 66 | let snapshot = NeedsAttentionSnapshot( | ||
| 67 | unreadInboxThreads: unreadInboxThreads ?? existing?.unreadInboxThreads, | ||
| 68 | assignedOpenTickets: assignedOpenTickets ?? existing?.assignedOpenTickets, | ||
| 69 | failedBuilds: failedBuilds ?? existing?.failedBuilds, | ||
| 70 | updatedAt: .now | ||
| 71 | ) | ||
| 72 | save(snapshot, defaults: defaults) | ||
| 73 | } | ||
| 74 | |||
| 75 | static func adjustUnreadInboxThreads( | ||
| 76 | by delta: Int, | ||
| 77 | defaults: UserDefaults? = sharedDefaults() | ||
| 78 | ) { | ||
| 79 | guard let existing = load(defaults: defaults), | ||
| 80 | let unreadInboxThreads = existing.unreadInboxThreads else { | ||
| 81 | return | ||
| 82 | } | ||
| 83 | |||
| 84 | save( | ||
| 85 | NeedsAttentionSnapshot( | ||
| 86 | unreadInboxThreads: max(0, unreadInboxThreads + delta), | ||
| 87 | assignedOpenTickets: existing.assignedOpenTickets, | ||
| 88 | failedBuilds: existing.failedBuilds, | ||
| 89 | updatedAt: .now | ||
| 90 | ), | ||
| 91 | defaults: defaults | ||
| 92 | ) | ||
| 93 | } | ||
| 94 | |||
| 95 | static func clear(defaults: UserDefaults? = sharedDefaults()) { | ||
| 96 | defaults?.removeObject(forKey: snapshotKey) | ||
| 97 | reloadWidgetTimelines() | ||
| 98 | } | ||
| 99 | |||
| 100 | private static func sharedDefaults() -> UserDefaults? { | ||
| 101 | UserDefaults(suiteName: HutchAppGroup.identifier) | ||
| 102 | } | ||
| 103 | |||
| 104 | private static func reloadWidgetTimelines() { | ||
| 105 | #if canImport(WidgetKit) | ||
| 106 | WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind) | ||
| 107 | #endif | ||
| 108 | } | ||
| 109 | } | ||