From 9050405b4f33b2b9b70458fcf3e43b80c940eef7 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sun, 12 Apr 2026 00:55:42 -0500 Subject: add shortcuts widgets and power-user workflows --- Hutch.xcodeproj/project.pbxproj | 16 +- Hutch/App/AppState.swift | 2 + Hutch/App/DeepLink.swift | 25 ++++ Hutch/App/HutchApp.swift | 15 ++ Hutch/App/HutchIntents.swift | 154 ++++++++++++++++++++ Hutch/App/RootView.swift | 71 +++++++++ Hutch/Views/Home/HomeViewModel.swift | 22 +++ HutchTests/DeepLinkTests.swift | 90 ++++++++++++ HutchTests/HutchIntentsTests.swift | 75 ++++++++++ HutchTests/SystemStatusWidgetSnapshotTests.swift | 73 ++++++++++ HutchWidgetExtension/NeedsAttentionWidget.swift | 79 +++++----- HutchWidgetExtension/SystemStatusWidget.swift | 177 +++++++++++++++++++++++ Shared/SystemStatusWidgetSnapshot.swift | 67 +++++++++ 13 files changed, 825 insertions(+), 41 deletions(-) create mode 100644 Hutch/App/HutchIntents.swift create mode 100644 HutchTests/DeepLinkTests.swift create mode 100644 HutchTests/HutchIntentsTests.swift create mode 100644 HutchTests/SystemStatusWidgetSnapshotTests.swift create mode 100644 HutchWidgetExtension/SystemStatusWidget.swift create mode 100644 Shared/SystemStatusWidgetSnapshot.swift diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index ca3476e..1020bb0 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -515,7 +515,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 31; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -532,7 +532,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.14.0; + MARKETING_VERSION = 2.15.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -552,7 +552,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 31; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.14.0; + MARKETING_VERSION = 2.15.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -632,7 +632,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 31; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -642,7 +642,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.14.0; + MARKETING_VERSION = 2.15.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -661,7 +661,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 31; + CURRENT_PROJECT_VERSION = 32; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -671,7 +671,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.14.0; + MARKETING_VERSION = 2.15.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift index c2e2744..35ac77f 100644 --- a/Hutch/App/AppState.swift +++ b/Hutch/App/AppState.swift @@ -203,6 +203,7 @@ final class AppState { await clearWebData() clearWebContentRenderCaches() NeedsAttentionSnapshotStore.clear() + SystemStatusWidgetSnapshotStore.clear() authPhase = .unauthenticated selectedTab = .home } @@ -218,6 +219,7 @@ final class AppState { await clearWebData() clearWebContentRenderCaches() NeedsAttentionSnapshotStore.clear() + SystemStatusWidgetSnapshotStore.clear() authPhase = .unauthenticated selectedTab = .home diff --git a/Hutch/App/DeepLink.swift b/Hutch/App/DeepLink.swift index e42bd6b..bbf8885 100644 --- a/Hutch/App/DeepLink.swift +++ b/Hutch/App/DeepLink.swift @@ -9,6 +9,16 @@ enum DeepLink: Equatable { case ticket(owner: String, tracker: String, ticketId: Int) /// hutch://builds/ case build(jobId: Int) + /// hutch://builds (tab-level) + case buildsTab + /// hutch://repositories (tab-level) + case repositoriesTab + /// hutch://trackers (tab-level) + case trackersTab + /// hutch://status + case systemStatus + /// hutch://lookup + case lookup /// Attempt to parse a URL into a DeepLink. /// Expected format: hutch:// @@ -38,6 +48,21 @@ enum DeepLink: Equatable { guard let jobId = Int(components[1]) else { return nil } self = .build(jobId: jobId) + case "builds": + self = .buildsTab + + case "repositories": + self = .repositoriesTab + + case "trackers": + self = .trackersTab + + case "status": + self = .systemStatus + + case "lookup": + self = .lookup + default: return nil } diff --git a/Hutch/App/HutchApp.swift b/Hutch/App/HutchApp.swift index fda0f06..e65833c 100644 --- a/Hutch/App/HutchApp.swift +++ b/Hutch/App/HutchApp.swift @@ -15,6 +15,21 @@ struct HutchApp: App { appState.pendingDeepLink = link } } + .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in + guard let destination else { return } + HutchIntentNavigator.shared.pendingDestination = nil + let link: DeepLink + switch destination { + case .home: link = .home + case .inbox: link = .home + case .builds: link = .buildsTab + case .repositories: link = .repositoriesTab + case .trackers: link = .trackersTab + case .systemStatus: link = .systemStatus + case .lookup: link = .lookup + } + appState.pendingDeepLink = link + } } } } diff --git a/Hutch/App/HutchIntents.swift b/Hutch/App/HutchIntents.swift new file mode 100644 index 0000000..652c07b --- /dev/null +++ b/Hutch/App/HutchIntents.swift @@ -0,0 +1,154 @@ +import AppIntents +import Foundation + +// MARK: - Open Hutch Intent + +enum HutchDestination: String, AppEnum { + case home + case inbox + case builds + case repositories + case trackers + case systemStatus + case lookup + + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Hutch Section") + + static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [ + .home: "Home", + .inbox: "Inbox", + .builds: "Builds", + .repositories: "Repositories", + .trackers: "Trackers", + .systemStatus: "System Status", + .lookup: "Look Up" + ] +} + +struct OpenHutchIntent: AppIntent { + static var title: LocalizedStringResource = "Open Hutch" + static var description = IntentDescription("Opens Hutch to a specific section.") + static var openAppWhenRun = true + + @Parameter(title: "Section", default: .home) + var destination: HutchDestination + + @MainActor + func perform() async throws -> some IntentResult { + HutchIntentNavigator.shared.pendingDestination = destination + return .result() + } +} + +// MARK: - Check System Status Intent + +struct CheckSystemStatusIntent: AppIntent { + static var title: LocalizedStringResource = "Check SourceHut Status" + static var description = IntentDescription("Returns the current SourceHut system status.") + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + guard let snapshot = SystemStatusWidgetSnapshotStore.load() else { + return .result(value: "System status is unavailable. Open Hutch to refresh.") + } + + if snapshot.hasDisruption { + let disrupted = snapshot.services + .filter { $0.requiresAttention } + .map { "\($0.name): \($0.status)" } + .joined(separator: ", ") + return .result(value: "SourceHut disruption detected: \(disrupted)") + } + + return .result(value: "All SourceHut services operational.") + } +} + +// MARK: - Check Builds Intent + +struct CheckBuildsIntent: AppIntent { + static var title: LocalizedStringResource = "Check Hutch Builds" + static var description = IntentDescription("Returns a summary of your recent build status.") + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + guard let snapshot = NeedsAttentionSnapshotStore.load() else { + return .result(value: "Build status unavailable. Open Hutch to refresh.") + } + + var parts: [String] = [] + + if let failed = snapshot.failedBuilds { + if failed > 0 { + parts.append("\(failed) failed build\(failed == 1 ? "" : "s")") + } else { + parts.append("No failed builds") + } + } + + if let unread = snapshot.unreadInboxThreads, unread > 0 { + parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")") + } + + if let assigned = snapshot.assignedOpenTickets, assigned > 0 { + parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")") + } + + if parts.isEmpty { + return .result(value: "No recent data. Open Hutch to refresh.") + } + + return .result(value: parts.joined(separator: ". ") + ".") + } +} + +// MARK: - Shortcuts Provider + +struct HutchShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: OpenHutchIntent(), + phrases: [ + "Open \(.applicationName)", + "Open \(.applicationName) \(\.$destination)", + "Show my \(.applicationName) \(\.$destination)", + "Go to \(\.$destination) in \(.applicationName)" + ], + shortTitle: "Open Hutch", + systemImageName: "house" + ) + + AppShortcut( + intent: CheckSystemStatusIntent(), + phrases: [ + "Check \(.applicationName) status", + "Is SourceHut up in \(.applicationName)", + "SourceHut status in \(.applicationName)" + ], + shortTitle: "Check SourceHut Status", + systemImageName: "server.rack" + ) + + AppShortcut( + intent: CheckBuildsIntent(), + phrases: [ + "Check my \(.applicationName) builds", + "How are my builds in \(.applicationName)", + "Build status in \(.applicationName)" + ], + shortTitle: "Check Builds", + systemImageName: "hammer" + ) + } +} + +// MARK: - Intent Navigator + +@MainActor +@Observable +final class HutchIntentNavigator { + static let shared = HutchIntentNavigator() + var pendingDestination: HutchDestination? + + private init() {} +} diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index c210d9d..80532a0 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -113,6 +113,11 @@ struct RootView: View { Label("More", systemImage: "ellipsis.circle") } } + .modifier(SidebarAdaptableTabStyle()) + .modifier(TabKeyboardShortcuts(selectedTab: Binding( + get: { appState.selectedTab }, + set: { appState.selectedTab = $0 } + ))) .overlay { if isResolvingDeepLink { ZStack { @@ -178,6 +183,29 @@ struct RootView: View { case .ticket(let owner, let tracker, let ticketId): resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId) + + case .buildsTab: + buildsPath = NavigationPath() + appState.selectedTab = .builds + + case .repositoriesTab: + repoPath = NavigationPath() + appState.selectedTab = .repositories + + case .trackersTab: + ticketsPath = NavigationPath() + appState.selectedTab = .tickets + + case .systemStatus: + appState.navigateToSystemStatus() + + case .lookup: + morePath = NavigationPath() + appState.selectedTab = .more + Task { + await settleNavigationTransition() + morePath.append(MoreRoute.lookup) + } } } @@ -336,3 +364,46 @@ struct TicketDeepLinkTarget: Hashable { let trackerRid: String let ticketId: Int } + +// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard + +/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad. +private struct TabKeyboardShortcuts: ViewModifier { + @Binding var selectedTab: AppState.Tab + + private static let tabMap: [String: AppState.Tab] = [ + "1": .home, + "2": .repositories, + "3": .tickets, + "4": .builds, + "5": .more, + ] + + func body(content: Content) -> some View { + content + .onKeyPress(characters: .decimalDigits, phases: .down) { press in + guard press.modifiers == .command else { return .ignored } + let key = String(press.characters) + if let tab = Self.tabMap[key] { + selectedTab = tab + return .handled + } + return .ignored + } + } +} + +// MARK: - iPad Sidebar Adaptable + +/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar +/// becomes a full sidebar on iPad, while falling back to the standard tab +/// bar on earlier releases. +private struct SidebarAdaptableTabStyle: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 18.0, *) { + content.tabViewStyle(.sidebarAdaptable) + } else { + content + } + } +} diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index 8762393..ec2b324 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -388,6 +388,7 @@ final class HomeViewModel { } isLoadingSystemStatus = false persistNeedsAttentionSnapshot() + persistSystemStatusWidgetSnapshot() } var hasDashboardContent: Bool { @@ -928,6 +929,27 @@ final class HomeViewModel { ) } + private func persistSystemStatusWidgetSnapshot() { + guard let snapshot = systemStatusSnapshot else { + return + } + let widgetSnapshot = SystemStatusWidgetSnapshot( + services: snapshot.services.map { service in + SystemStatusWidgetSnapshot.ServiceEntry( + id: service.id, + name: service.name, + status: service.status.displayName, + requiresAttention: service.status.requiresAttention + ) + }, + hasDisruption: snapshot.hasDisruption, + overallStatusText: snapshot.overallStatusText, + bannerSummary: snapshot.bannerSummary, + updatedAt: .now + ) + SystemStatusWidgetSnapshotStore.save(widgetSnapshot) + } + nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { jobs.map { job in let repository = primaryRepositoryReference(in: job.manifest) diff --git a/HutchTests/DeepLinkTests.swift b/HutchTests/DeepLinkTests.swift new file mode 100644 index 0000000..12f759b --- /dev/null +++ b/HutchTests/DeepLinkTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing +@testable import Hutch + +struct DeepLinkTests { + + @Test + func parsesHomeLink() { + let link = DeepLink(url: URL(string: "hutch://home")!) + #expect(link == .home) + } + + @Test + func parsesNilPathAsHome() { + let link = DeepLink(url: URL(string: "hutch://")!) + #expect(link == .home) + } + + @Test + func parsesRepositoryLink() { + let link = DeepLink(url: URL(string: "hutch://git/~user/repo")!) + #expect(link == .repository(owner: "~user", repo: "repo")) + } + + @Test + func parsesTicketLink() { + let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/42")!) + #expect(link == .ticket(owner: "~owner", tracker: "tracker", ticketId: 42)) + } + + @Test + func parsesBuildJobLink() { + let link = DeepLink(url: URL(string: "hutch://builds/12345")!) + #expect(link == .build(jobId: 12345)) + } + + @Test + func parsesBuildsTabLink() { + let link = DeepLink(url: URL(string: "hutch://builds")!) + #expect(link == .buildsTab) + } + + @Test + func parsesRepositoriesTabLink() { + let link = DeepLink(url: URL(string: "hutch://repositories")!) + #expect(link == .repositoriesTab) + } + + @Test + func parsesTrackersTabLink() { + let link = DeepLink(url: URL(string: "hutch://trackers")!) + #expect(link == .trackersTab) + } + + @Test + func parsesSystemStatusLink() { + let link = DeepLink(url: URL(string: "hutch://status")!) + #expect(link == .systemStatus) + } + + @Test + func parsesLookupLink() { + let link = DeepLink(url: URL(string: "hutch://lookup")!) + #expect(link == .lookup) + } + + @Test + func rejectsNonHutchScheme() { + let link = DeepLink(url: URL(string: "https://example.com/home")!) + #expect(link == nil) + } + + @Test + func rejectsUnknownPath() { + let link = DeepLink(url: URL(string: "hutch://unknown")!) + #expect(link == nil) + } + + @Test + func rejectsTicketLinkWithNonNumericId() { + let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/abc")!) + #expect(link == nil) + } + + @Test + func rejectsBuildLinkWithNonNumericId() { + let link = DeepLink(url: URL(string: "hutch://builds/abc")!) + #expect(link == nil) + } +} diff --git a/HutchTests/HutchIntentsTests.swift b/HutchTests/HutchIntentsTests.swift new file mode 100644 index 0000000..94ecbf0 --- /dev/null +++ b/HutchTests/HutchIntentsTests.swift @@ -0,0 +1,75 @@ +import Foundation +import Testing +@testable import Hutch + +@MainActor +struct HutchIntentsTests { + + @Test + func checkSystemStatusReturnsOperationalWhenNoDisruption() async throws { + let defaultsName = "HutchIntentsTests-status-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: defaultsName)! + defer { defaults.removePersistentDomain(forName: defaultsName) } + + let snapshot = SystemStatusWidgetSnapshot( + services: [ + .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false), + ], + hasDisruption: false, + overallStatusText: "All monitored services operational", + bannerSummary: "", + updatedAt: .now + ) + SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults) + + let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults) + #expect(loaded != nil) + #expect(loaded?.hasDisruption == false) + } + + @Test + func checkSystemStatusReturnsDisruptionDetails() async throws { + let defaultsName = "HutchIntentsTests-disruption-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: defaultsName)! + defer { defaults.removePersistentDomain(forName: defaultsName) } + + let snapshot = SystemStatusWidgetSnapshot( + services: [ + .init(id: "git", name: "git.sr.ht", status: "Degraded", requiresAttention: true), + .init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false), + ], + hasDisruption: true, + overallStatusText: "Experiencing disruptions", + bannerSummary: "git.sr.ht disrupted", + updatedAt: .now + ) + SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults) + + let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults) + #expect(loaded?.hasDisruption == true) + + let disrupted = loaded!.services.filter(\.requiresAttention) + #expect(disrupted.count == 1) + #expect(disrupted.first?.name == "git.sr.ht") + } + + @Test + func needsAttentionSnapshotSupportsBuildsCheck() { + let defaultsName = "HutchIntentsTests-builds-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: defaultsName)! + defer { defaults.removePersistentDomain(forName: defaultsName) } + + let snapshot = NeedsAttentionSnapshot( + unreadInboxThreads: 5, + assignedOpenTickets: 3, + failedBuilds: 2, + updatedAt: .now + ) + NeedsAttentionSnapshotStore.save(snapshot, defaults: defaults) + + let loaded = NeedsAttentionSnapshotStore.load(defaults: defaults) + #expect(loaded?.failedBuilds == 2) + #expect(loaded?.unreadInboxThreads == 5) + #expect(loaded?.assignedOpenTickets == 3) + } +} diff --git a/HutchTests/SystemStatusWidgetSnapshotTests.swift b/HutchTests/SystemStatusWidgetSnapshotTests.swift new file mode 100644 index 0000000..ddb74b1 --- /dev/null +++ b/HutchTests/SystemStatusWidgetSnapshotTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing +@testable import Hutch + +@MainActor +struct SystemStatusWidgetSnapshotTests { + + @Test + func saveAndLoadRoundTrips() { + let defaultsName = "SystemStatusWidgetSnapshotTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: defaultsName)! + defer { defaults.removePersistentDomain(forName: defaultsName) } + + let snapshot = SystemStatusWidgetSnapshot( + services: [ + .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false), + .init(id: "builds", name: "builds.sr.ht", status: "Degraded", requiresAttention: true), + ], + hasDisruption: true, + overallStatusText: "Experiencing disruptions", + bannerSummary: "builds.sr.ht disrupted", + updatedAt: Date(timeIntervalSince1970: 1000) + ) + + SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults) + let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults) + + #expect(loaded != nil) + #expect(loaded?.services.count == 2) + #expect(loaded?.hasDisruption == true) + #expect(loaded?.bannerSummary == "builds.sr.ht disrupted") + #expect(loaded?.services[0].name == "git.sr.ht") + #expect(loaded?.services[1].requiresAttention == true) + } + + @Test + func loadReturnsNilWhenEmpty() { + let defaultsName = "SystemStatusWidgetSnapshotTests-empty-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: defaultsName)! + defer { defaults.removePersistentDomain(forName: defaultsName) } + + let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults) + #expect(loaded == nil) + } + + @Test + func clearRemovesSnapshot() { + let defaultsName = "SystemStatusWidgetSnapshotTests-clear-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: defaultsName)! + defer { defaults.removePersistentDomain(forName: defaultsName) } + + let snapshot = SystemStatusWidgetSnapshot( + services: [.init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false)], + hasDisruption: false, + overallStatusText: "All monitored services operational", + bannerSummary: "", + updatedAt: .now + ) + + SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults) + #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) != nil) + + SystemStatusWidgetSnapshotStore.clear(defaults: defaults) + #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) == nil) + } + + @Test + func unavailableSnapshotHasEmptyServices() { + let snapshot = SystemStatusWidgetSnapshot.unavailable + #expect(snapshot.services.isEmpty) + #expect(snapshot.hasDisruption == false) + } +} diff --git a/HutchWidgetExtension/NeedsAttentionWidget.swift b/HutchWidgetExtension/NeedsAttentionWidget.swift index e212af6..574e9eb 100644 --- a/HutchWidgetExtension/NeedsAttentionWidget.swift +++ b/HutchWidgetExtension/NeedsAttentionWidget.swift @@ -71,7 +71,7 @@ private struct NeedsAttentionWidgetView: View { fallbackState } } - .widgetURL(URL(string: "hutch://home")) + .widgetURL(family == .systemSmall ? URL(string: "hutch://home") : nil) .containerBackground(for: .widget) { Color(.systemBackground) } @@ -101,52 +101,64 @@ private struct NeedsAttentionWidgetView: View { private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top, spacing: 12) { - metricColumn( + Link(destination: URL(string: "hutch://home")!) { + metricColumn( + count: snapshot.unreadInboxThreads, + label: "Unread", + systemImage: "tray", + tint: unreadTint(for: snapshot.unreadInboxThreads) + ) + } + Link(destination: URL(string: "hutch://trackers")!) { + metricColumn( + count: snapshot.assignedOpenTickets, + label: "Assigned", + systemImage: "ticket" + ) + } + Link(destination: URL(string: "hutch://builds")!) { + metricColumn( + count: snapshot.failedBuilds, + label: "Failed", + systemImage: "hammer", + tint: failedTint(for: snapshot.failedBuilds) + ) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.top, 18) + .padding(.bottom, 16) + } + + private func largeView(snapshot: NeedsAttentionSnapshot) -> some View { + VStack(alignment: .leading, spacing: 18) { + Link(destination: URL(string: "hutch://home")!) { + metricRow( count: snapshot.unreadInboxThreads, - label: "Unread", + label: "Unread threads", systemImage: "tray", tint: unreadTint(for: snapshot.unreadInboxThreads) ) - metricColumn( + } + Link(destination: URL(string: "hutch://trackers")!) { + metricRow( count: snapshot.assignedOpenTickets, - label: "Assigned", + label: "Assigned tickets", systemImage: "ticket" ) - metricColumn( + } + Link(destination: URL(string: "hutch://builds")!) { + metricRow( count: snapshot.failedBuilds, - label: "Failed", + label: "Failed builds", systemImage: "hammer", tint: failedTint(for: snapshot.failedBuilds) ) } Spacer(minLength: 0) } - .padding(.horizontal, 16) - .padding(.top, 18) - .padding(.bottom, 16) - } - - private func largeView(snapshot: NeedsAttentionSnapshot) -> some View { - VStack(alignment: .leading, spacing: 18) { - metricRow( - count: snapshot.unreadInboxThreads, - label: "Unread threads", - systemImage: "tray", - tint: unreadTint(for: snapshot.unreadInboxThreads) - ) - metricRow( - count: snapshot.assignedOpenTickets, - label: "Assigned tickets", - systemImage: "ticket" - ) - metricRow( - count: snapshot.failedBuilds, - label: "Failed builds", - systemImage: "hammer", - tint: failedTint(for: snapshot.failedBuilds) - ) - Spacer(minLength: 0) - } .padding(20) } @@ -303,5 +315,6 @@ struct HutchWidgets: WidgetBundle { var body: some Widget { NeedsAttentionWidget() ContributionGraphWidget() + SystemStatusWidget() } } diff --git a/HutchWidgetExtension/SystemStatusWidget.swift b/HutchWidgetExtension/SystemStatusWidget.swift new file mode 100644 index 0000000..4a3c1e5 --- /dev/null +++ b/HutchWidgetExtension/SystemStatusWidget.swift @@ -0,0 +1,177 @@ +import SwiftUI +import WidgetKit + +struct SystemStatusEntry: TimelineEntry { + let date: Date + let snapshot: SystemStatusWidgetSnapshot? +} + +struct SystemStatusTimelineProvider: TimelineProvider { + func placeholder(in _: Context) -> SystemStatusEntry { + SystemStatusEntry( + date: .now, + snapshot: SystemStatusWidgetSnapshot( + services: [ + .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false), + .init(id: "builds", name: "builds.sr.ht", status: "Operational", requiresAttention: false), + .init(id: "lists", name: "lists.sr.ht", status: "Operational", requiresAttention: false), + .init(id: "todo", name: "todo.sr.ht", status: "Operational", requiresAttention: false), + .init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false), + .init(id: "hg", name: "hg.sr.ht", status: "Operational", requiresAttention: false), + ], + hasDisruption: false, + overallStatusText: "All monitored services operational", + bannerSummary: "", + updatedAt: .now + ) + ) + } + + func getSnapshot(in _: Context, completion: @escaping (SystemStatusEntry) -> Void) { + completion( + SystemStatusEntry( + date: .now, + snapshot: SystemStatusWidgetSnapshotStore.load() + ) + ) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: .now) ?? .now.addingTimeInterval(900) + let entry = SystemStatusEntry(date: .now, snapshot: SystemStatusWidgetSnapshotStore.load()) + completion(Timeline(entries: [entry], policy: .after(refreshDate))) + } +} + +struct SystemStatusWidget: Widget { + static let kind = SystemStatusWidgetConfiguration.kind + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: SystemStatusTimelineProvider()) { entry in + SystemStatusWidgetView(entry: entry) + } + .configurationDisplayName("SourceHut Status") + .description("Current status of SourceHut services at a glance.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +private struct SystemStatusWidgetView: View { + let entry: SystemStatusEntry + @Environment(\.widgetFamily) private var family + + var body: some View { + Group { + if let snapshot = entry.snapshot, !snapshot.services.isEmpty { + switch family { + case .systemMedium: + mediumView(snapshot: snapshot) + default: + smallView(snapshot: snapshot) + } + } else { + fallbackView + } + } + .widgetURL(URL(string: "hutch://status")) + .containerBackground(for: .widget) { + Color(.systemBackground) + } + } + + private func smallView(snapshot: SystemStatusWidgetSnapshot) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") + .font(.title3.weight(.semibold)) + .foregroundStyle(snapshot.hasDisruption ? .orange : .green) + Spacer() + } + + Text(snapshot.hasDisruption ? snapshot.bannerSummary : "All Clear") + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + + Spacer(minLength: 0) + + if snapshot.hasDisruption { + let disrupted = snapshot.services.filter(\.requiresAttention) + Text(disrupted.map(\.name).joined(separator: ", ")) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } else { + Text("\(snapshot.services.count) services monitored") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding() + } + + private func mediumView(snapshot: SystemStatusWidgetSnapshot) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") + .font(.title3.weight(.semibold)) + .foregroundStyle(snapshot.hasDisruption ? .orange : .green) + + Text(snapshot.hasDisruption ? snapshot.bannerSummary : "All Services Operational") + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + + Spacer() + } + + Divider() + + let columns = Array(snapshot.services.prefix(8)) + let half = (columns.count + 1) / 2 + let leftColumn = Array(columns.prefix(half)) + let rightColumn = Array(columns.suffix(from: half)) + + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + ForEach(leftColumn) { service in + serviceRow(service) + } + } + VStack(alignment: .leading, spacing: 4) { + ForEach(rightColumn) { service in + serviceRow(service) + } + } + Spacer(minLength: 0) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 12) + } + + private func serviceRow(_ service: SystemStatusWidgetSnapshot.ServiceEntry) -> some View { + HStack(spacing: 6) { + Circle() + .fill(service.requiresAttention ? Color.orange : Color.green) + .frame(width: 6, height: 6) + Text(service.name) + .font(.caption2) + .foregroundStyle(service.requiresAttention ? .primary : .secondary) + .lineLimit(1) + } + } + + private var fallbackView: some View { + VStack(alignment: .leading, spacing: 8) { + Spacer(minLength: 0) + Text("Open Hutch") + .font(.title3.weight(.semibold)) + Text("Sign in to load system status.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + } +} diff --git a/Shared/SystemStatusWidgetSnapshot.swift b/Shared/SystemStatusWidgetSnapshot.swift new file mode 100644 index 0000000..8489547 --- /dev/null +++ b/Shared/SystemStatusWidgetSnapshot.swift @@ -0,0 +1,67 @@ +import Foundation +#if canImport(WidgetKit) +import WidgetKit +#endif + +enum SystemStatusWidgetConfiguration { + static let kind = "SystemStatusWidget" +} + +struct SystemStatusWidgetSnapshot: Codable, Sendable { + let services: [ServiceEntry] + let hasDisruption: Bool + let overallStatusText: String + let bannerSummary: String + let updatedAt: Date + + struct ServiceEntry: Codable, Sendable, Identifiable { + let id: String + let name: String + let status: String + let requiresAttention: Bool + } + + static let unavailable = SystemStatusWidgetSnapshot( + services: [], + hasDisruption: false, + overallStatusText: "Unavailable", + bannerSummary: "", + updatedAt: .now + ) +} + +enum SystemStatusWidgetSnapshotStore { + private static let snapshotKey = "systemStatus.widgetSnapshot" + + static func load(defaults: UserDefaults? = sharedDefaults()) -> SystemStatusWidgetSnapshot? { + guard let defaults, + let data = defaults.data(forKey: snapshotKey) else { + return nil + } + return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data) + } + + static func save(_ snapshot: SystemStatusWidgetSnapshot, defaults: UserDefaults? = sharedDefaults()) { + guard let defaults, + let data = try? JSONEncoder().encode(snapshot) else { + return + } + defaults.set(data, forKey: snapshotKey) + reloadWidgetTimelines() + } + + static func clear(defaults: UserDefaults? = sharedDefaults()) { + defaults?.removeObject(forKey: snapshotKey) + reloadWidgetTimelines() + } + + private static func sharedDefaults() -> UserDefaults? { + UserDefaults(suiteName: HutchAppGroup.identifier) + } + + private static func reloadWidgetTimelines() { + #if canImport(WidgetKit) + WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind) + #endif + } +} -- cgit v1.2.3