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