krz/hutch

an ios client for sourcehut

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

v2.5.2: 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("Tickets", systemImage: "ticket")
 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        }
211    }
212
213    private func resolveRepositoryLink(owner: String, repo: String) {
214        isResolvingDeepLink = true
215        Task {
216            defer { isResolvingDeepLink = false }
217            do {
218                let summary = try await appState.resolveRepository(owner: owner, name: repo)
219                repoPath = NavigationPath()
220                appState.selectedTab = .repositories
221                await settleNavigationTransition()
222                repoPath.append(summary)
223            } catch {
224                appState.presentRepositoryDeepLinkError()
225            }
226        }
227    }
228
229    private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
230        isResolvingDeepLink = true
231        Task {
232            defer { isResolvingDeepLink = false }
233            do {
234                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
235                ticketsPath = NavigationPath()
236                appState.selectedTab = .tickets
237                await settleNavigationTransition()
238                ticketsPath.append(trackerSummary)
239                ticketsPath.append(TicketDeepLinkTarget(
240                    ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
241                    trackerName: trackerSummary.name,
242                    trackerId: trackerSummary.id,
243                    trackerRid: trackerSummary.rid,
244                    ticketId: ticketId
245                ))
246            } catch {
247                appState.presentTicketDeepLinkError()
248            }
249        }
250    }
251
252    @MainActor
253    private func settleNavigationTransition() async {
254        await Task.yield()
255        await Task.yield()
256    }
257}
258
259enum MoreDestination: Hashable {
260    case lists
261    case pastes
262    case settings
263}
264
265enum MoreRoute: Hashable {
266    case lists
267    case pastes
268    case settings
269    case mailingList(InboxMailingListReference)
270    case thread(InboxThreadSummary)
271}
272
273private struct MoreNavigationRoot: View {
274    var body: some View {
275        MoreView()
276            .navigationDestination(for: MoreRoute.self) { route in
277                switch route {
278                case .lists:
279                    MailingListListView()
280                case .pastes:
281                    PasteListView()
282                case .settings:
283                    SettingsView()
284                case .mailingList(let mailingList):
285                    MailingListDetailView(mailingList: mailingList)
286                case .thread(let thread):
287                    ThreadDetailView(
288                        thread: thread,
289                        onViewed: {
290                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
291                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
292                        },
293                        onMarkRead: {
294                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
295                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
296                        },
297                        onMarkUnread: {
298                            InboxReadStateStore.markUnread(for: thread.id)
299                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
300                        }
301                    )
302                }
303            }
304    }
305}
306
307// MARK: - Ticket Deep Link Navigation Target
308
309/// Hashable wrapper to push a ticket detail view from a deep link.
310struct TicketDeepLinkTarget: Hashable {
311    let ownerUsername: String
312    let trackerName: String
313    let trackerId: Int
314    let trackerRid: String
315    let ticketId: Int
316}