krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.4: 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 mailingList(InboxMailingListReference)
340 case thread(InboxThreadSummary)
341 case manPageBrowser
342 case manPage(URL)
343}
344
345private struct MoreNavigationRoot: View {
346 @Environment(AppState.self) private var appState
347
348 var body: some View {
349 MoreView()
350 .navigationDestination(for: MoreRoute.self) { route in
351 switch route {
352 case .lookup:
353 LookupView()
354 case .projects:
355 ProjectsListView()
356 case .lists:
357 MailingListListView()
358 case .pastes:
359 PasteListView()
360 case .profile:
361 ProfileView()
362 case .systemStatus:
363 SystemStatusView()
364 case .settings:
365 SettingsView()
366 case .mailingList(let mailingList):
367 MailingListDetailView(mailingList: mailingList)
368 case .thread(let thread):
369 ThreadDetailView(
370 thread: thread,
371 onViewed: {
372 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
373 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
374 },
375 onMarkRead: {
376 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
377 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
378 },
379 onMarkUnread: {
380 InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
381 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
382 }
383 )
384 case .manPageBrowser:
385 ManPageBrowserView()
386 case .manPage(let url):
387 ManPageDetailView(url: url)
388 }
389 }
390 }
391}
392
393// MARK: - Ticket Deep Link Navigation Target
394
395/// Hashable wrapper to push a ticket detail view from a deep link.
396struct TicketDeepLinkTarget: Hashable {
397 let ownerUsername: String
398 let trackerName: String
399 let trackerId: Int
400 let trackerRid: String
401 let ticketId: Int
402}
403
404// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
405
406/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
407private struct TabKeyboardShortcuts: ViewModifier {
408 @Binding var selectedTab: AppState.Tab
409
410 private static let tabMap: [String: AppState.Tab] = [
411 "1": .home,
412 "2": .repositories,
413 "3": .tickets,
414 "4": .builds,
415 "5": .more,
416 ]
417
418 func body(content: Content) -> some View {
419 content
420 .onKeyPress(characters: .decimalDigits, phases: .down) { press in
421 guard press.modifiers == .command else { return .ignored }
422 let key = String(press.characters)
423 if let tab = Self.tabMap[key] {
424 selectedTab = tab
425 return .handled
426 }
427 return .ignored
428 }
429 }
430}
431
432// MARK: - AMOLED Toolbar Styling
433
434/// Applies true-black backgrounds to the tab bar and navigation bar when the AMOLED theme is active.
435private struct AMOLEDToolbarStyle: ViewModifier {
436 let isAMOLED: Bool
437
438 func body(content: Content) -> some View {
439 if isAMOLED {
440 content
441 .toolbarBackground(Color.black, for: .tabBar)
442 .toolbarBackground(.visible, for: .tabBar)
443 .toolbarBackground(Color.black, for: .navigationBar)
444 .toolbarBackground(.visible, for: .navigationBar)
445 } else {
446 content
447 }
448 }
449}
450
451// MARK: - iPad Sidebar Adaptable
452
453/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
454/// becomes a full sidebar on iPad, while falling back to the standard tab
455/// bar on earlier releases.
456private struct SidebarAdaptableTabStyle: ViewModifier {
457 func body(content: Content) -> some View {
458 if #available(iOS 18.0, *) {
459 content.tabViewStyle(.sidebarAdaptable)
460 } else {
461 content
462 }
463 }
464}