diff options
Diffstat (limited to 'Hutch/App')
| -rw-r--r-- | Hutch/App/AppState.swift | 54 | ||||
| -rw-r--r-- | Hutch/App/RootView.swift | 151 |
2 files changed, 173 insertions, 32 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift index 624c78d..d5a86fd 100644 --- a/Hutch/App/AppState.swift +++ b/Hutch/App/AppState.swift @@ -7,6 +7,20 @@ import WebKit @MainActor final class AppState { + enum Tab: Hashable { + case home + case repositories + case tickets + case builds + case more + } + + enum TabNavigationTarget: Hashable { + case repository(RepositorySummary) + case tracker(TrackerSummary) + case mailingList(InboxMailingListReference) + } + enum AuthPhase { /// App just launched, checking for an existing token. case launching @@ -25,6 +39,8 @@ final class AppState { authPhase == .authenticated && currentUser != nil } + var selectedTab: Tab = .home + // MARK: - Current user (populated after successful validation) private(set) var currentUser: User? @@ -37,6 +53,7 @@ final class AppState { /// Set by the deep link handler; consumed by RootView to drive navigation. var pendingDeepLink: DeepLink? + var pendingTabNavigation: TabNavigationTarget? // MARK: - Init @@ -94,6 +111,7 @@ final class AppState { await clearWebData() clearWebContentRenderCaches() authPhase = .unauthenticated + selectedTab = .home } func resetAppData() async { @@ -108,14 +126,15 @@ final class AppState { clearWebContentRenderCaches() authPhase = .unauthenticated + selectedTab = .home } // MARK: - Deep link resolution /// Resolve a repository by owner and name for deep linking. - func resolveRepository(owner: String, name: String) async throws -> RepositorySummary { + func resolveRepository(owner: String, name: String, service: SRHTService = .git) async throws -> RepositorySummary { let result = try await client.execute( - service: .git, + service: service, query: Self.repoLookupQuery, variables: ["owner": owner, "name": name], responseType: RepoLookupResponse.self @@ -134,6 +153,35 @@ final class AppState { return result.user.tracker } + func resolveProjectSource(_ source: Project.SourceRepo) async throws -> RepositorySummary { + try await resolveRepository( + owner: source.ownerUsername, + name: source.name, + service: source.repoType.service + ) + } + + func resolveProjectTracker(_ tracker: Project.Tracker) async throws -> TrackerSummary { + try await resolveTracker(owner: tracker.ownerUsername, name: tracker.name) + } + + func openProjectSource(_ source: Project.SourceRepo) async throws { + let repository = try await resolveProjectSource(source) + pendingTabNavigation = .repository(repository) + selectedTab = .repositories + } + + func openProjectTracker(_ tracker: Project.Tracker) async throws { + let resolvedTracker = try await resolveProjectTracker(tracker) + pendingTabNavigation = .tracker(resolvedTracker) + selectedTab = .tickets + } + + func openMailingList(_ mailingList: InboxMailingListReference) { + pendingTabNavigation = .mailingList(mailingList) + selectedTab = .more + } + // MARK: - Private private static let meQuery = """ @@ -208,6 +256,8 @@ final class AppState { client.responseCache.clear() currentUser = nil pendingDeepLink = nil + pendingTabNavigation = nil + selectedTab = .home } private func clearWebData() async { diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 7af162d..0885835 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -4,15 +4,8 @@ import SwiftUI /// full-screen sheet for token entry on first launch. struct RootView: View { @Environment(AppState.self) private var appState - - enum Tab: Hashable { - case repositories - case builds - case tickets - case settings - } - - @State private var selectedTab: Tab = .repositories + @State private var homePath = NavigationPath() + @State private var morePath = NavigationPath() @State private var repoPath = NavigationPath() @State private var buildsPath = NavigationPath() @State private var ticketsPath = NavigationPath() @@ -41,20 +34,45 @@ struct RootView: View { .onChange(of: appState.authPhase) { _, newPhase in handleAuthPhaseChange(newPhase) } + .onChange(of: appState.pendingTabNavigation) { _, newValue in + consumePendingTabNavigationIfPossible(newValue) + } } // MARK: - Tab View private var tabContent: some View { - TabView(selection: $selectedTab) { + @Bindable var appState = appState + + return TabView(selection: $appState.selectedTab) { + NavigationStack(path: $homePath) { + HomeView() + } + .tag(AppState.Tab.home) + .tabItem { + Label("Home", systemImage: "house") + } + NavigationStack(path: $repoPath) { RepositoryListView() } - .tag(Tab.repositories) + .tag(AppState.Tab.repositories) .tabItem { Label("Repositories", systemImage: "book.closed") } + NavigationStack(path: $ticketsPath) { + TrackerListView() + // Deep link destination for jumping straight to a ticket. + .navigationDestination(for: TicketDeepLinkTarget.self) { target in + TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId) + } + } + .tag(AppState.Tab.tickets) + .tabItem { + Label("Tickets", systemImage: "ticket") + } + NavigationStack(path: $buildsPath) { BuildListView() // Int destination used by deep links (hutch://builds/<id>). @@ -63,28 +81,18 @@ struct RootView: View { BuildDetailView(jobId: jobId) } } - .tag(Tab.builds) + .tag(AppState.Tab.builds) .tabItem { Label("Builds", systemImage: "hammer") } - NavigationStack(path: $ticketsPath) { - TrackerListView() - // Deep link destination for jumping straight to a ticket. - .navigationDestination(for: TicketDeepLinkTarget.self) { target in - TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId) - } + NavigationStack(path: $morePath) { + MoreNavigationRoot() } - .tag(Tab.tickets) + .tag(AppState.Tab.more) .tabItem { - Label("Tickets", systemImage: "ticket") + Label("More", systemImage: "ellipsis.circle") } - - SettingsView() - .tag(Tab.settings) - .tabItem { - Label("Settings", systemImage: "gear") - } } .overlay { if isResolvingDeepLink { @@ -106,10 +114,12 @@ struct RootView: View { case .launching: break case .unauthenticated: + homePath = NavigationPath() + morePath = NavigationPath() repoPath = NavigationPath() buildsPath = NavigationPath() ticketsPath = NavigationPath() - selectedTab = .repositories + appState.selectedTab = .home isResolvingDeepLink = false case .authenticated: consumePendingDeepLinkIfPossible(appState.pendingDeepLink) @@ -122,6 +132,12 @@ struct RootView: View { appState.pendingDeepLink = nil } + private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) { + guard appState.isAuthenticated, let target else { return } + handleTabNavigation(target) + appState.pendingTabNavigation = nil + } + private func handleDeepLink(_ link: DeepLink) { guard appState.isAuthenticated else { return } @@ -132,7 +148,7 @@ struct RootView: View { case .build(let jobId): // Reset the builds navigation and push the detail buildsPath = NavigationPath() - selectedTab = .builds + appState.selectedTab = .builds // Defer the push slightly so the tab switch takes effect Task { @MainActor in try? await Task.sleep(for: .milliseconds(100)) @@ -144,6 +160,36 @@ struct RootView: View { } } + private func handleTabNavigation(_ target: AppState.TabNavigationTarget) { + switch target { + case .repository(let repository): + repoPath = NavigationPath() + appState.selectedTab = .repositories + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + repoPath.append(repository) + } + + case .tracker(let tracker): + ticketsPath = NavigationPath() + appState.selectedTab = .tickets + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + ticketsPath.append(tracker) + } + + case .mailingList(let mailingList): + morePath = NavigationPath() + appState.selectedTab = .more + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + morePath.append(MoreRoute.lists) + try? await Task.sleep(for: .milliseconds(100)) + morePath.append(MoreRoute.mailingList(mailingList)) + } + } + } + private func resolveRepositoryLink(owner: String, repo: String) { isResolvingDeepLink = true Task { @@ -151,7 +197,7 @@ struct RootView: View { do { let summary = try await appState.resolveRepository(owner: owner, name: repo) repoPath = NavigationPath() - selectedTab = .repositories + appState.selectedTab = .repositories try? await Task.sleep(for: .milliseconds(100)) repoPath.append(summary) } catch { @@ -167,7 +213,7 @@ struct RootView: View { do { let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker) ticketsPath = NavigationPath() - selectedTab = .tickets + appState.selectedTab = .tickets try? await Task.sleep(for: .milliseconds(100)) ticketsPath.append(trackerSummary) try? await Task.sleep(for: .milliseconds(100)) @@ -185,6 +231,51 @@ struct RootView: View { } } +enum MoreDestination: Hashable { + case lists + case pastes + case settings +} + +enum MoreRoute: Hashable { + case lists + case pastes + case settings + case mailingList(InboxMailingListReference) + case thread(InboxThreadSummary) +} + +private struct MoreNavigationRoot: View { + var body: some View { + MoreView() + .navigationDestination(for: MoreRoute.self) { route in + switch route { + case .lists: + MailingListListView() + case .pastes: + PasteListView() + case .settings: + SettingsView() + case .mailingList(let mailingList): + MailingListDetailView(mailingList: mailingList) + case .thread(let thread): + ThreadDetailView( + thread: thread, + onViewed: { + InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) + }, + onMarkRead: { + InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) + }, + onMarkUnread: { + InboxReadStateStore.markUnread(for: thread.id) + } + ) + } + } + } +} + // MARK: - Ticket Deep Link Navigation Target /// Hashable wrapper to push a ticket detail view from a deep link. |
