krz/hutch

an ios client for sourcehut

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

v2.3.0: 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 .repository(let owner, let repo):
165            resolveRepositoryLink(owner: owner, repo: repo)
166
167        case .build(let jobId):
168            buildsPath = NavigationPath()
169            appState.selectedTab = .builds
170            Task {
171                await settleNavigationTransition()
172                buildsPath.append(jobId)
173            }
174
175        case .ticket(let owner, let tracker, let ticketId):
176            resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
177        }
178    }
179
180    private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
181        switch target {
182        case .repository(let repository):
183            repoPath = NavigationPath()
184            appState.selectedTab = .repositories
185            Task {
186                await settleNavigationTransition()
187                repoPath.append(repository)
188            }
189
190        case .tracker(let tracker):
191            ticketsPath = NavigationPath()
192            appState.selectedTab = .tickets
193            Task {
194                await settleNavigationTransition()
195                ticketsPath.append(tracker)
196            }
197
198        case .mailingList(let mailingList):
199            morePath = NavigationPath()
200            appState.selectedTab = .more
201            Task {
202                await settleNavigationTransition()
203                morePath.append(MoreRoute.lists)
204                morePath.append(MoreRoute.mailingList(mailingList))
205            }
206        }
207    }
208
209    private func resolveRepositoryLink(owner: String, repo: String) {
210        isResolvingDeepLink = true
211        Task {
212            defer { isResolvingDeepLink = false }
213            do {
214                let summary = try await appState.resolveRepository(owner: owner, name: repo)
215                repoPath = NavigationPath()
216                appState.selectedTab = .repositories
217                await settleNavigationTransition()
218                repoPath.append(summary)
219            } catch {
220                appState.presentRepositoryDeepLinkError()
221            }
222        }
223    }
224
225    private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
226        isResolvingDeepLink = true
227        Task {
228            defer { isResolvingDeepLink = false }
229            do {
230                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
231                ticketsPath = NavigationPath()
232                appState.selectedTab = .tickets
233                await settleNavigationTransition()
234                ticketsPath.append(trackerSummary)
235                ticketsPath.append(TicketDeepLinkTarget(
236                    ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
237                    trackerName: trackerSummary.name,
238                    trackerId: trackerSummary.id,
239                    trackerRid: trackerSummary.rid,
240                    ticketId: ticketId
241                ))
242            } catch {
243                appState.presentTicketDeepLinkError()
244            }
245        }
246    }
247
248    @MainActor
249    private func settleNavigationTransition() async {
250        await Task.yield()
251        await Task.yield()
252    }
253}
254
255enum MoreDestination: Hashable {
256    case lists
257    case pastes
258    case settings
259}
260
261enum MoreRoute: Hashable {
262    case lists
263    case pastes
264    case settings
265    case mailingList(InboxMailingListReference)
266    case thread(InboxThreadSummary)
267}
268
269private struct MoreNavigationRoot: View {
270    var body: some View {
271        MoreView()
272            .navigationDestination(for: MoreRoute.self) { route in
273                switch route {
274                case .lists:
275                    MailingListListView()
276                case .pastes:
277                    PasteListView()
278                case .settings:
279                    SettingsView()
280                case .mailingList(let mailingList):
281                    MailingListDetailView(mailingList: mailingList)
282                case .thread(let thread):
283                    ThreadDetailView(
284                        thread: thread,
285                        onViewed: {
286                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
287                        },
288                        onMarkRead: {
289                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
290                        },
291                        onMarkUnread: {
292                            InboxReadStateStore.markUnread(for: thread.id)
293                        }
294                    )
295                }
296            }
297    }
298}
299
300// MARK: - Ticket Deep Link Navigation Target
301
302/// Hashable wrapper to push a ticket detail view from a deep link.
303struct TicketDeepLinkTarget: Hashable {
304    let ownerUsername: String
305    let trackerName: String
306    let trackerId: Int
307    let trackerRid: String
308    let ticketId: Int
309}