krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2: Hutch/App/RootView.swift · raw

  1import SwiftUI
  2
  3/// The root view of the app. Shows a TabView when authenticated, or a
  4/// full-screen sheet for token entry on first launch.
  5struct RootView: View {
  6    @Environment(AppState.self) private var appState
  7    @State private var homePath = NavigationPath()
  8    @State private var morePath = NavigationPath()
  9    @State private var repoPath = NavigationPath()
 10    @State private var buildsPath = NavigationPath()
 11    @State private var ticketsPath = NavigationPath()
 12    @State private var isResolvingDeepLink = false
 13
 14    var body: some View {
 15        Group {
 16            switch appState.authPhase {
 17            case .launching:
 18                ProgressView("Connecting…")
 19                    .task {
 20                        await appState.validateOnLaunch()
 21                    }
 22
 23            case .unauthenticated:
 24                // Full-screen token entry that cannot be dismissed.
 25                TokenEntryView()
 26
 27            case .authenticated:
 28                tabContent
 29            }
 30        }
 31        .onChange(of: appState.pendingDeepLink) { _, newValue in
 32            consumePendingDeepLinkIfPossible(newValue)
 33        }
 34        .onChange(of: appState.authPhase) { _, newPhase in
 35            handleAuthPhaseChange(newPhase)
 36        }
 37        .onChange(of: appState.pendingTabNavigation) { _, newValue in
 38            consumePendingTabNavigationIfPossible(newValue)
 39        }
 40    }
 41
 42    // MARK: - Tab View
 43
 44    private var tabContent: some View {
 45        @Bindable var appState = appState
 46
 47        return TabView(selection: $appState.selectedTab) {
 48            NavigationStack(path: $homePath) {
 49                HomeView()
 50            }
 51            .tag(AppState.Tab.home)
 52            .tabItem {
 53                Label("Home", systemImage: "house")
 54            }
 55
 56            NavigationStack(path: $repoPath) {
 57                RepositoryListView()
 58            }
 59            .tag(AppState.Tab.repositories)
 60            .tabItem {
 61                Label("Repositories", systemImage: "book.closed")
 62            }
 63
 64            NavigationStack(path: $ticketsPath) {
 65                TrackerListView()
 66                    // Deep link destination for jumping straight to a ticket.
 67                    .navigationDestination(for: TicketDeepLinkTarget.self) { target in
 68                        TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
 69                    }
 70            }
 71            .tag(AppState.Tab.tickets)
 72            .tabItem {
 73                Label("Tickets", systemImage: "ticket")
 74            }
 75
 76            NavigationStack(path: $buildsPath) {
 77                BuildListView()
 78                    // Int destination used by deep links (hutch://builds/<id>).
 79                    // JobSummary destination is registered inside BuildListView.
 80                    .navigationDestination(for: Int.self) { jobId in
 81                        BuildDetailView(jobId: jobId)
 82                    }
 83            }
 84            .tag(AppState.Tab.builds)
 85            .tabItem {
 86                Label("Builds", systemImage: "hammer")
 87            }
 88
 89            NavigationStack(path: $morePath) {
 90                MoreNavigationRoot()
 91            }
 92            .tag(AppState.Tab.more)
 93            .tabItem {
 94                Label("More", systemImage: "ellipsis.circle")
 95            }
 96        }
 97        .overlay {
 98            if isResolvingDeepLink {
 99                ZStack {
100                    Color.black.opacity(0.3)
101                        .ignoresSafeArea()
102                    ProgressView("Opening link…")
103                        .padding()
104                        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
105                }
106            }
107        }
108    }
109
110    // MARK: - Deep Link Handling
111
112    private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
113        switch newPhase {
114        case .launching:
115            break
116        case .unauthenticated:
117            homePath = NavigationPath()
118            morePath = NavigationPath()
119            repoPath = NavigationPath()
120            buildsPath = NavigationPath()
121            ticketsPath = NavigationPath()
122            appState.selectedTab = .home
123            isResolvingDeepLink = false
124        case .authenticated:
125            consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
126        }
127    }
128
129    private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
130        guard appState.isAuthenticated, let link else { return }
131        handleDeepLink(link)
132        appState.pendingDeepLink = nil
133    }
134
135    private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
136        guard appState.isAuthenticated, let target else { return }
137        handleTabNavigation(target)
138        appState.pendingTabNavigation = nil
139    }
140
141    private func handleDeepLink(_ link: DeepLink) {
142        guard appState.isAuthenticated else { return }
143
144        switch link {
145        case .repository(let owner, let repo):
146            resolveRepositoryLink(owner: owner, repo: repo)
147
148        case .build(let jobId):
149            // Reset the builds navigation and push the detail
150            buildsPath = NavigationPath()
151            appState.selectedTab = .builds
152            // Defer the push slightly so the tab switch takes effect
153            Task { @MainActor in
154                try? await Task.sleep(for: .milliseconds(100))
155                buildsPath.append(jobId)
156            }
157
158        case .ticket(let owner, let tracker, let ticketId):
159            resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
160        }
161    }
162
163    private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
164        switch target {
165        case .repository(let repository):
166            repoPath = NavigationPath()
167            appState.selectedTab = .repositories
168            Task { @MainActor in
169                try? await Task.sleep(for: .milliseconds(100))
170                repoPath.append(repository)
171            }
172
173        case .tracker(let tracker):
174            ticketsPath = NavigationPath()
175            appState.selectedTab = .tickets
176            Task { @MainActor in
177                try? await Task.sleep(for: .milliseconds(100))
178                ticketsPath.append(tracker)
179            }
180
181        case .mailingList(let mailingList):
182            morePath = NavigationPath()
183            appState.selectedTab = .more
184            Task { @MainActor in
185                try? await Task.sleep(for: .milliseconds(100))
186                morePath.append(MoreRoute.lists)
187                try? await Task.sleep(for: .milliseconds(100))
188                morePath.append(MoreRoute.mailingList(mailingList))
189            }
190        }
191    }
192
193    private func resolveRepositoryLink(owner: String, repo: String) {
194        isResolvingDeepLink = true
195        Task {
196            defer { isResolvingDeepLink = false }
197            do {
198                let summary = try await appState.resolveRepository(owner: owner, name: repo)
199                repoPath = NavigationPath()
200                appState.selectedTab = .repositories
201                try? await Task.sleep(for: .milliseconds(100))
202                repoPath.append(summary)
203            } catch {
204                // Silently fail  the repo may not exist or be inaccessible
205            }
206        }
207    }
208
209    private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
210        isResolvingDeepLink = true
211        Task {
212            defer { isResolvingDeepLink = false }
213            do {
214                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
215                ticketsPath = NavigationPath()
216                appState.selectedTab = .tickets
217                try? await Task.sleep(for: .milliseconds(100))
218                ticketsPath.append(trackerSummary)
219                try? await Task.sleep(for: .milliseconds(100))
220                ticketsPath.append(TicketDeepLinkTarget(
221                    ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
222                    trackerName: trackerSummary.name,
223                    trackerId: trackerSummary.id,
224                    trackerRid: trackerSummary.rid,
225                    ticketId: ticketId
226                ))
227            } catch {
228                // Silently fail
229            }
230        }
231    }
232}
233
234enum MoreDestination: Hashable {
235    case lists
236    case pastes
237    case settings
238}
239
240enum MoreRoute: Hashable {
241    case lists
242    case pastes
243    case settings
244    case mailingList(InboxMailingListReference)
245    case thread(InboxThreadSummary)
246}
247
248private struct MoreNavigationRoot: View {
249    var body: some View {
250        MoreView()
251            .navigationDestination(for: MoreRoute.self) { route in
252                switch route {
253                case .lists:
254                    MailingListListView()
255                case .pastes:
256                    PasteListView()
257                case .settings:
258                    SettingsView()
259                case .mailingList(let mailingList):
260                    MailingListDetailView(mailingList: mailingList)
261                case .thread(let thread):
262                    ThreadDetailView(
263                        thread: thread,
264                        onViewed: {
265                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
266                        },
267                        onMarkRead: {
268                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
269                        },
270                        onMarkUnread: {
271                            InboxReadStateStore.markUnread(for: thread.id)
272                        }
273                    )
274                }
275            }
276    }
277}
278
279// MARK: - Ticket Deep Link Navigation Target
280
281/// Hashable wrapper to push a ticket detail view from a deep link.
282struct TicketDeepLinkTarget: Hashable {
283    let ownerUsername: String
284    let trackerName: String
285    let trackerId: Int
286    let trackerRid: String
287    let ticketId: Int
288}