krz/hutch

an ios client for sourcehut

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

v2.13.1: 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        @Bindable var appState = appState
 16
 17        Group {
 18            switch appState.authPhase {
 19            case .launching:
 20                ProgressView("Connecting…")
 21                    .task {
 22                        await appState.validateOnLaunch()
 23                    }
 24
 25            case .unauthenticated:
 26                // Full-screen token entry that cannot be dismissed.
 27                TokenEntryView()
 28
 29            case .authenticated:
 30                tabContent
 31            }
 32        }
 33        .onChange(of: appState.pendingDeepLink) { _, newValue in
 34            consumePendingDeepLinkIfPossible(newValue)
 35        }
 36        .onChange(of: appState.authPhase) { _, newPhase in
 37            handleAuthPhaseChange(newPhase)
 38        }
 39        .onChange(of: appState.pendingTabNavigation) { _, newValue in
 40            consumePendingTabNavigationIfPossible(newValue)
 41        }
 42        .alert(
 43            "Couldn't Open Link",
 44            isPresented: Binding(
 45                get: { appState.deepLinkError != nil },
 46                set: { isPresented in
 47                    if !isPresented {
 48                        appState.deepLinkError = nil
 49                    }
 50                }
 51            )
 52        ) {
 53            Button("OK") {
 54                appState.deepLinkError = nil
 55            }
 56        } message: {
 57            Text(appState.deepLinkError ?? "")
 58        }
 59    }
 60
 61    // MARK: - Tab View
 62
 63    private var tabContent: some View {
 64        @Bindable var appState = appState
 65
 66        return TabView(selection: $appState.selectedTab) {
 67            NavigationStack(path: $homePath) {
 68                HomeView()
 69            }
 70            .tag(AppState.Tab.home)
 71            .tabItem {
 72                Label("Home", systemImage: "house")
 73            }
 74
 75            NavigationStack(path: $repoPath) {
 76                RepositoryListView()
 77            }
 78            .tag(AppState.Tab.repositories)
 79            .tabItem {
 80                Label("Repositories", systemImage: "book.closed")
 81            }
 82
 83            NavigationStack(path: $ticketsPath) {
 84                TrackerListView()
 85                    // Deep link destination for jumping straight to a ticket.
 86                    .navigationDestination(for: TicketDeepLinkTarget.self) { target in
 87                        TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
 88                    }
 89            }
 90            .tag(AppState.Tab.tickets)
 91            .tabItem {
 92                Label("Trackers", systemImage: "checklist")
 93            }
 94
 95            NavigationStack(path: $buildsPath) {
 96                BuildListView()
 97                    // Int destination used by deep links (hutch://builds/<id>).
 98                    // JobSummary destination is registered inside BuildListView.
 99                    .navigationDestination(for: Int.self) { jobId in
100                        BuildDetailView(jobId: jobId)
101                    }
102            }
103            .tag(AppState.Tab.builds)
104            .tabItem {
105                Label("Builds", systemImage: "hammer")
106            }
107
108            NavigationStack(path: $morePath) {
109                MoreNavigationRoot()
110            }
111            .tag(AppState.Tab.more)
112            .tabItem {
113                Label("More", systemImage: "ellipsis.circle")
114            }
115        }
116        .overlay {
117            if isResolvingDeepLink {
118                ZStack {
119                    Color.black.opacity(0.3)
120                        .ignoresSafeArea()
121                    ProgressView("Opening link…")
122                        .padding()
123                        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
124                }
125            }
126        }
127    }
128
129    // MARK: - Deep Link Handling
130
131    private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
132        switch newPhase {
133        case .launching:
134            break
135        case .unauthenticated:
136            homePath = NavigationPath()
137            morePath = NavigationPath()
138            repoPath = NavigationPath()
139            buildsPath = NavigationPath()
140            ticketsPath = NavigationPath()
141            appState.selectedTab = .home
142            isResolvingDeepLink = false
143        case .authenticated:
144            consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
145        }
146    }
147
148    private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
149        guard appState.isAuthenticated, let link else { return }
150        handleDeepLink(link)
151        appState.pendingDeepLink = nil
152    }
153
154    private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
155        guard appState.isAuthenticated, let target else { return }
156        handleTabNavigation(target)
157        appState.pendingTabNavigation = nil
158    }
159
160    private func handleDeepLink(_ link: DeepLink) {
161        guard appState.isAuthenticated else { return }
162
163        switch link {
164        case .home:
165            homePath = NavigationPath()
166            appState.selectedTab = .home
167
168        case .repository(let owner, let repo):
169            resolveRepositoryLink(owner: owner, repo: repo)
170
171        case .build(let jobId):
172            buildsPath = NavigationPath()
173            appState.selectedTab = .builds
174            Task {
175                await settleNavigationTransition()
176                buildsPath.append(jobId)
177            }
178
179        case .ticket(let owner, let tracker, let ticketId):
180            resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
181        }
182    }
183
184    private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
185        switch target {
186        case .repository(let repository):
187            repoPath = NavigationPath()
188            appState.selectedTab = .repositories
189            Task {
190                await settleNavigationTransition()
191                repoPath.append(repository)
192            }
193
194        case .tracker(let tracker):
195            ticketsPath = NavigationPath()
196            appState.selectedTab = .tickets
197            Task {
198                await settleNavigationTransition()
199                ticketsPath.append(tracker)
200            }
201
202        case .mailingList(let mailingList):
203            morePath = NavigationPath()
204            appState.selectedTab = .more
205            Task {
206                await settleNavigationTransition()
207                morePath.append(MoreRoute.lists)
208                morePath.append(MoreRoute.mailingList(mailingList))
209            }
210        case .systemStatus:
211            morePath = NavigationPath()
212            appState.selectedTab = .more
213            Task {
214                await settleNavigationTransition()
215                morePath.append(MoreRoute.systemStatus)
216            }
217        }
218    }
219
220    private func resolveRepositoryLink(owner: String, repo: String) {
221        isResolvingDeepLink = true
222        Task {
223            defer { isResolvingDeepLink = false }
224            do {
225                let summary = try await appState.resolveRepository(owner: owner, name: repo)
226                repoPath = NavigationPath()
227                appState.selectedTab = .repositories
228                await settleNavigationTransition()
229                repoPath.append(summary)
230            } catch {
231                appState.presentRepositoryDeepLinkError()
232            }
233        }
234    }
235
236    private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
237        isResolvingDeepLink = true
238        Task {
239            defer { isResolvingDeepLink = false }
240            do {
241                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
242                ticketsPath = NavigationPath()
243                appState.selectedTab = .tickets
244                await settleNavigationTransition()
245                ticketsPath.append(trackerSummary)
246                ticketsPath.append(TicketDeepLinkTarget(
247                    ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
248                    trackerName: trackerSummary.name,
249                    trackerId: trackerSummary.id,
250                    trackerRid: trackerSummary.rid,
251                    ticketId: ticketId
252                ))
253            } catch {
254                appState.presentTicketDeepLinkError()
255            }
256        }
257    }
258
259    @MainActor
260    private func settleNavigationTransition() async {
261        await Task.yield()
262        await Task.yield()
263    }
264}
265
266enum MoreDestination: Hashable {
267    case lists
268    case pastes
269    case settings
270}
271
272enum MoreRoute: Hashable {
273    case lookup
274    case lists
275    case pastes
276    case profile
277    case systemStatus
278    case settings
279    case mailingList(InboxMailingListReference)
280    case thread(InboxThreadSummary)
281    case manPageBrowser
282    case manPage(URL)
283}
284
285private struct MoreNavigationRoot: View {
286    var body: some View {
287        MoreView()
288            .navigationDestination(for: MoreRoute.self) { route in
289                switch route {
290                case .lookup:
291                    LookupView()
292                case .lists:
293                    MailingListListView()
294                case .pastes:
295                    PasteListView()
296                case .profile:
297                    ProfileView()
298                case .systemStatus:
299                    SystemStatusView()
300                case .settings:
301                    SettingsView()
302                case .mailingList(let mailingList):
303                    MailingListDetailView(mailingList: mailingList)
304                case .thread(let thread):
305                    ThreadDetailView(
306                        thread: thread,
307                        onViewed: {
308                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
309                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
310                        },
311                        onMarkRead: {
312                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
313                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
314                        },
315                        onMarkUnread: {
316                            InboxReadStateStore.markUnread(for: thread.id)
317                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
318                        }
319                    )
320                case .manPageBrowser:
321                    ManPageBrowserView()
322                case .manPage(let url):
323                    ManPageDetailView(url: url)
324                }
325            }
326    }
327}
328
329// MARK: - Ticket Deep Link Navigation Target
330
331/// Hashable wrapper to push a ticket detail view from a deep link.
332struct TicketDeepLinkTarget: Hashable {
333    let ownerUsername: String
334    let trackerName: String
335    let trackerId: Int
336    let trackerRid: String
337    let ticketId: Int
338}