krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.16.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("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 .modifier(SidebarAdaptableTabStyle())
117 .modifier(TabKeyboardShortcuts(selectedTab: Binding(
118 get: { appState.selectedTab },
119 set: { appState.selectedTab = $0 }
120 )))
121 .overlay {
122 if isResolvingDeepLink {
123 ZStack {
124 Color.black.opacity(0.3)
125 .ignoresSafeArea()
126 ProgressView("Opening link…")
127 .padding()
128 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
129 }
130 }
131 }
132 }
133
134 // MARK: - Deep Link Handling
135
136 private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
137 switch newPhase {
138 case .launching:
139 break
140 case .unauthenticated:
141 homePath = NavigationPath()
142 morePath = NavigationPath()
143 repoPath = NavigationPath()
144 buildsPath = NavigationPath()
145 ticketsPath = NavigationPath()
146 appState.selectedTab = .home
147 isResolvingDeepLink = false
148 case .authenticated:
149 consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
150 }
151 }
152
153 private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
154 guard appState.isAuthenticated, let link else { return }
155 handleDeepLink(link)
156 appState.pendingDeepLink = nil
157 }
158
159 private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
160 guard appState.isAuthenticated, let target else { return }
161 handleTabNavigation(target)
162 appState.pendingTabNavigation = nil
163 }
164
165 private func handleDeepLink(_ link: DeepLink) {
166 guard appState.isAuthenticated else { return }
167
168 switch link {
169 case .home:
170 homePath = NavigationPath()
171 appState.selectedTab = .home
172
173 case .repository(let owner, let repo):
174 resolveRepositoryLink(owner: owner, repo: repo)
175
176 case .build(let jobId):
177 buildsPath = NavigationPath()
178 appState.selectedTab = .builds
179 Task {
180 await settleNavigationTransition()
181 buildsPath.append(jobId)
182 }
183
184 case .ticket(let owner, let tracker, let ticketId):
185 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
186
187 case .buildsTab:
188 buildsPath = NavigationPath()
189 appState.selectedTab = .builds
190
191 case .repositoriesTab:
192 repoPath = NavigationPath()
193 appState.selectedTab = .repositories
194
195 case .trackersTab:
196 ticketsPath = NavigationPath()
197 appState.selectedTab = .tickets
198
199 case .systemStatus:
200 appState.navigateToSystemStatus()
201
202 case .lookup:
203 morePath = NavigationPath()
204 appState.selectedTab = .more
205 Task {
206 await settleNavigationTransition()
207 morePath.append(MoreRoute.lookup)
208 }
209 }
210 }
211
212 private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
213 switch target {
214 case .repository(let repository):
215 repoPath = NavigationPath()
216 appState.selectedTab = .repositories
217 Task {
218 await settleNavigationTransition()
219 repoPath.append(repository)
220 }
221
222 case .tracker(let tracker):
223 ticketsPath = NavigationPath()
224 appState.selectedTab = .tickets
225 Task {
226 await settleNavigationTransition()
227 ticketsPath.append(tracker)
228 }
229
230 case .mailingList(let mailingList):
231 morePath = NavigationPath()
232 appState.selectedTab = .more
233 Task {
234 await settleNavigationTransition()
235 morePath.append(MoreRoute.lists)
236 morePath.append(MoreRoute.mailingList(mailingList))
237 }
238 case .systemStatus:
239 morePath = NavigationPath()
240 appState.selectedTab = .more
241 Task {
242 await settleNavigationTransition()
243 morePath.append(MoreRoute.systemStatus)
244 }
245 case .builds:
246 buildsPath = NavigationPath()
247 appState.selectedTab = .builds
248 }
249 }
250
251 private func resolveRepositoryLink(owner: String, repo: String) {
252 isResolvingDeepLink = true
253 Task {
254 defer { isResolvingDeepLink = false }
255 do {
256 let summary = try await appState.resolveRepository(owner: owner, name: repo)
257 repoPath = NavigationPath()
258 appState.selectedTab = .repositories
259 await settleNavigationTransition()
260 repoPath.append(summary)
261 } catch {
262 appState.presentRepositoryDeepLinkError()
263 }
264 }
265 }
266
267 private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
268 isResolvingDeepLink = true
269 Task {
270 defer { isResolvingDeepLink = false }
271 do {
272 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
273 ticketsPath = NavigationPath()
274 appState.selectedTab = .tickets
275 await settleNavigationTransition()
276 ticketsPath.append(trackerSummary)
277 ticketsPath.append(TicketDeepLinkTarget(
278 ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
279 trackerName: trackerSummary.name,
280 trackerId: trackerSummary.id,
281 trackerRid: trackerSummary.rid,
282 ticketId: ticketId
283 ))
284 } catch {
285 appState.presentTicketDeepLinkError()
286 }
287 }
288 }
289
290 @MainActor
291 private func settleNavigationTransition() async {
292 await Task.yield()
293 await Task.yield()
294 }
295}
296
297enum MoreDestination: Hashable {
298 case lists
299 case pastes
300 case settings
301}
302
303enum MoreRoute: Hashable {
304 case lookup
305 case lists
306 case pastes
307 case profile
308 case systemStatus
309 case settings
310 case mailingList(InboxMailingListReference)
311 case thread(InboxThreadSummary)
312 case manPageBrowser
313 case manPage(URL)
314}
315
316private struct MoreNavigationRoot: View {
317 var body: some View {
318 MoreView()
319 .navigationDestination(for: MoreRoute.self) { route in
320 switch route {
321 case .lookup:
322 LookupView()
323 case .lists:
324 MailingListListView()
325 case .pastes:
326 PasteListView()
327 case .profile:
328 ProfileView()
329 case .systemStatus:
330 SystemStatusView()
331 case .settings:
332 SettingsView()
333 case .mailingList(let mailingList):
334 MailingListDetailView(mailingList: mailingList)
335 case .thread(let thread):
336 ThreadDetailView(
337 thread: thread,
338 onViewed: {
339 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
340 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
341 },
342 onMarkRead: {
343 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
344 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
345 },
346 onMarkUnread: {
347 InboxReadStateStore.markUnread(for: thread.id)
348 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
349 }
350 )
351 case .manPageBrowser:
352 ManPageBrowserView()
353 case .manPage(let url):
354 ManPageDetailView(url: url)
355 }
356 }
357 }
358}
359
360// MARK: - Ticket Deep Link Navigation Target
361
362/// Hashable wrapper to push a ticket detail view from a deep link.
363struct TicketDeepLinkTarget: Hashable {
364 let ownerUsername: String
365 let trackerName: String
366 let trackerId: Int
367 let trackerRid: String
368 let ticketId: Int
369}
370
371// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
372
373/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
374private struct TabKeyboardShortcuts: ViewModifier {
375 @Binding var selectedTab: AppState.Tab
376
377 private static let tabMap: [String: AppState.Tab] = [
378 "1": .home,
379 "2": .repositories,
380 "3": .tickets,
381 "4": .builds,
382 "5": .more,
383 ]
384
385 func body(content: Content) -> some View {
386 content
387 .onKeyPress(characters: .decimalDigits, phases: .down) { press in
388 guard press.modifiers == .command else { return .ignored }
389 let key = String(press.characters)
390 if let tab = Self.tabMap[key] {
391 selectedTab = tab
392 return .handled
393 }
394 return .ignored
395 }
396 }
397}
398
399// MARK: - iPad Sidebar Adaptable
400
401/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
402/// becomes a full sidebar on iPad, while falling back to the standard tab
403/// bar on earlier releases.
404private struct SidebarAdaptableTabStyle: ViewModifier {
405 func body(content: Content) -> some View {
406 if #available(iOS 18.0, *) {
407 content.tabViewStyle(.sidebarAdaptable)
408 } else {
409 content
410 }
411 }
412}