Commit 8d5cd9e707
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +172 −4
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; }; |
| 11 | 11 | 8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; }; |
| 12 | 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 | 14 | /* End PBXBuildFile section */ |
| 14 | 15 | |
| 15 | 16 | /* Begin PBXContainerItemProxy section */ |
| @@ -20,6 +21,13 @@ | ||
| 20 | 21 | remoteGlobalIDString = 8B4B28D02F6704280045FA19; |
| 21 | 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 | 31 | /* End PBXContainerItemProxy section */ |
| 24 | 32 | |
| 25 | 33 | /* Begin PBXFileReference section */ |
| @@ -30,6 +38,7 @@ | ||
| 30 | 38 | 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HutchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 31 | 39 | 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; }; |
| 32 | 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 | 42 | /* End PBXFileReference section */ |
| 34 | 43 | |
| 35 | 44 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ |
| @@ -40,6 +49,13 @@ | ||
| 40 | 49 | ); |
| 41 | 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 | 59 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ |
| 44 | 60 | |
| 45 | 61 | /* Begin PBXFileSystemSynchronizedRootGroup section */ |
| @@ -56,8 +72,35 @@ | ||
| 56 | 72 | path = HutchTests; |
| 57 | 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 | 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 | 104 | /* Begin PBXFrameworksBuildPhase section */ |
| 62 | 105 | 8B4B28CE2F6704280045FA19 /* Frameworks */ = { |
| 63 | 106 | isa = PBXFrameworksBuildPhase; |
| @@ -73,6 +116,13 @@ | ||
| 73 | 116 | ); |
| 74 | 117 | runOnlyForDeploymentPostprocessing = 0; |
| 75 | 118 | }; |
| 119 | 8BE081F92F80000100000001 /* Frameworks */ = { | |
| 120 | isa = PBXFrameworksBuildPhase; | |
| 121 | buildActionMask = 2147483647; | |
| 122 | files = ( | |
| 123 | ); | |
| 124 | runOnlyForDeploymentPostprocessing = 0; | |
| 125 | }; | |
| 76 | 126 | /* End PBXFrameworksBuildPhase section */ |
| 77 | 127 | |
| 78 | 128 | /* Begin PBXGroup section */ |
| @@ -85,6 +135,8 @@ | ||
| 85 | 135 | 8B2F89642F69DEB900FC0253 /* README.md */, |
| 86 | 136 | 8B2F89652F69DEB900FC0253 /* SECURITY.md */, |
| 87 | 137 | 8B4B28D32F6704280045FA19 /* Hutch */, |
| 138 | 8BE081F42F80000100000001 /* Shared */, | |
| 139 | 8BE081F52F80000100000001 /* HutchWidgetExtension */, | |
| 88 | 140 | 8B8182B82F6B73F3000AE049 /* HutchTests */, |
| 89 | 141 | 8B4B28D22F6704280045FA19 /* Products */, |
| 90 | 142 | ); |
| @@ -95,6 +147,7 @@ | ||
| 95 | 147 | children = ( |
| 96 | 148 | 8B4B28D12F6704280045FA19 /* Hutch.app */, |
| 97 | 149 | 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */, |
| 150 | 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */, | |
| 98 | 151 | ); |
| 99 | 152 | name = Products; |
| 100 | 153 | sourceTree = "<group>"; |
| @@ -109,13 +162,16 @@ | ||
| 109 | 162 | 8B4B28CD2F6704280045FA19 /* Sources */, |
| 110 | 163 | 8B4B28CE2F6704280045FA19 /* Frameworks */, |
| 111 | 164 | 8B4B28CF2F6704280045FA19 /* Resources */, |
| 165 | 8BE081FA2F80000100000001 /* Embed App Extensions */, | |
| 112 | 166 | ); |
| 113 | 167 | buildRules = ( |
| 114 | 168 | ); |
| 115 | 169 | dependencies = ( |
| 170 | 8BE082042F80000100000001 /* PBXTargetDependency */, | |
| 116 | 171 | ); |
| 117 | 172 | fileSystemSynchronizedGroups = ( |
| 118 | 173 | 8B4B28D32F6704280045FA19 /* Hutch */, |
| 174 | 8BE081F42F80000100000001 /* Shared */, | |
| 119 | 175 | ); |
| 120 | 176 | name = Hutch; |
| 121 | 177 | packageProductDependencies = ( |
| @@ -147,6 +203,29 @@ | ||
| 147 | 203 | productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */; |
| 148 | 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 | 229 | /* End PBXNativeTarget section */ |
| 151 | 230 | |
| 152 | 231 | /* Begin PBXProject section */ |
| @@ -182,6 +261,7 @@ | ||
| 182 | 261 | targets = ( |
| 183 | 262 | 8B4B28D02F6704280045FA19 /* Hutch */, |
| 184 | 263 | 8B8182B62F6B73F3000AE049 /* HutchTests */, |
| 264 | 8BE081F72F80000100000001 /* HutchWidgetExtension */, | |
| 185 | 265 | ); |
| 186 | 266 | }; |
| 187 | 267 | /* End PBXProject section */ |
| @@ -204,6 +284,13 @@ | ||
| 204 | 284 | ); |
| 205 | 285 | runOnlyForDeploymentPostprocessing = 0; |
| 206 | 286 | }; |
| 287 | 8BE081FB2F80000100000001 /* Resources */ = { | |
| 288 | isa = PBXResourcesBuildPhase; | |
| 289 | buildActionMask = 2147483647; | |
| 290 | files = ( | |
| 291 | ); | |
| 292 | runOnlyForDeploymentPostprocessing = 0; | |
| 293 | }; | |
| 207 | 294 | /* End PBXResourcesBuildPhase section */ |
| 208 | 295 | |
| 209 | 296 | /* Begin PBXSourcesBuildPhase section */ |
| @@ -221,6 +308,13 @@ | ||
| 221 | 308 | ); |
| 222 | 309 | runOnlyForDeploymentPostprocessing = 0; |
| 223 | 310 | }; |
| 311 | 8BE081F82F80000100000001 /* Sources */ = { | |
| 312 | isa = PBXSourcesBuildPhase; | |
| 313 | buildActionMask = 2147483647; | |
| 314 | files = ( | |
| 315 | ); | |
| 316 | runOnlyForDeploymentPostprocessing = 0; | |
| 317 | }; | |
| 224 | 318 | /* End PBXSourcesBuildPhase section */ |
| 225 | 319 | |
| 226 | 320 | /* Begin PBXTargetDependency section */ |
| @@ -229,6 +323,11 @@ | ||
| 229 | 323 | target = 8B4B28D02F6704280045FA19 /* Hutch */; |
| 230 | 324 | targetProxy = 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */; |
| 231 | 325 | }; |
| 326 | 8BE082042F80000100000001 /* PBXTargetDependency */ = { | |
| 327 | isa = PBXTargetDependency; | |
| 328 | target = 8BE081F72F80000100000001 /* HutchWidgetExtension */; | |
| 329 | targetProxy = 8BE082022F80000100000001 /* PBXContainerItemProxy */; | |
| 330 | }; | |
| 232 | 331 | /* End PBXTargetDependency section */ |
| 233 | 332 | |
| 234 | 333 | /* Begin XCBuildConfiguration section */ |
| @@ -358,8 +457,9 @@ | ||
| 358 | 457 | buildSettings = { |
| 359 | 458 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 360 | 459 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 460 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | |
| 361 | 461 | CODE_SIGN_STYLE = Automatic; |
| 362 | CURRENT_PROJECT_VERSION = 9; | |
| 462 | CURRENT_PROJECT_VERSION = 10; | |
| 363 | 463 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 364 | 464 | ENABLE_PREVIEWS = YES; |
| 365 | 465 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -376,7 +476,7 @@ | ||
| 376 | 476 | "$(inherited)", |
| 377 | 477 | "@executable_path/Frameworks", |
| 378 | 478 | ); |
| 379 | MARKETING_VERSION = 2.3.1; | |
| 479 | MARKETING_VERSION = 2.4.0; | |
| 380 | 480 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 381 | 481 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 382 | 482 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -394,8 +494,9 @@ | ||
| 394 | 494 | buildSettings = { |
| 395 | 495 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 396 | 496 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 497 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | |
| 397 | 498 | CODE_SIGN_STYLE = Automatic; |
| 398 | CURRENT_PROJECT_VERSION = 9; | |
| 499 | CURRENT_PROJECT_VERSION = 10; | |
| 399 | 500 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 400 | 501 | ENABLE_PREVIEWS = YES; |
| 401 | 502 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -412,7 +513,7 @@ | ||
| 412 | 513 | "$(inherited)", |
| 413 | 514 | "@executable_path/Frameworks", |
| 414 | 515 | ); |
| 415 | MARKETING_VERSION = 2.3.1; | |
| 516 | MARKETING_VERSION = 2.4.0; | |
| 416 | 517 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 417 | 518 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 418 | 519 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -469,6 +570,64 @@ | ||
| 469 | 570 | }; |
| 470 | 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 | 631 | /* End XCBuildConfiguration section */ |
| 473 | 632 | |
| 474 | 633 | /* Begin XCConfigurationList section */ |
| @@ -499,6 +658,15 @@ | ||
| 499 | 658 | defaultConfigurationIsVisible = 0; |
| 500 | 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 | 670 | /* End XCConfigurationList section */ |
| 503 | 671 | }; |
| 504 | 672 | rootObject = 8B4B28C92F6704280045FA19 /* Project object */; |
Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata +8
| @@ -4,4 +4,12 @@ | ||
| 4 | 4 | <FileRef |
| 5 | 5 | location = "self:"> |
| 6 | 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 | 15 | </Workspace> |
Hutch/App/AppState.swift +15
| @@ -77,11 +77,13 @@ final class AppState { | ||
| 77 | 77 | let user = try await fetchMe() |
| 78 | 78 | currentUser = user |
| 79 | 79 | authPhase = .authenticated |
| 80 | await refreshNeedsAttentionSnapshot() | |
| 80 | 81 | } catch { |
| 81 | 82 | try? KeychainHelper.deleteToken() |
| 82 | 83 | client.setToken(nil) |
| 83 | 84 | currentUser = nil |
| 84 | 85 | authPhase = .unauthenticated |
| 86 | NeedsAttentionSnapshotStore.clear() | |
| 85 | 87 | } |
| 86 | 88 | } |
| 87 | 89 | |
| @@ -98,6 +100,7 @@ final class AppState { | ||
| 98 | 100 | try KeychainHelper.saveToken(token) |
| 99 | 101 | currentUser = user |
| 100 | 102 | authPhase = .authenticated |
| 103 | await refreshNeedsAttentionSnapshot() | |
| 101 | 104 | } catch { |
| 102 | 105 | // Roll back — don't leave an invalid token in the client. |
| 103 | 106 | client.setToken(nil) |
| @@ -111,6 +114,7 @@ final class AppState { | ||
| 111 | 114 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } |
| 112 | 115 | await clearWebData() |
| 113 | 116 | clearWebContentRenderCaches() |
| 117 | NeedsAttentionSnapshotStore.clear() | |
| 114 | 118 | authPhase = .unauthenticated |
| 115 | 119 | selectedTab = .home |
| 116 | 120 | } |
| @@ -125,6 +129,7 @@ final class AppState { | ||
| 125 | 129 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } |
| 126 | 130 | await clearWebData() |
| 127 | 131 | clearWebContentRenderCaches() |
| 132 | NeedsAttentionSnapshotStore.clear() | |
| 128 | 133 | |
| 129 | 134 | authPhase = .unauthenticated |
| 130 | 135 | selectedTab = .home |
| @@ -270,6 +275,16 @@ final class AppState { | ||
| 270 | 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 | 288 | private func clearWebData() async { |
| 274 | 289 | await withCheckedContinuation { continuation in |
| 275 | 290 | let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes() |
Hutch/App/DeepLink.swift +4
| @@ -2,6 +2,7 @@ import Foundation | ||
| 2 | 2 | |
| 3 | 3 | /// Represents a parsed `hutch://` deep link. |
| 4 | 4 | enum DeepLink: Equatable { |
| 5 | case home | |
| 5 | 6 | /// hutch://git/<owner>/<repo> |
| 6 | 7 | case repository(owner: String, repo: String) |
| 7 | 8 | /// hutch://todo/<owner>/<tracker>/<ticketId> |
| @@ -19,6 +20,9 @@ enum DeepLink: Equatable { | ||
| 19 | 20 | let components = url.pathComponents(fromScheme: "hutch") |
| 20 | 21 | |
| 21 | 22 | switch components.first { |
| 23 | case "home", nil: | |
| 24 | self = .home | |
| 25 | ||
| 22 | 26 | case "git" where components.count >= 3: |
| 23 | 27 | let owner = components[1] |
| 24 | 28 | let repo = components[2] |
Hutch/App/RootView.swift +7
| @@ -161,6 +161,10 @@ struct RootView: View { | ||
| 161 | 161 | guard appState.isAuthenticated else { return } |
| 162 | 162 | |
| 163 | 163 | switch link { |
| 164 | case .home: | |
| 165 | homePath = NavigationPath() | |
| 166 | appState.selectedTab = .home | |
| 167 | ||
| 164 | 168 | case .repository(let owner, let repo): |
| 165 | 169 | resolveRepositoryLink(owner: owner, repo: repo) |
| 166 | 170 | |
| @@ -284,12 +288,15 @@ private struct MoreNavigationRoot: View { | ||
| 284 | 288 | thread: thread, |
| 285 | 289 | onViewed: { |
| 286 | 290 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) |
| 291 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | |
| 287 | 292 | }, |
| 288 | 293 | onMarkRead: { |
| 289 | 294 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) |
| 295 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | |
| 290 | 296 | }, |
| 291 | 297 | onMarkUnread: { |
| 292 | 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 | 60 | private struct HomeInboxThreadPage: Decodable, Sendable { |
| 61 | 61 | let results: [HomeInboxThreadPayload] |
| 62 | let cursor: String? | |
| 62 | 63 | } |
| 63 | 64 | |
| 64 | 65 | private struct HomeInboxThreadPayload: Decodable, Sendable { |
| @@ -143,6 +144,7 @@ final class HomeViewModel { | ||
| 143 | 144 | var assignedTickets: [HomeAssignedTicket] = [] |
| 144 | 145 | var recentBuilds: [HomeBuildItem] = [] |
| 145 | 146 | private(set) var hasUnreadInboxThreads = false |
| 147 | private(set) var unreadInboxThreadCount: Int? | |
| 146 | 148 | private(set) var isLoadingProjects = false |
| 147 | 149 | private(set) var isLoadingAssignedTickets = false |
| 148 | 150 | private(set) var isLoadingRecentBuilds = false |
| @@ -154,7 +156,6 @@ final class HomeViewModel { | ||
| 154 | 156 | private let projectService: ProjectService |
| 155 | 157 | private let ticketFetchConcurrencyLimit = 6 |
| 156 | 158 | private let inboxUnreadConcurrencyLimit = 4 |
| 157 | private let inboxUnreadThreadPreviewLimit = 10 | |
| 158 | 159 | |
| 159 | 160 | private static let jobsQuery = """ |
| 160 | 161 | query jobs { |
| @@ -232,13 +233,14 @@ final class HomeViewModel { | ||
| 232 | 233 | """ |
| 233 | 234 | |
| 234 | 235 | private static let inboxListThreadsQuery = """ |
| 235 | query inboxListThreads($rid: ID!) { | |
| 236 | query inboxListThreads($rid: ID!, $cursor: Cursor) { | |
| 236 | 237 | list(rid: $rid) { |
| 237 | threads { | |
| 238 | threads(cursor: $cursor) { | |
| 238 | 239 | results { |
| 239 | 240 | updated |
| 240 | 241 | subject |
| 241 | 242 | } |
| 243 | cursor | |
| 242 | 244 | } |
| 243 | 245 | } |
| 244 | 246 | } |
| @@ -280,7 +282,7 @@ final class HomeViewModel { | ||
| 280 | 282 | async let projectsTask = loadProjects() |
| 281 | 283 | async let jobsTask = loadRecentJobs() |
| 282 | 284 | async let assignedTicketsTask = loadAssignedTickets() |
| 283 | async let inboxUnreadTask = loadInboxUnreadState() | |
| 285 | async let inboxUnreadTask = loadInboxUnreadCount() | |
| 284 | 286 | |
| 285 | 287 | let projectsResult = await projectsTask |
| 286 | 288 | switch projectsResult { |
| @@ -316,7 +318,9 @@ final class HomeViewModel { | ||
| 316 | 318 | } |
| 317 | 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 | 326 | func resolveTicket(_ ticket: HomeAssignedTicket) async { |
| @@ -347,6 +351,7 @@ final class HomeViewModel { | ||
| 347 | 351 | responseType: UnassignResponse.self |
| 348 | 352 | ) |
| 349 | 353 | assignedTickets.removeAll { $0.id == ticket.id } |
| 354 | persistNeedsAttentionSnapshot() | |
| 350 | 355 | } catch { |
| 351 | 356 | homeLogger.error("Unassign from me failed: \(error, privacy: .public)") |
| 352 | 357 | } |
| @@ -380,6 +385,7 @@ final class HomeViewModel { | ||
| 380 | 385 | repositoryOwner: build.repositoryOwner |
| 381 | 386 | ) |
| 382 | 387 | } |
| 388 | persistNeedsAttentionSnapshot() | |
| 383 | 389 | } catch { |
| 384 | 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 | 416 | do { |
| 411 | return try await fetchHasUnreadInboxThreads() | |
| 417 | return try await fetchUnreadInboxThreadCount() | |
| 412 | 418 | } catch { |
| 413 | 419 | return nil |
| 414 | 420 | } |
| 415 | 421 | } |
| 416 | 422 | |
| 417 | private func fetchHasUnreadInboxThreads() async throws -> Bool { | |
| 423 | private func fetchUnreadInboxThreadCount() async throws -> Int { | |
| 418 | 424 | let mailingLists = try await fetchInboxMailingLists() |
| 419 | guard !mailingLists.isEmpty else { return false } | |
| 425 | guard !mailingLists.isEmpty else { return 0 } | |
| 420 | 426 | |
| 421 | 427 | var startIndex = mailingLists.startIndex |
| 428 | var unreadCount = 0 | |
| 429 | var successfulFetchCount = 0 | |
| 422 | 430 | while startIndex < mailingLists.endIndex { |
| 423 | 431 | let endIndex = mailingLists.index( |
| 424 | 432 | startIndex, |
| @@ -427,30 +435,41 @@ final class HomeViewModel { | ||
| 427 | 435 | ) ?? mailingLists.endIndex |
| 428 | 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 | 439 | for mailingList in batch { |
| 432 | 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 { | |
| 438 | if hasUnread { | |
| 439 | group.cancelAll() | |
| 440 | return true | |
| 449 | var counts: [Int] = [] | |
| 450 | var errors: [Error] = [] | |
| 451 | for await result in group { | |
| 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 { | |
| 447 | return true | |
| 448 | } | |
| 462 | unreadCount += batchResult.0.reduce(0, +) | |
| 463 | successfulFetchCount += batchResult.0.count | |
| 449 | 464 | |
| 450 | 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 | 475 | private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] { |
| @@ -481,23 +500,40 @@ final class HomeViewModel { | ||
| 481 | 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 { | |
| 485 | let response = try await client.execute( | |
| 486 | service: .lists, | |
| 487 | query: Self.inboxListThreadsQuery, | |
| 488 | variables: ["rid": mailingList.rid], | |
| 489 | responseType: HomeInboxListThreadsResponse.self | |
| 490 | ) | |
| 503 | private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int { | |
| 504 | var unreadCount = 0 | |
| 505 | var cursor: String? | |
| 506 | ||
| 507 | while true { | |
| 508 | var variables: [String: any Sendable] = ["rid": mailingList.rid] | |
| 509 | if let cursor { | |
| 510 | variables["cursor"] = cursor | |
| 511 | } | |
| 491 | 512 | |
| 492 | return response.list.threads.results.prefix(inboxUnreadThreadPreviewLimit).contains { thread in | |
| 493 | let normalizedSubject = thread.subject | |
| 494 | .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) | |
| 495 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 496 | .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]) | |
| 497 | .lowercased() | |
| 498 | let threadID = "\(mailingList.rid)#\(normalizedSubject)" | |
| 499 | return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) | |
| 513 | let response = try await client.execute( | |
| 514 | service: .lists, | |
| 515 | query: Self.inboxListThreadsQuery, | |
| 516 | variables: variables, | |
| 517 | responseType: HomeInboxListThreadsResponse.self | |
| 518 | ) | |
| 519 | ||
| 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 | 539 | private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { |
| @@ -616,11 +652,32 @@ final class HomeViewModel { | ||
| 616 | 652 | responseType: StatusEventResponse.self |
| 617 | 653 | ) |
| 618 | 654 | assignedTickets.removeAll { $0.id == ticket.id } |
| 655 | persistNeedsAttentionSnapshot() | |
| 619 | 656 | } catch { |
| 620 | 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 | 681 | nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { |
| 625 | 682 | jobs.map { job in |
| 626 | 683 | let repository = primaryRepositoryReference(in: job.manifest) |
Hutch/Views/Inbox/InboxViewModel.swift +3
| @@ -136,6 +136,7 @@ final class InboxViewModel { | ||
| 136 | 136 | } |
| 137 | 137 | return lhs.lastActivityAt > rhs.lastActivityAt |
| 138 | 138 | } |
| 139 | NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count) | |
| 139 | 140 | } catch { |
| 140 | 141 | inboxListLogger.error("Inbox request failed") |
| 141 | 142 | self.error = "Failed to load inbox" |
| @@ -146,11 +147,13 @@ final class InboxViewModel { | ||
| 146 | 147 | let viewedAt = max(Date(), thread.lastActivityAt) |
| 147 | 148 | InboxReadStateStore.markViewed(viewedAt, for: thread.id) |
| 148 | 149 | threads.removeAll { $0.id == thread.id } |
| 150 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | |
| 149 | 151 | } |
| 150 | 152 | |
| 151 | 153 | func markThreadUnread(_ thread: InboxThreadSummary) { |
| 152 | 154 | InboxReadStateStore.markUnread(for: thread.id) |
| 153 | 155 | updateThread(thread, isUnread: true) |
| 156 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | |
| 154 | 157 | } |
| 155 | 158 | |
| 156 | 159 | func toggleThreadReadState(_ thread: InboxThreadSummary) { |
Hutch/Views/Projects/ProjectMailingListView.swift +2
| @@ -92,11 +92,13 @@ final class MailingListDetailViewModel { | ||
| 92 | 92 | let viewedAt = max(Date(), thread.lastActivityAt) |
| 93 | 93 | InboxReadStateStore.markViewed(viewedAt, for: thread.id) |
| 94 | 94 | updateThread(thread, isUnread: false) |
| 95 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | |
| 95 | 96 | } |
| 96 | 97 | |
| 97 | 98 | func markThreadUnread(_ thread: InboxThreadSummary) { |
| 98 | 99 | InboxReadStateStore.markUnread(for: thread.id) |
| 99 | 100 | updateThread(thread, isUnread: true) |
| 101 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | |
| 100 | 102 | } |
| 101 | 103 | |
| 102 | 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 | } | |