krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.15.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 }
246 }
247
248 private func resolveRepositoryLink(owner: String, repo: String) {
249 isResolvingDeepLink = true
250 Task {
251 defer { isResolvingDeepLink = false }
252 do {
253 let summary = try await appState.resolveRepository(owner: owner, name: repo)
254 repoPath = NavigationPath()
255 appState.selectedTab = .repositories
256 await settleNavigationTransition()
257 repoPath.append(summary)
258 } catch {
259 appState.presentRepositoryDeepLinkError()
260 }
261 }
262 }
263
264 private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
265 isResolvingDeepLink = true
266 Task {
267 defer { isResolvingDeepLink = false }
268 do {
269 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
270 ticketsPath = NavigationPath()
271 appState.selectedTab = .tickets
272 await settleNavigationTransition()
273 ticketsPath.append(trackerSummary)
274 ticketsPath.append(TicketDeepLinkTarget(
275 ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
276 trackerName: trackerSummary.name,
277 trackerId: trackerSummary.id,
278 trackerRid: trackerSummary.rid,
279 ticketId: ticketId
280 ))
281 } catch {
282 appState.presentTicketDeepLinkError()
283 }
284 }
285 }
286
287 @MainActor
288 private func settleNavigationTransition() async {
289 await Task.yield()
290 await Task.yield()
291 }
292}
293
294enum MoreDestination: Hashable {
295 case lists
296 case pastes
297 case settings
298}
299
300enum MoreRoute: Hashable {
301 case lookup
302 case lists
303 case pastes
304 case profile
305 case systemStatus
306 case settings
307 case mailingList(InboxMailingListReference)
308 case thread(InboxThreadSummary)
309 case manPageBrowser
310 case manPage(URL)
311}
312
313private struct MoreNavigationRoot: View {
314 var body: some View {
315 MoreView()
316 .navigationDestination(for: MoreRoute.self) { route in
317 switch route {
318 case .lookup:
319 LookupView()
320 case .lists:
321 MailingListListView()
322 case .pastes:
323 PasteListView()
324 case .profile:
325 ProfileView()
326 case .systemStatus:
327 SystemStatusView()
328 case .settings:
329 SettingsView()
330 case .mailingList(let mailingList):
331 MailingListDetailView(mailingList: mailingList)
332 case .thread(let thread):
333 ThreadDetailView(
334 thread: thread,
335 onViewed: {
336 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
337 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
338 },
339 onMarkRead: {
340 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
341 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
342 },
343 onMarkUnread: {
344 InboxReadStateStore.markUnread(for: thread.id)
345 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
346 }
347 )
348 case .manPageBrowser:
349 ManPageBrowserView()
350 case .manPage(let url):
351 ManPageDetailView(url: url)
352 }
353 }
354 }
355}
356
357// MARK: - Ticket Deep Link Navigation Target
358
359/// Hashable wrapper to push a ticket detail view from a deep link.
360struct TicketDeepLinkTarget: Hashable {
361 let ownerUsername: String
362 let trackerName: String
363 let trackerId: Int
364 let trackerRid: String
365 let ticketId: Int
366}
367
368// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
369
370/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
371private struct TabKeyboardShortcuts: ViewModifier {
372 @Binding var selectedTab: AppState.Tab
373
374 private static let tabMap: [String: AppState.Tab] = [
375 "1": .home,
376 "2": .repositories,
377 "3": .tickets,
378 "4": .builds,
379 "5": .more,
380 ]
381
382 func body(content: Content) -> some View {
383 content
384 .onKeyPress(characters: .decimalDigits, phases: .down) { press in
385 guard press.modifiers == .command else { return .ignored }
386 let key = String(press.characters)
387 if let tab = Self.tabMap[key] {
388 selectedTab = tab
389 return .handled
390 }
391 return .ignored
392 }
393 }
394}
395
396// MARK: - iPad Sidebar Adaptable
397
398/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
399/// becomes a full sidebar on iPad, while falling back to the standard tab
400/// bar on earlier releases.
401private struct SidebarAdaptableTabStyle: ViewModifier {
402 func body(content: Content) -> some View {
403 if #available(iOS 18.0, *) {
404 content.tabViewStyle(.sidebarAdaptable)
405 } else {
406 content
407 }
408 }
409}