diff options
Diffstat (limited to 'Hutch/App')
| -rw-r--r-- | Hutch/App/AppState.swift | 2 | ||||
| -rw-r--r-- | Hutch/App/DeepLink.swift | 25 | ||||
| -rw-r--r-- | Hutch/App/HutchApp.swift | 15 | ||||
| -rw-r--r-- | Hutch/App/HutchIntents.swift | 154 | ||||
| -rw-r--r-- | Hutch/App/RootView.swift | 71 |
5 files changed, 267 insertions, 0 deletions
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/<jobId> 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://<path> @@ -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<String> { + 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<String> { + 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 + } + } +} |
