Commit 258ce2b73b
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | |||
| 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | CODE_SIGN_STYLE = Automatic; | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 31; | 518 | CURRENT_PROJECT_VERSION = 32; |
| 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | ENABLE_PREVIEWS = YES; | 520 | ENABLE_PREVIEWS = YES; |
| 521 | GENERATE_INFOPLIST_FILE = YES; | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | |||
| 532 | "$(inherited)", | 532 | "$(inherited)", |
| 533 | "@executable_path/Frameworks", | 533 | "@executable_path/Frameworks", |
| 534 | ); | 534 | ); |
| 535 | MARKETING_VERSION = 2.14.0; | 535 | MARKETING_VERSION = 2.15.0; |
| 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | PRODUCT_NAME = "$(TARGET_NAME)"; | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | |||
| 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | CODE_SIGN_STYLE = Automatic; | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 31; | 555 | CURRENT_PROJECT_VERSION = 32; |
| 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | ENABLE_PREVIEWS = YES; | 557 | ENABLE_PREVIEWS = YES; |
| 558 | GENERATE_INFOPLIST_FILE = YES; | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | |||
| 569 | "$(inherited)", | 569 | "$(inherited)", |
| 570 | "@executable_path/Frameworks", | 570 | "@executable_path/Frameworks", |
| 571 | ); | 571 | ); |
| 572 | MARKETING_VERSION = 2.14.0; | 572 | MARKETING_VERSION = 2.15.0; |
| 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | PRODUCT_NAME = "$(TARGET_NAME)"; | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | |||
| 632 | APPLICATION_EXTENSION_API_ONLY = YES; | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | CODE_SIGN_STYLE = Automatic; | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 31; | 635 | CURRENT_PROJECT_VERSION = 32; |
| 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | GENERATE_INFOPLIST_FILE = NO; | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | |||
| 642 | "@executable_path/Frameworks", | 642 | "@executable_path/Frameworks", |
| 643 | "@executable_path/../../Frameworks", | 643 | "@executable_path/../../Frameworks", |
| 644 | ); | 644 | ); |
| 645 | MARKETING_VERSION = 2.14.0; | 645 | MARKETING_VERSION = 2.15.0; |
| 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | PRODUCT_NAME = "$(TARGET_NAME)"; | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | SKIP_INSTALL = YES; | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | |||
| 661 | APPLICATION_EXTENSION_API_ONLY = YES; | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | CODE_SIGN_STYLE = Automatic; | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 31; | 664 | CURRENT_PROJECT_VERSION = 32; |
| 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | GENERATE_INFOPLIST_FILE = NO; | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | |||
| 671 | "@executable_path/Frameworks", | 671 | "@executable_path/Frameworks", |
| 672 | "@executable_path/../../Frameworks", | 672 | "@executable_path/../../Frameworks", |
| 673 | ); | 673 | ); |
| 674 | MARKETING_VERSION = 2.14.0; | 674 | MARKETING_VERSION = 2.15.0; |
| 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | PRODUCT_NAME = "$(TARGET_NAME)"; | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | SKIP_INSTALL = YES; | 677 | SKIP_INSTALL = YES; |
Hutch/App/AppState.swift +2
| @@ -203,6 +203,7 @@ final class AppState { | |||
| 203 | await clearWebData() | 203 | await clearWebData() |
| 204 | clearWebContentRenderCaches() | 204 | clearWebContentRenderCaches() |
| 205 | NeedsAttentionSnapshotStore.clear() | 205 | NeedsAttentionSnapshotStore.clear() |
| 206 | SystemStatusWidgetSnapshotStore.clear() | ||
| 206 | authPhase = .unauthenticated | 207 | authPhase = .unauthenticated |
| 207 | selectedTab = .home | 208 | selectedTab = .home |
| 208 | } | 209 | } |
| @@ -218,6 +219,7 @@ final class AppState { | |||
| 218 | await clearWebData() | 219 | await clearWebData() |
| 219 | clearWebContentRenderCaches() | 220 | clearWebContentRenderCaches() |
| 220 | NeedsAttentionSnapshotStore.clear() | 221 | NeedsAttentionSnapshotStore.clear() |
| 222 | SystemStatusWidgetSnapshotStore.clear() | ||
| 221 | 223 | ||
| 222 | authPhase = .unauthenticated | 224 | authPhase = .unauthenticated |
| 223 | selectedTab = .home | 225 | selectedTab = .home |
Hutch/App/DeepLink.swift +25
| @@ -9,6 +9,16 @@ enum DeepLink: Equatable { | |||
| 9 | case ticket(owner: String, tracker: String, ticketId: Int) | 9 | case ticket(owner: String, tracker: String, ticketId: Int) |
| 10 | /// hutch://builds/<jobId> | 10 | /// hutch://builds/<jobId> |
| 11 | case build(jobId: Int) | 11 | case build(jobId: Int) |
| 12 | /// hutch://builds (tab-level) | ||
| 13 | case buildsTab | ||
| 14 | /// hutch://repositories (tab-level) | ||
| 15 | case repositoriesTab | ||
| 16 | /// hutch://trackers (tab-level) | ||
| 17 | case trackersTab | ||
| 18 | /// hutch://status | ||
| 19 | case systemStatus | ||
| 20 | /// hutch://lookup | ||
| 21 | case lookup | ||
| 12 | 22 | ||
| 13 | /// Attempt to parse a URL into a DeepLink. | 23 | /// Attempt to parse a URL into a DeepLink. |
| 14 | /// Expected format: hutch://<path> | 24 | /// Expected format: hutch://<path> |
| @@ -38,6 +48,21 @@ enum DeepLink: Equatable { | |||
| 38 | guard let jobId = Int(components[1]) else { return nil } | 48 | guard let jobId = Int(components[1]) else { return nil } |
| 39 | self = .build(jobId: jobId) | 49 | self = .build(jobId: jobId) |
| 40 | 50 | ||
| 51 | case "builds": | ||
| 52 | self = .buildsTab | ||
| 53 | |||
| 54 | case "repositories": | ||
| 55 | self = .repositoriesTab | ||
| 56 | |||
| 57 | case "trackers": | ||
| 58 | self = .trackersTab | ||
| 59 | |||
| 60 | case "status": | ||
| 61 | self = .systemStatus | ||
| 62 | |||
| 63 | case "lookup": | ||
| 64 | self = .lookup | ||
| 65 | |||
| 41 | default: | 66 | default: |
| 42 | return nil | 67 | return nil |
| 43 | } | 68 | } |
Hutch/App/HutchApp.swift +15
| @@ -15,6 +15,21 @@ struct HutchApp: App { | |||
| 15 | appState.pendingDeepLink = link | 15 | appState.pendingDeepLink = link |
| 16 | } | 16 | } |
| 17 | } | 17 | } |
| 18 | .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in | ||
| 19 | guard let destination else { return } | ||
| 20 | HutchIntentNavigator.shared.pendingDestination = nil | ||
| 21 | let link: DeepLink | ||
| 22 | switch destination { | ||
| 23 | case .home: link = .home | ||
| 24 | case .inbox: link = .home | ||
| 25 | case .builds: link = .buildsTab | ||
| 26 | case .repositories: link = .repositoriesTab | ||
| 27 | case .trackers: link = .trackersTab | ||
| 28 | case .systemStatus: link = .systemStatus | ||
| 29 | case .lookup: link = .lookup | ||
| 30 | } | ||
| 31 | appState.pendingDeepLink = link | ||
| 32 | } | ||
| 18 | } | 33 | } |
| 19 | } | 34 | } |
| 20 | } | 35 | } |
Hutch/App/HutchIntents.swift added +154
| @@ -0,0 +1,154 @@ | |||
| 1 | import AppIntents | ||
| 2 | import Foundation | ||
| 3 | |||
| 4 | // MARK: - Open Hutch Intent | ||
| 5 | |||
| 6 | enum HutchDestination: String, AppEnum { | ||
| 7 | case home | ||
| 8 | case inbox | ||
| 9 | case builds | ||
| 10 | case repositories | ||
| 11 | case trackers | ||
| 12 | case systemStatus | ||
| 13 | case lookup | ||
| 14 | |||
| 15 | static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Hutch Section") | ||
| 16 | |||
| 17 | static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [ | ||
| 18 | .home: "Home", | ||
| 19 | .inbox: "Inbox", | ||
| 20 | .builds: "Builds", | ||
| 21 | .repositories: "Repositories", | ||
| 22 | .trackers: "Trackers", | ||
| 23 | .systemStatus: "System Status", | ||
| 24 | .lookup: "Look Up" | ||
| 25 | ] | ||
| 26 | } | ||
| 27 | |||
| 28 | struct OpenHutchIntent: AppIntent { | ||
| 29 | static var title: LocalizedStringResource = "Open Hutch" | ||
| 30 | static var description = IntentDescription("Opens Hutch to a specific section.") | ||
| 31 | static var openAppWhenRun = true | ||
| 32 | |||
| 33 | @Parameter(title: "Section", default: .home) | ||
| 34 | var destination: HutchDestination | ||
| 35 | |||
| 36 | @MainActor | ||
| 37 | func perform() async throws -> some IntentResult { | ||
| 38 | HutchIntentNavigator.shared.pendingDestination = destination | ||
| 39 | return .result() | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | // MARK: - Check System Status Intent | ||
| 44 | |||
| 45 | struct CheckSystemStatusIntent: AppIntent { | ||
| 46 | static var title: LocalizedStringResource = "Check SourceHut Status" | ||
| 47 | static var description = IntentDescription("Returns the current SourceHut system status.") | ||
| 48 | |||
| 49 | @MainActor | ||
| 50 | func perform() async throws -> some IntentResult & ReturnsValue<String> { | ||
| 51 | guard let snapshot = SystemStatusWidgetSnapshotStore.load() else { | ||
| 52 | return .result(value: "System status is unavailable. Open Hutch to refresh.") | ||
| 53 | } | ||
| 54 | |||
| 55 | if snapshot.hasDisruption { | ||
| 56 | let disrupted = snapshot.services | ||
| 57 | .filter { $0.requiresAttention } | ||
| 58 | .map { "\($0.name): \($0.status)" } | ||
| 59 | .joined(separator: ", ") | ||
| 60 | return .result(value: "SourceHut disruption detected: \(disrupted)") | ||
| 61 | } | ||
| 62 | |||
| 63 | return .result(value: "All SourceHut services operational.") | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | // MARK: - Check Builds Intent | ||
| 68 | |||
| 69 | struct CheckBuildsIntent: AppIntent { | ||
| 70 | static var title: LocalizedStringResource = "Check Hutch Builds" | ||
| 71 | static var description = IntentDescription("Returns a summary of your recent build status.") | ||
| 72 | |||
| 73 | @MainActor | ||
| 74 | func perform() async throws -> some IntentResult & ReturnsValue<String> { | ||
| 75 | guard let snapshot = NeedsAttentionSnapshotStore.load() else { | ||
| 76 | return .result(value: "Build status unavailable. Open Hutch to refresh.") | ||
| 77 | } | ||
| 78 | |||
| 79 | var parts: [String] = [] | ||
| 80 | |||
| 81 | if let failed = snapshot.failedBuilds { | ||
| 82 | if failed > 0 { | ||
| 83 | parts.append("\(failed) failed build\(failed == 1 ? "" : "s")") | ||
| 84 | } else { | ||
| 85 | parts.append("No failed builds") | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | if let unread = snapshot.unreadInboxThreads, unread > 0 { | ||
| 90 | parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")") | ||
| 91 | } | ||
| 92 | |||
| 93 | if let assigned = snapshot.assignedOpenTickets, assigned > 0 { | ||
| 94 | parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")") | ||
| 95 | } | ||
| 96 | |||
| 97 | if parts.isEmpty { | ||
| 98 | return .result(value: "No recent data. Open Hutch to refresh.") | ||
| 99 | } | ||
| 100 | |||
| 101 | return .result(value: parts.joined(separator: ". ") + ".") | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | // MARK: - Shortcuts Provider | ||
| 106 | |||
| 107 | struct HutchShortcuts: AppShortcutsProvider { | ||
| 108 | static var appShortcuts: [AppShortcut] { | ||
| 109 | AppShortcut( | ||
| 110 | intent: OpenHutchIntent(), | ||
| 111 | phrases: [ | ||
| 112 | "Open \(.applicationName)", | ||
| 113 | "Open \(.applicationName) \(\.$destination)", | ||
| 114 | "Show my \(.applicationName) \(\.$destination)", | ||
| 115 | "Go to \(\.$destination) in \(.applicationName)" | ||
| 116 | ], | ||
| 117 | shortTitle: "Open Hutch", | ||
| 118 | systemImageName: "house" | ||
| 119 | ) | ||
| 120 | |||
| 121 | AppShortcut( | ||
| 122 | intent: CheckSystemStatusIntent(), | ||
| 123 | phrases: [ | ||
| 124 | "Check \(.applicationName) status", | ||
| 125 | "Is SourceHut up in \(.applicationName)", | ||
| 126 | "SourceHut status in \(.applicationName)" | ||
| 127 | ], | ||
| 128 | shortTitle: "Check SourceHut Status", | ||
| 129 | systemImageName: "server.rack" | ||
| 130 | ) | ||
| 131 | |||
| 132 | AppShortcut( | ||
| 133 | intent: CheckBuildsIntent(), | ||
| 134 | phrases: [ | ||
| 135 | "Check my \(.applicationName) builds", | ||
| 136 | "How are my builds in \(.applicationName)", | ||
| 137 | "Build status in \(.applicationName)" | ||
| 138 | ], | ||
| 139 | shortTitle: "Check Builds", | ||
| 140 | systemImageName: "hammer" | ||
| 141 | ) | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | // MARK: - Intent Navigator | ||
| 146 | |||
| 147 | @MainActor | ||
| 148 | @Observable | ||
| 149 | final class HutchIntentNavigator { | ||
| 150 | static let shared = HutchIntentNavigator() | ||
| 151 | var pendingDestination: HutchDestination? | ||
| 152 | |||
| 153 | private init() {} | ||
| 154 | } | ||
Hutch/App/RootView.swift +71
| @@ -113,6 +113,11 @@ struct RootView: View { | |||
| 113 | Label("More", systemImage: "ellipsis.circle") | 113 | Label("More", systemImage: "ellipsis.circle") |
| 114 | } | 114 | } |
| 115 | } | 115 | } |
| 116 | .modifier(SidebarAdaptableTabStyle()) | ||
| 117 | .modifier(TabKeyboardShortcuts(selectedTab: Binding( | ||
| 118 | get: { appState.selectedTab }, | ||
| 119 | set: { appState.selectedTab = $0 } | ||
| 120 | ))) | ||
| 116 | .overlay { | 121 | .overlay { |
| 117 | if isResolvingDeepLink { | 122 | if isResolvingDeepLink { |
| 118 | ZStack { | 123 | ZStack { |
| @@ -178,6 +183,29 @@ struct RootView: View { | |||
| 178 | 183 | ||
| 179 | case .ticket(let owner, let tracker, let ticketId): | 184 | case .ticket(let owner, let tracker, let ticketId): |
| 180 | resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId) | 185 | resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId) |
| 186 | |||
| 187 | case .buildsTab: | ||
| 188 | buildsPath = NavigationPath() | ||
| 189 | appState.selectedTab = .builds | ||
| 190 | |||
| 191 | case .repositoriesTab: | ||
| 192 | repoPath = NavigationPath() | ||
| 193 | appState.selectedTab = .repositories | ||
| 194 | |||
| 195 | case .trackersTab: | ||
| 196 | ticketsPath = NavigationPath() | ||
| 197 | appState.selectedTab = .tickets | ||
| 198 | |||
| 199 | case .systemStatus: | ||
| 200 | appState.navigateToSystemStatus() | ||
| 201 | |||
| 202 | case .lookup: | ||
| 203 | morePath = NavigationPath() | ||
| 204 | appState.selectedTab = .more | ||
| 205 | Task { | ||
| 206 | await settleNavigationTransition() | ||
| 207 | morePath.append(MoreRoute.lookup) | ||
| 208 | } | ||
| 181 | } | 209 | } |
| 182 | } | 210 | } |
| 183 | 211 | ||
| @@ -336,3 +364,46 @@ struct TicketDeepLinkTarget: Hashable { | |||
| 336 | let trackerRid: String | 364 | let trackerRid: String |
| 337 | let ticketId: Int | 365 | let ticketId: Int |
| 338 | } | 366 | } |
| 367 | |||
| 368 | // MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard | ||
| 369 | |||
| 370 | /// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad. | ||
| 371 | private struct TabKeyboardShortcuts: ViewModifier { | ||
| 372 | @Binding var selectedTab: AppState.Tab | ||
| 373 | |||
| 374 | private static let tabMap: [String: AppState.Tab] = [ | ||
| 375 | "1": .home, | ||
| 376 | "2": .repositories, | ||
| 377 | "3": .tickets, | ||
| 378 | "4": .builds, | ||
| 379 | "5": .more, | ||
| 380 | ] | ||
| 381 | |||
| 382 | func body(content: Content) -> some View { | ||
| 383 | content | ||
| 384 | .onKeyPress(characters: .decimalDigits, phases: .down) { press in | ||
| 385 | guard press.modifiers == .command else { return .ignored } | ||
| 386 | let key = String(press.characters) | ||
| 387 | if let tab = Self.tabMap[key] { | ||
| 388 | selectedTab = tab | ||
| 389 | return .handled | ||
| 390 | } | ||
| 391 | return .ignored | ||
| 392 | } | ||
| 393 | } | ||
| 394 | } | ||
| 395 | |||
| 396 | // MARK: - iPad Sidebar Adaptable | ||
| 397 | |||
| 398 | /// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar | ||
| 399 | /// becomes a full sidebar on iPad, while falling back to the standard tab | ||
| 400 | /// bar on earlier releases. | ||
| 401 | private struct SidebarAdaptableTabStyle: ViewModifier { | ||
| 402 | func body(content: Content) -> some View { | ||
| 403 | if #available(iOS 18.0, *) { | ||
| 404 | content.tabViewStyle(.sidebarAdaptable) | ||
| 405 | } else { | ||
| 406 | content | ||
| 407 | } | ||
| 408 | } | ||
| 409 | } | ||
Hutch/Views/Home/HomeViewModel.swift +22
| @@ -388,6 +388,7 @@ final class HomeViewModel { | |||
| 388 | } | 388 | } |
| 389 | isLoadingSystemStatus = false | 389 | isLoadingSystemStatus = false |
| 390 | persistNeedsAttentionSnapshot() | 390 | persistNeedsAttentionSnapshot() |
| 391 | persistSystemStatusWidgetSnapshot() | ||
| 391 | } | 392 | } |
| 392 | 393 | ||
| 393 | var hasDashboardContent: Bool { | 394 | var hasDashboardContent: Bool { |
| @@ -928,6 +929,27 @@ final class HomeViewModel { | |||
| 928 | ) | 929 | ) |
| 929 | } | 930 | } |
| 930 | 931 | ||
| 932 | private func persistSystemStatusWidgetSnapshot() { | ||
| 933 | guard let snapshot = systemStatusSnapshot else { | ||
| 934 | return | ||
| 935 | } | ||
| 936 | let widgetSnapshot = SystemStatusWidgetSnapshot( | ||
| 937 | services: snapshot.services.map { service in | ||
| 938 | SystemStatusWidgetSnapshot.ServiceEntry( | ||
| 939 | id: service.id, | ||
| 940 | name: service.name, | ||
| 941 | status: service.status.displayName, | ||
| 942 | requiresAttention: service.status.requiresAttention | ||
| 943 | ) | ||
| 944 | }, | ||
| 945 | hasDisruption: snapshot.hasDisruption, | ||
| 946 | overallStatusText: snapshot.overallStatusText, | ||
| 947 | bannerSummary: snapshot.bannerSummary, | ||
| 948 | updatedAt: .now | ||
| 949 | ) | ||
| 950 | SystemStatusWidgetSnapshotStore.save(widgetSnapshot) | ||
| 951 | } | ||
| 952 | |||
| 931 | nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { | 953 | nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { |
| 932 | jobs.map { job in | 954 | jobs.map { job in |
| 933 | let repository = primaryRepositoryReference(in: job.manifest) | 955 | let repository = primaryRepositoryReference(in: job.manifest) |
HutchTests/DeepLinkTests.swift added +90
| @@ -0,0 +1,90 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct DeepLinkTests { | ||
| 6 | |||
| 7 | @Test | ||
| 8 | func parsesHomeLink() { | ||
| 9 | let link = DeepLink(url: URL(string: "hutch://home")!) | ||
| 10 | #expect(link == .home) | ||
| 11 | } | ||
| 12 | |||
| 13 | @Test | ||
| 14 | func parsesNilPathAsHome() { | ||
| 15 | let link = DeepLink(url: URL(string: "hutch://")!) | ||
| 16 | #expect(link == .home) | ||
| 17 | } | ||
| 18 | |||
| 19 | @Test | ||
| 20 | func parsesRepositoryLink() { | ||
| 21 | let link = DeepLink(url: URL(string: "hutch://git/~user/repo")!) | ||
| 22 | #expect(link == .repository(owner: "~user", repo: "repo")) | ||
| 23 | } | ||
| 24 | |||
| 25 | @Test | ||
| 26 | func parsesTicketLink() { | ||
| 27 | let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/42")!) | ||
| 28 | #expect(link == .ticket(owner: "~owner", tracker: "tracker", ticketId: 42)) | ||
| 29 | } | ||
| 30 | |||
| 31 | @Test | ||
| 32 | func parsesBuildJobLink() { | ||
| 33 | let link = DeepLink(url: URL(string: "hutch://builds/12345")!) | ||
| 34 | #expect(link == .build(jobId: 12345)) | ||
| 35 | } | ||
| 36 | |||
| 37 | @Test | ||
| 38 | func parsesBuildsTabLink() { | ||
| 39 | let link = DeepLink(url: URL(string: "hutch://builds")!) | ||
| 40 | #expect(link == .buildsTab) | ||
| 41 | } | ||
| 42 | |||
| 43 | @Test | ||
| 44 | func parsesRepositoriesTabLink() { | ||
| 45 | let link = DeepLink(url: URL(string: "hutch://repositories")!) | ||
| 46 | #expect(link == .repositoriesTab) | ||
| 47 | } | ||
| 48 | |||
| 49 | @Test | ||
| 50 | func parsesTrackersTabLink() { | ||
| 51 | let link = DeepLink(url: URL(string: "hutch://trackers")!) | ||
| 52 | #expect(link == .trackersTab) | ||
| 53 | } | ||
| 54 | |||
| 55 | @Test | ||
| 56 | func parsesSystemStatusLink() { | ||
| 57 | let link = DeepLink(url: URL(string: "hutch://status")!) | ||
| 58 | #expect(link == .systemStatus) | ||
| 59 | } | ||
| 60 | |||
| 61 | @Test | ||
| 62 | func parsesLookupLink() { | ||
| 63 | let link = DeepLink(url: URL(string: "hutch://lookup")!) | ||
| 64 | #expect(link == .lookup) | ||
| 65 | } | ||
| 66 | |||
| 67 | @Test | ||
| 68 | func rejectsNonHutchScheme() { | ||
| 69 | let link = DeepLink(url: URL(string: "https://example.com/home")!) | ||
| 70 | #expect(link == nil) | ||
| 71 | } | ||
| 72 | |||
| 73 | @Test | ||
| 74 | func rejectsUnknownPath() { | ||
| 75 | let link = DeepLink(url: URL(string: "hutch://unknown")!) | ||
| 76 | #expect(link == nil) | ||
| 77 | } | ||
| 78 | |||
| 79 | @Test | ||
| 80 | func rejectsTicketLinkWithNonNumericId() { | ||
| 81 | let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/abc")!) | ||
| 82 | #expect(link == nil) | ||
| 83 | } | ||
| 84 | |||
| 85 | @Test | ||
| 86 | func rejectsBuildLinkWithNonNumericId() { | ||
| 87 | let link = DeepLink(url: URL(string: "hutch://builds/abc")!) | ||
| 88 | #expect(link == nil) | ||
| 89 | } | ||
| 90 | } | ||
HutchTests/HutchIntentsTests.swift added +75
| @@ -0,0 +1,75 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | @MainActor | ||
| 6 | struct HutchIntentsTests { | ||
| 7 | |||
| 8 | @Test | ||
| 9 | func checkSystemStatusReturnsOperationalWhenNoDisruption() async throws { | ||
| 10 | let defaultsName = "HutchIntentsTests-status-\(UUID().uuidString)" | ||
| 11 | let defaults = UserDefaults(suiteName: defaultsName)! | ||
| 12 | defer { defaults.removePersistentDomain(forName: defaultsName) } | ||
| 13 | |||
| 14 | let snapshot = SystemStatusWidgetSnapshot( | ||
| 15 | services: [ | ||
| 16 | .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false), | ||
| 17 | ], | ||
| 18 | hasDisruption: false, | ||
| 19 | overallStatusText: "All monitored services operational", | ||
| 20 | bannerSummary: "", | ||
| 21 | updatedAt: .now | ||
| 22 | ) | ||
| 23 | SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults) | ||
| 24 | |||
| 25 | let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults) | ||
| 26 | #expect(loaded != nil) | ||
| 27 | #expect(loaded?.hasDisruption == false) | ||
| 28 | } | ||
| 29 | |||
| 30 | @Test | ||
| 31 | func checkSystemStatusReturnsDisruptionDetails() async throws { | ||
| 32 | let defaultsName = "HutchIntentsTests-disruption-\(UUID().uuidString)" | ||
| 33 | let defaults = UserDefaults(suiteName: defaultsName)! | ||
| 34 | defer { defaults.removePersistentDomain(forName: defaultsName) } | ||
| 35 | |||
| 36 | let snapshot = SystemStatusWidgetSnapshot( | ||
| 37 | services: [ | ||
| 38 | .init(id: "git", name: "git.sr.ht", status: "Degraded", requiresAttention: true), | ||
| 39 | .init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false), | ||
| 40 | ], | ||
| 41 | hasDisruption: true, | ||
| 42 | overallStatusText: "Experiencing disruptions", | ||
| 43 | bannerSummary: "git.sr.ht disrupted", | ||
| 44 | updatedAt: .now | ||
| 45 | ) | ||
| 46 | SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults) | ||
| 47 | |||
| 48 | let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults) | ||
| 49 | #expect(loaded?.hasDisruption == true) | ||
| 50 | |||
| 51 | let disrupted = loaded!.services.filter(\.requiresAttention) | ||
| 52 | #expect(disrupted.count == 1) | ||
| 53 | #expect(disrupted.first?.name == "git.sr.ht") | ||
| 54 | } | ||
| 55 | |||
| 56 | @Test | ||
| 57 | func needsAttentionSnapshotSupportsBuildsCheck() { | ||
| 58 | let defaultsName = "HutchIntentsTests-builds-\(UUID().uuidString)" | ||
| 59 | let defaults = UserDefaults(suiteName: defaultsName)! | ||
| 60 | defer { defaults.removePersistentDomain(forName: defaultsName) } | ||
| 61 | |||
| 62 | let snapshot = NeedsAttentionSnapshot( | ||
| 63 | unreadInboxThreads: 5, | ||
| 64 | assignedOpenTickets: 3, | ||
| 65 | failedBuilds: 2, | ||
| 66 | updatedAt: .now | ||
| 67 | ) | ||
| 68 | NeedsAttentionSnapshotStore.save(snapshot, defaults: defaults) | ||
| 69 | |||
| 70 | let loaded = NeedsAttentionSnapshotStore.load(defaults: defaults) | ||
| 71 | #expect(loaded?.failedBuilds == 2) | ||
| 72 | #expect(loaded?.unreadInboxThreads == 5) | ||
| 73 | #expect(loaded?.assignedOpenTickets == 3) | ||
| 74 | } | ||
| 75 | } | ||
HutchTests/SystemStatusWidgetSnapshotTests.swift added +73
| @@ -0,0 +1,73 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | @MainActor | ||
| 6 | struct SystemStatusWidgetSnapshotTests { | ||
| 7 | |||
| 8 | @Test | ||
| 9 | func saveAndLoadRoundTrips() { | ||
| 10 | let defaultsName = "SystemStatusWidgetSnapshotTests-\(UUID().uuidString)" | ||
| 11 | let defaults = UserDefaults(suiteName: defaultsName)! | ||
| 12 | defer { defaults.removePersistentDomain(forName: defaultsName) } | ||
| 13 | |||
| 14 | let snapshot = SystemStatusWidgetSnapshot( | ||
| 15 | services: [ | ||
| 16 | .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false), | ||
| 17 | .init(id: "builds", name: "builds.sr.ht", status: "Degraded", requiresAttention: true), | ||
| 18 | ], | ||
| 19 | hasDisruption: true, | ||
| 20 | overallStatusText: "Experiencing disruptions", | ||
| 21 | bannerSummary: "builds.sr.ht disrupted", | ||
| 22 | updatedAt: Date(timeIntervalSince1970: 1000) | ||
| 23 | ) | ||
| 24 | |||
| 25 | SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults) | ||
| 26 | let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults) | ||
| 27 | |||
| 28 | #expect(loaded != nil) | ||
| 29 | #expect(loaded?.services.count == 2) | ||
| 30 | #expect(loaded?.hasDisruption == true) | ||
| 31 | #expect(loaded?.bannerSummary == "builds.sr.ht disrupted") | ||
| 32 | #expect(loaded?.services[0].name == "git.sr.ht") | ||
| 33 | #expect(loaded?.services[1].requiresAttention == true) | ||
| 34 | } | ||
| 35 | |||
| 36 | @Test | ||
| 37 | func loadReturnsNilWhenEmpty() { | ||
| 38 | let defaultsName = "SystemStatusWidgetSnapshotTests-empty-\(UUID().uuidString)" | ||
| 39 | let defaults = UserDefaults(suiteName: defaultsName)! | ||
| 40 | defer { defaults.removePersistentDomain(forName: defaultsName) } | ||
| 41 | |||
| 42 | let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults) | ||
| 43 | #expect(loaded == nil) | ||
| 44 | } | ||
| 45 | |||
| 46 | @Test | ||
| 47 | func clearRemovesSnapshot() { | ||
| 48 | let defaultsName = "SystemStatusWidgetSnapshotTests-clear-\(UUID().uuidString)" | ||
| 49 | let defaults = UserDefaults(suiteName: defaultsName)! | ||
| 50 | defer { defaults.removePersistentDomain(forName: defaultsName) } | ||
| 51 | |||
| 52 | let snapshot = SystemStatusWidgetSnapshot( | ||
| 53 | services: [.init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false)], | ||
| 54 | hasDisruption: false, | ||
| 55 | overallStatusText: "All monitored services operational", | ||
| 56 | bannerSummary: "", | ||
| 57 | updatedAt: .now | ||
| 58 | ) | ||
| 59 | |||
| 60 | SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults) | ||
| 61 | #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) != nil) | ||
| 62 | |||
| 63 | SystemStatusWidgetSnapshotStore.clear(defaults: defaults) | ||
| 64 | #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) == nil) | ||
| 65 | } | ||
| 66 | |||
| 67 | @Test | ||
| 68 | func unavailableSnapshotHasEmptyServices() { | ||
| 69 | let snapshot = SystemStatusWidgetSnapshot.unavailable | ||
| 70 | #expect(snapshot.services.isEmpty) | ||
| 71 | #expect(snapshot.hasDisruption == false) | ||
| 72 | } | ||
| 73 | } | ||
HutchWidgetExtension/NeedsAttentionWidget.swift +46 −33
| @@ -71,7 +71,7 @@ private struct NeedsAttentionWidgetView: View { | |||
| 71 | fallbackState | 71 | fallbackState |
| 72 | } | 72 | } |
| 73 | } | 73 | } |
| 74 | .widgetURL(URL(string: "hutch://home")) | 74 | .widgetURL(family == .systemSmall ? URL(string: "hutch://home") : nil) |
| 75 | .containerBackground(for: .widget) { | 75 | .containerBackground(for: .widget) { |
| 76 | Color(.systemBackground) | 76 | Color(.systemBackground) |
| 77 | } | 77 | } |
| @@ -101,52 +101,64 @@ private struct NeedsAttentionWidgetView: View { | |||
| 101 | private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View { | 101 | private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View { |
| 102 | VStack(alignment: .leading, spacing: 0) { | 102 | VStack(alignment: .leading, spacing: 0) { |
| 103 | HStack(alignment: .top, spacing: 12) { | 103 | HStack(alignment: .top, spacing: 12) { |
| 104 | metricColumn( | 104 | Link(destination: URL(string: "hutch://home")!) { |
| 105 | metricColumn( | ||
| 106 | count: snapshot.unreadInboxThreads, | ||
| 107 | label: "Unread", | ||
| 108 | systemImage: "tray", | ||
| 109 | tint: unreadTint(for: snapshot.unreadInboxThreads) | ||
| 110 | ) | ||
| 111 | } | ||
| 112 | Link(destination: URL(string: "hutch://trackers")!) { | ||
| 113 | metricColumn( | ||
| 114 | count: snapshot.assignedOpenTickets, | ||
| 115 | label: "Assigned", | ||
| 116 | systemImage: "ticket" | ||
| 117 | ) | ||
| 118 | } | ||
| 119 | Link(destination: URL(string: "hutch://builds")!) { | ||
| 120 | metricColumn( | ||
| 121 | count: snapshot.failedBuilds, | ||
| 122 | label: "Failed", | ||
| 123 | systemImage: "hammer", | ||
| 124 | tint: failedTint(for: snapshot.failedBuilds) | ||
| 125 | ) | ||
| 126 | } | ||
| 127 | } | ||
| 128 | Spacer(minLength: 0) | ||
| 129 | } | ||
| 130 | .padding(.horizontal, 16) | ||
| 131 | .padding(.top, 18) | ||
| 132 | .padding(.bottom, 16) | ||
| 133 | } | ||
| 134 | |||
| 135 | private func largeView(snapshot: NeedsAttentionSnapshot) -> some View { | ||
| 136 | VStack(alignment: .leading, spacing: 18) { | ||
| 137 | Link(destination: URL(string: "hutch://home")!) { | ||
| 138 | metricRow( | ||
| 105 | count: snapshot.unreadInboxThreads, | 139 | count: snapshot.unreadInboxThreads, |
| 106 | label: "Unread", | 140 | label: "Unread threads", |
| 107 | systemImage: "tray", | 141 | systemImage: "tray", |
| 108 | tint: unreadTint(for: snapshot.unreadInboxThreads) | 142 | tint: unreadTint(for: snapshot.unreadInboxThreads) |
| 109 | ) | 143 | ) |
| 110 | metricColumn( | 144 | } |
| 145 | Link(destination: URL(string: "hutch://trackers")!) { | ||
| 146 | metricRow( | ||
| 111 | count: snapshot.assignedOpenTickets, | 147 | count: snapshot.assignedOpenTickets, |
| 112 | label: "Assigned", | 148 | label: "Assigned tickets", |
| 113 | systemImage: "ticket" | 149 | systemImage: "ticket" |
| 114 | ) | 150 | ) |
| 115 | metricColumn( | 151 | } |
| 152 | Link(destination: URL(string: "hutch://builds")!) { | ||
| 153 | metricRow( | ||
| 116 | count: snapshot.failedBuilds, | 154 | count: snapshot.failedBuilds, |
| 117 | label: "Failed", | 155 | label: "Failed builds", |
| 118 | systemImage: "hammer", | 156 | systemImage: "hammer", |
| 119 | tint: failedTint(for: snapshot.failedBuilds) | 157 | tint: failedTint(for: snapshot.failedBuilds) |
| 120 | ) | 158 | ) |
| 121 | } | 159 | } |
| 122 | Spacer(minLength: 0) | 160 | Spacer(minLength: 0) |
| 123 | } | 161 | } |
| 124 | .padding(.horizontal, 16) | ||
| 125 | .padding(.top, 18) | ||
| 126 | .padding(.bottom, 16) | ||
| 127 | } | ||
| 128 | |||
| 129 | private func largeView(snapshot: NeedsAttentionSnapshot) -> some View { | ||
| 130 | VStack(alignment: .leading, spacing: 18) { | ||
| 131 | metricRow( | ||
| 132 | count: snapshot.unreadInboxThreads, | ||
| 133 | label: "Unread threads", | ||
| 134 | systemImage: "tray", | ||
| 135 | tint: unreadTint(for: snapshot.unreadInboxThreads) | ||
| 136 | ) | ||
| 137 | metricRow( | ||
| 138 | count: snapshot.assignedOpenTickets, | ||
| 139 | label: "Assigned tickets", | ||
| 140 | systemImage: "ticket" | ||
| 141 | ) | ||
| 142 | metricRow( | ||
| 143 | count: snapshot.failedBuilds, | ||
| 144 | label: "Failed builds", | ||
| 145 | systemImage: "hammer", | ||
| 146 | tint: failedTint(for: snapshot.failedBuilds) | ||
| 147 | ) | ||
| 148 | Spacer(minLength: 0) | ||
| 149 | } | ||
| 150 | .padding(20) | 162 | .padding(20) |
| 151 | } | 163 | } |
| 152 | 164 | ||
| @@ -303,5 +315,6 @@ struct HutchWidgets: WidgetBundle { | |||
| 303 | var body: some Widget { | 315 | var body: some Widget { |
| 304 | NeedsAttentionWidget() | 316 | NeedsAttentionWidget() |
| 305 | ContributionGraphWidget() | 317 | ContributionGraphWidget() |
| 318 | SystemStatusWidget() | ||
| 306 | } | 319 | } |
| 307 | } | 320 | } |
HutchWidgetExtension/SystemStatusWidget.swift added +177
| @@ -0,0 +1,177 @@ | |||
| 1 | import SwiftUI | ||
| 2 | import WidgetKit | ||
| 3 | |||
| 4 | struct SystemStatusEntry: TimelineEntry { | ||
| 5 | let date: Date | ||
| 6 | let snapshot: SystemStatusWidgetSnapshot? | ||
| 7 | } | ||
| 8 | |||
| 9 | struct SystemStatusTimelineProvider: TimelineProvider { | ||
| 10 | func placeholder(in _: Context) -> SystemStatusEntry { | ||
| 11 | SystemStatusEntry( | ||
| 12 | date: .now, | ||
| 13 | snapshot: SystemStatusWidgetSnapshot( | ||
| 14 | services: [ | ||
| 15 | .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false), | ||
| 16 | .init(id: "builds", name: "builds.sr.ht", status: "Operational", requiresAttention: false), | ||
| 17 | .init(id: "lists", name: "lists.sr.ht", status: "Operational", requiresAttention: false), | ||
| 18 | .init(id: "todo", name: "todo.sr.ht", status: "Operational", requiresAttention: false), | ||
| 19 | .init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false), | ||
| 20 | .init(id: "hg", name: "hg.sr.ht", status: "Operational", requiresAttention: false), | ||
| 21 | ], | ||
| 22 | hasDisruption: false, | ||
| 23 | overallStatusText: "All monitored services operational", | ||
| 24 | bannerSummary: "", | ||
| 25 | updatedAt: .now | ||
| 26 | ) | ||
| 27 | ) | ||
| 28 | } | ||
| 29 | |||
| 30 | func getSnapshot(in _: Context, completion: @escaping (SystemStatusEntry) -> Void) { | ||
| 31 | completion( | ||
| 32 | SystemStatusEntry( | ||
| 33 | date: .now, | ||
| 34 | snapshot: SystemStatusWidgetSnapshotStore.load() | ||
| 35 | ) | ||
| 36 | ) | ||
| 37 | } | ||
| 38 | |||
| 39 | func getTimeline(in _: Context, completion: @escaping (Timeline<SystemStatusEntry>) -> Void) { | ||
| 40 | let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: .now) ?? .now.addingTimeInterval(900) | ||
| 41 | let entry = SystemStatusEntry(date: .now, snapshot: SystemStatusWidgetSnapshotStore.load()) | ||
| 42 | completion(Timeline(entries: [entry], policy: .after(refreshDate))) | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | struct SystemStatusWidget: Widget { | ||
| 47 | static let kind = SystemStatusWidgetConfiguration.kind | ||
| 48 | |||
| 49 | var body: some WidgetConfiguration { | ||
| 50 | StaticConfiguration(kind: Self.kind, provider: SystemStatusTimelineProvider()) { entry in | ||
| 51 | SystemStatusWidgetView(entry: entry) | ||
| 52 | } | ||
| 53 | .configurationDisplayName("SourceHut Status") | ||
| 54 | .description("Current status of SourceHut services at a glance.") | ||
| 55 | .supportedFamilies([.systemSmall, .systemMedium]) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | private struct SystemStatusWidgetView: View { | ||
| 60 | let entry: SystemStatusEntry | ||
| 61 | @Environment(\.widgetFamily) private var family | ||
| 62 | |||
| 63 | var body: some View { | ||
| 64 | Group { | ||
| 65 | if let snapshot = entry.snapshot, !snapshot.services.isEmpty { | ||
| 66 | switch family { | ||
| 67 | case .systemMedium: | ||
| 68 | mediumView(snapshot: snapshot) | ||
| 69 | default: | ||
| 70 | smallView(snapshot: snapshot) | ||
| 71 | } | ||
| 72 | } else { | ||
| 73 | fallbackView | ||
| 74 | } | ||
| 75 | } | ||
| 76 | .widgetURL(URL(string: "hutch://status")) | ||
| 77 | .containerBackground(for: .widget) { | ||
| 78 | Color(.systemBackground) | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | private func smallView(snapshot: SystemStatusWidgetSnapshot) -> some View { | ||
| 83 | VStack(alignment: .leading, spacing: 8) { | ||
| 84 | HStack(spacing: 6) { | ||
| 85 | Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") | ||
| 86 | .font(.title3.weight(.semibold)) | ||
| 87 | .foregroundStyle(snapshot.hasDisruption ? .orange : .green) | ||
| 88 | Spacer() | ||
| 89 | } | ||
| 90 | |||
| 91 | Text(snapshot.hasDisruption ? snapshot.bannerSummary : "All Clear") | ||
| 92 | .font(.subheadline.weight(.semibold)) | ||
| 93 | .lineLimit(2) | ||
| 94 | |||
| 95 | Spacer(minLength: 0) | ||
| 96 | |||
| 97 | if snapshot.hasDisruption { | ||
| 98 | let disrupted = snapshot.services.filter(\.requiresAttention) | ||
| 99 | Text(disrupted.map(\.name).joined(separator: ", ")) | ||
| 100 | .font(.caption2) | ||
| 101 | .foregroundStyle(.secondary) | ||
| 102 | .lineLimit(2) | ||
| 103 | } else { | ||
| 104 | Text("\(snapshot.services.count) services monitored") | ||
| 105 | .font(.caption2) | ||
| 106 | .foregroundStyle(.secondary) | ||
| 107 | } | ||
| 108 | } | ||
| 109 | .padding() | ||
| 110 | } | ||
| 111 | |||
| 112 | private func mediumView(snapshot: SystemStatusWidgetSnapshot) -> some View { | ||
| 113 | VStack(alignment: .leading, spacing: 6) { | ||
| 114 | HStack(spacing: 8) { | ||
| 115 | Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") | ||
| 116 | .font(.title3.weight(.semibold)) | ||
| 117 | .foregroundStyle(snapshot.hasDisruption ? .orange : .green) | ||
| 118 | |||
| 119 | Text(snapshot.hasDisruption ? snapshot.bannerSummary : "All Services Operational") | ||
| 120 | .font(.subheadline.weight(.semibold)) | ||
| 121 | .lineLimit(1) | ||
| 122 | |||
| 123 | Spacer() | ||
| 124 | } | ||
| 125 | |||
| 126 | Divider() | ||
| 127 | |||
| 128 | let columns = Array(snapshot.services.prefix(8)) | ||
| 129 | let half = (columns.count + 1) / 2 | ||
| 130 | let leftColumn = Array(columns.prefix(half)) | ||
| 131 | let rightColumn = Array(columns.suffix(from: half)) | ||
| 132 | |||
| 133 | HStack(alignment: .top, spacing: 16) { | ||
| 134 | VStack(alignment: .leading, spacing: 4) { | ||
| 135 | ForEach(leftColumn) { service in | ||
| 136 | serviceRow(service) | ||
| 137 | } | ||
| 138 | } | ||
| 139 | VStack(alignment: .leading, spacing: 4) { | ||
| 140 | ForEach(rightColumn) { service in | ||
| 141 | serviceRow(service) | ||
| 142 | } | ||
| 143 | } | ||
| 144 | Spacer(minLength: 0) | ||
| 145 | } | ||
| 146 | |||
| 147 | Spacer(minLength: 0) | ||
| 148 | } | ||
| 149 | .padding(.horizontal, 16) | ||
| 150 | .padding(.top, 14) | ||
| 151 | .padding(.bottom, 12) | ||
| 152 | } | ||
| 153 | |||
| 154 | private func serviceRow(_ service: SystemStatusWidgetSnapshot.ServiceEntry) -> some View { | ||
| 155 | HStack(spacing: 6) { | ||
| 156 | Circle() | ||
| 157 | .fill(service.requiresAttention ? Color.orange : Color.green) | ||
| 158 | .frame(width: 6, height: 6) | ||
| 159 | Text(service.name) | ||
| 160 | .font(.caption2) | ||
| 161 | .foregroundStyle(service.requiresAttention ? .primary : .secondary) | ||
| 162 | .lineLimit(1) | ||
| 163 | } | ||
| 164 | } | ||
| 165 | |||
| 166 | private var fallbackView: some View { | ||
| 167 | VStack(alignment: .leading, spacing: 8) { | ||
| 168 | Spacer(minLength: 0) | ||
| 169 | Text("Open Hutch") | ||
| 170 | .font(.title3.weight(.semibold)) | ||
| 171 | Text("Sign in to load system status.") | ||
| 172 | .font(.caption) | ||
| 173 | .foregroundStyle(.secondary) | ||
| 174 | } | ||
| 175 | .padding() | ||
| 176 | } | ||
| 177 | } | ||
Shared/SystemStatusWidgetSnapshot.swift added +67
| @@ -0,0 +1,67 @@ | |||
| 1 | import Foundation | ||
| 2 | #if canImport(WidgetKit) | ||
| 3 | import WidgetKit | ||
| 4 | #endif | ||
| 5 | |||
| 6 | enum SystemStatusWidgetConfiguration { | ||
| 7 | static let kind = "SystemStatusWidget" | ||
| 8 | } | ||
| 9 | |||
| 10 | struct SystemStatusWidgetSnapshot: Codable, Sendable { | ||
| 11 | let services: [ServiceEntry] | ||
| 12 | let hasDisruption: Bool | ||
| 13 | let overallStatusText: String | ||
| 14 | let bannerSummary: String | ||
| 15 | let updatedAt: Date | ||
| 16 | |||
| 17 | struct ServiceEntry: Codable, Sendable, Identifiable { | ||
| 18 | let id: String | ||
| 19 | let name: String | ||
| 20 | let status: String | ||
| 21 | let requiresAttention: Bool | ||
| 22 | } | ||
| 23 | |||
| 24 | static let unavailable = SystemStatusWidgetSnapshot( | ||
| 25 | services: [], | ||
| 26 | hasDisruption: false, | ||
| 27 | overallStatusText: "Unavailable", | ||
| 28 | bannerSummary: "", | ||
| 29 | updatedAt: .now | ||
| 30 | ) | ||
| 31 | } | ||
| 32 | |||
| 33 | enum SystemStatusWidgetSnapshotStore { | ||
| 34 | private static let snapshotKey = "systemStatus.widgetSnapshot" | ||
| 35 | |||
| 36 | static func load(defaults: UserDefaults? = sharedDefaults()) -> SystemStatusWidgetSnapshot? { | ||
| 37 | guard let defaults, | ||
| 38 | let data = defaults.data(forKey: snapshotKey) else { | ||
| 39 | return nil | ||
| 40 | } | ||
| 41 | return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data) | ||
| 42 | } | ||
| 43 | |||
| 44 | static func save(_ snapshot: SystemStatusWidgetSnapshot, defaults: UserDefaults? = sharedDefaults()) { | ||
| 45 | guard let defaults, | ||
| 46 | let data = try? JSONEncoder().encode(snapshot) else { | ||
| 47 | return | ||
| 48 | } | ||
| 49 | defaults.set(data, forKey: snapshotKey) | ||
| 50 | reloadWidgetTimelines() | ||
| 51 | } | ||
| 52 | |||
| 53 | static func clear(defaults: UserDefaults? = sharedDefaults()) { | ||
| 54 | defaults?.removeObject(forKey: snapshotKey) | ||
| 55 | reloadWidgetTimelines() | ||
| 56 | } | ||
| 57 | |||
| 58 | private static func sharedDefaults() -> UserDefaults? { | ||
| 59 | UserDefaults(suiteName: HutchAppGroup.identifier) | ||
| 60 | } | ||
| 61 | |||
| 62 | private static func reloadWidgetTimelines() { | ||
| 63 | #if canImport(WidgetKit) | ||
| 64 | WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind) | ||
| 65 | #endif | ||
| 66 | } | ||
| 67 | } | ||