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}