krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.2.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 @Environment(\.isAMOLEDTheme) private var isAMOLED
8 @State private var homePath = NavigationPath()
9 @State private var morePath = NavigationPath()
10 @State private var repoPath = NavigationPath()
11 @State private var buildsPath = NavigationPath()
12 @State private var ticketsPath = NavigationPath()
13 @State private var isResolvingDeepLink = false
14 @State private var hasValidatedLaunch = false
15
16 var body: some View {
17 @Bindable var appState = appState
18
19 Group {
20 switch appState.authPhase {
21 case .launching:
22 ProgressView(appState.authStatusMessage)
23 .task {
24 guard !hasValidatedLaunch else { return }
25 hasValidatedLaunch = true
26 await appState.validateOnLaunch()
27 }
28
29 case .unauthenticated:
30 // Full-screen token entry that cannot be dismissed.
31 TokenEntryView()
32
33 case .authenticated:
34 tabContent
35 }
36 }
37 .onChange(of: appState.pendingDeepLink) { _, newValue in
38 consumePendingDeepLinkIfPossible(newValue)
39 }
40 .onChange(of: appState.authPhase) { _, newPhase in
41 handleAuthPhaseChange(newPhase)
42 }
43 .onChange(of: appState.pendingTabNavigation) { _, newValue in
44 consumePendingTabNavigationIfPossible(newValue)
45 }
46 .alert(
47 "Couldn't Open Link",
48 isPresented: Binding(
49 get: { appState.deepLinkError != nil },
50 set: { isPresented in
51 if !isPresented {
52 appState.deepLinkError = nil
53 }
54 }
55 )
56 ) {
57 Button("OK") {
58 appState.deepLinkError = nil
59 }
60 } message: {
61 Text(appState.deepLinkError ?? "")
62 }
63 }
64
65 // MARK: - Tab View
66
67 private var tabContent: some View {
68 @Bindable var appState = appState
69
70 return TabView(selection: $appState.selectedTab) {
71 NavigationStack(path: $homePath) {
72 HomeView()
73 .navigationDestination(for: HomeRoute.self) { route in
74 switch route {
75 case .work:
76 WorkView()
77 }
78 }
79 }
80 .tag(AppState.Tab.home)
81 .tabItem {
82 Label("Home", systemImage: "house")
83 }
84
85 NavigationStack(path: $repoPath) {
86 RepositoryListView()
87 }
88 .tag(AppState.Tab.repositories)
89 .tabItem {
90 Label("Repositories", systemImage: "book.closed")
91 }
92
93 NavigationStack(path: $ticketsPath) {
94 TrackerListView()
95 // Deep link destination for jumping straight to a ticket.
96 .navigationDestination(for: TicketDeepLinkTarget.self) { target in
97 TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
98 }
99 }
100 .tag(AppState.Tab.tickets)
101 .tabItem {
102 Label("Trackers", systemImage: "checklist")
103 }
104
105 NavigationStack(path: $buildsPath) {
106 BuildListView()
107 // Int destination used by deep links (hutch://builds/<id>).
108 // JobSummary destination is registered inside BuildListView.
109 .navigationDestination(for: Int.self) { jobId in
110 BuildDetailView(jobId: jobId)
111 }
112 }
113 .tag(AppState.Tab.builds)
114 .tabItem {
115 Label("Builds", systemImage: "hammer")
116 }
117
118 NavigationStack(path: $morePath) {
119 MoreNavigationRoot()
120 }
121 .tag(AppState.Tab.more)
122 .tabItem {
123 Label("More", systemImage: "ellipsis.circle")
124 }
125 }
126 .id(appState.sessionIdentity)
127 .defaultAppStorage(appState.accountDefaults)
128 .modifier(SidebarAdaptableTabStyle())
129 .modifier(AMOLEDToolbarStyle(isAMOLED: isAMOLED))
130 .modifier(TabKeyboardShortcuts(selectedTab: Binding(
131 get: { appState.selectedTab },
132 set: { appState.selectedTab = $0 }
133 )))
134 .safeAreaInset(edge: .bottom) {
135 if let message = appState.copyConfirmationMessage {
136 CopyConfirmationBadge(message: message)
137 .padding(.bottom, 4)
138 .transition(.move(edge: .bottom).combined(with: .opacity))
139 }
140 }
141 .overlay {
142 if isResolvingDeepLink {
143 ZStack {
144 Color.black.opacity(0.3)
145 .ignoresSafeArea()
146 ProgressView("Opening link…")
147 .padding()
148 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
149 }
150 }
151 }
152 }
153
154 // MARK: - Deep Link Handling
155
156 private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
157 switch newPhase {
158 case .launching:
159 break
160 case .unauthenticated:
161 homePath = NavigationPath()
162 morePath = NavigationPath()
163 repoPath = NavigationPath()
164 buildsPath = NavigationPath()
165 ticketsPath = NavigationPath()
166 appState.selectedTab = .home
167 isResolvingDeepLink = false
168 case .authenticated:
169 consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
170 }
171 }
172
173 private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
174 guard appState.isAuthenticated, let link else { return }
175 handleDeepLink(link)
176 appState.pendingDeepLink = nil
177 }
178
179 private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
180 guard appState.isAuthenticated, let target else { return }
181 handleTabNavigation(target)
182 appState.pendingTabNavigation = nil
183 }
184
185 private func handleDeepLink(_ link: DeepLink) {
186 guard appState.isAuthenticated else { return }
187
188 switch link {
189 case .home:
190 homePath = NavigationPath()
191 appState.selectedTab = .home
192
193 case .repository(let owner, let repo):
194 resolveRepositoryLink(owner: owner, repo: repo)
195
196 case .build(let jobId):
197 buildsPath = NavigationPath()
198 appState.selectedTab = .builds
199 Task {
200 await settleNavigationTransition()
201 buildsPath.append(jobId)
202 }
203
204 case .ticket(let owner, let tracker, let ticketId):
205 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
206
207 case .work:
208 homePath = NavigationPath()
209 appState.selectedTab = .home
210 Task {
211 await settleNavigationTransition()
212 homePath.append(HomeRoute.work)
213 }
214
215 case .buildsTab:
216 buildsPath = NavigationPath()
217 appState.selectedTab = .builds
218
219 case .repositoriesTab:
220 repoPath = NavigationPath()
221 appState.selectedTab = .repositories
222
223 case .trackersTab:
224 ticketsPath = NavigationPath()
225 appState.selectedTab = .tickets
226
227 case .systemStatus:
228 appState.navigateToSystemStatus()
229
230 case .lookup:
231 morePath = NavigationPath()
232 appState.selectedTab = .more
233 Task {
234 await settleNavigationTransition()
235 morePath.append(MoreRoute.lookup)
236 }
237 }
238 }
239
240 private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
241 switch target {
242 case .repository(let repository):
243 repoPath = NavigationPath()
244 appState.selectedTab = .repositories
245 Task {
246 await settleNavigationTransition()
247 repoPath.append(repository)
248 }
249
250 case .tracker(let tracker):
251 ticketsPath = NavigationPath()
252 appState.selectedTab = .tickets
253 Task {
254 await settleNavigationTransition()
255 ticketsPath.append(tracker)
256 }
257
258 case .mailingList(let mailingList):
259 morePath = NavigationPath()
260 appState.selectedTab = .more
261 Task {
262 await settleNavigationTransition()
263 morePath.append(MoreRoute.lists)
264 morePath.append(MoreRoute.mailingList(mailingList))
265 }
266 case .systemStatus:
267 morePath = NavigationPath()
268 appState.selectedTab = .more
269 Task {
270 await settleNavigationTransition()
271 morePath.append(MoreRoute.systemStatus)
272 }
273 case .builds:
274 buildsPath = NavigationPath()
275 appState.selectedTab = .builds
276 }
277 }
278
279 private func resolveRepositoryLink(owner: String, repo: String) {
280 isResolvingDeepLink = true
281 Task {
282 defer { isResolvingDeepLink = false }
283 do {
284 let summary = try await appState.resolveRepository(owner: owner, name: repo)
285 repoPath = NavigationPath()
286 appState.selectedTab = .repositories
287 await settleNavigationTransition()
288 repoPath.append(summary)
289 } catch {
290 appState.presentRepositoryDeepLinkError()
291 }
292 }
293 }
294
295 private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
296 isResolvingDeepLink = true
297 Task {
298 defer { isResolvingDeepLink = false }
299 do {
300 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
301 ticketsPath = NavigationPath()
302 appState.selectedTab = .tickets
303 await settleNavigationTransition()
304 ticketsPath.append(trackerSummary)
305 ticketsPath.append(TicketDeepLinkTarget(
306 ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
307 trackerName: trackerSummary.name,
308 trackerId: trackerSummary.id,
309 trackerRid: trackerSummary.rid,
310 ticketId: ticketId
311 ))
312 } catch {
313 appState.presentTicketDeepLinkError()
314 }
315 }
316 }
317
318 @MainActor
319 private func settleNavigationTransition() async {
320 await Task.yield()
321 await Task.yield()
322 }
323}
324
325enum MoreDestination: Hashable {
326 case lists
327 case pastes
328 case settings
329}
330
331enum MoreRoute: Hashable {
332 case lookup
333 case projects
334 case lists
335 case pastes
336 case profile
337 case systemStatus
338 case settings
339 case about
340 case mailingList(InboxMailingListReference)
341 case thread(InboxThreadSummary)
342 case manPageBrowser
343 case manPage(URL)
344}
345
346private struct MoreNavigationRoot: View {
347 @Environment(AppState.self) private var appState
348
349 var body: some View {
350 MoreView()
351 .navigationDestination(for: MoreRoute.self) { route in
352 switch route {
353 case .lookup:
354 LookupView()
355 case .projects:
356 ProjectsListView()
357 case .lists:
358 MailingListListView()
359 case .pastes:
360 PasteListView()
361 case .profile:
362 ProfileView()
363 case .systemStatus:
364 SystemStatusView()
365 case .settings:
366 SettingsView()
367 case .about:
368 AboutView()
369 case .mailingList(let mailingList):
370 MailingListDetailView(mailingList: mailingList)
371 case .thread(let thread):
372 ThreadDetailView(
373 thread: thread,
374 onViewed: {
375 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
376 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
377 },
378 onMarkRead: {
379 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
380 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
381 },
382 onMarkUnread: {
383 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
384 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
385 }
386 )
387 case .manPageBrowser:
388 ManPageBrowserView()
389 case .manPage(let url):
390 ManPageDetailView(url: url)
391 }
392 }
393 }
394}
395
396// MARK: - Ticket Deep Link Navigation Target
397
398/// Hashable wrapper to push a ticket detail view from a deep link.
399struct TicketDeepLinkTarget: Hashable {
400 let ownerUsername: String
401 let trackerName: String
402 let trackerId: Int
403 let trackerRid: String
404 let ticketId: Int
405}
406
407// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
408
409/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
410private struct TabKeyboardShortcuts: ViewModifier {
411 @Binding var selectedTab: AppState.Tab
412
413 private static let tabMap: [String: AppState.Tab] = [
414 "1": .home,
415 "2": .repositories,
416 "3": .tickets,
417 "4": .builds,
418 "5": .more,
419 ]
420
421 func body(content: Content) -> some View {
422 content
423 .onKeyPress(characters: .decimalDigits, phases: .down) { press in
424 guard press.modifiers == .command else { return .ignored }
425 let key = String(press.characters)
426 if let tab = Self.tabMap[key] {
427 selectedTab = tab
428 return .handled
429 }
430 return .ignored
431 }
432 }
433}
434
435// MARK: - AMOLED Toolbar Styling
436
437/// Applies true-black backgrounds to the tab bar and navigation bar when the AMOLED theme is active.
438private struct AMOLEDToolbarStyle: ViewModifier {
439 let isAMOLED: Bool
440
441 func body(content: Content) -> some View {
442 if isAMOLED {
443 content
444 .toolbarBackground(Color.black, for: .tabBar)
445 .toolbarBackground(.visible, for: .tabBar)
446 .toolbarBackground(Color.black, for: .navigationBar)
447 .toolbarBackground(.visible, for: .navigationBar)
448 } else {
449 content
450 }
451 }
452}
453
454// MARK: - iPad Sidebar Adaptable
455
456/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
457/// becomes a full sidebar on iPad, while falling back to the standard tab
458/// bar on earlier releases.
459private struct SidebarAdaptableTabStyle: ViewModifier {
460 func body(content: Content) -> some View {
461 if #available(iOS 18.0, *) {
462 content.tabViewStyle(.sidebarAdaptable)
463 } else {
464 content
465 }
466 }
467}