Commit 8d5cd9e707

8d5cd9e7077a059dadce089a255e8bdb8e62a8d3

parent: f27a5c4fa5

Unsigned

cmc <hello@cleberg.net> · 2026-03-24 23:28 UTC

v2.4.0: widgets

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.
4enum DeepLink: Equatable { 4enum 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
60private struct HomeInboxThreadPage: Decodable, Sendable { 60private struct HomeInboxThreadPage: Decodable, Sendable {
61 let results: [HomeInboxThreadPayload] 61 let results: [HomeInboxThreadPayload]
62 let cursor: String?
62} 63}
63 64
64private struct HomeInboxThreadPayload: Decodable, Sendable { 65private 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 @@
1import SwiftUI
2import WidgetKit
3
4struct NeedsAttentionEntry: TimelineEntry {
5 let date: Date
6 let snapshot: NeedsAttentionSnapshot?
7}
8
9struct 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
38struct 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
51private 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
222struct HutchWidgets: WidgetBundle {
223 var body: some Widget {
224 NeedsAttentionWidget()
225 }
226}
Shared/NeedsAttentionSnapshot.swift added +109
@@ -0,0 +1,109 @@
1import Foundation
2#if canImport(WidgetKit)
3import WidgetKit
4#endif
5
6enum HutchAppGroup {
7 static let identifier = "group.net.cleberg.Hutch"
8}
9
10enum NeedsAttentionWidgetConfiguration {
11 static let kind = "NeedsAttentionWidget"
12}
13
14struct 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
37enum 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}