krz/hutch

an ios client for sourcehut

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

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