krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.5.2: 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("Tickets", systemImage: "ticket")
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 .overlay {
117 if isResolvingDeepLink {
118 ZStack {
119 Color.black.opacity(0.3)
120 .ignoresSafeArea()
121 ProgressView("Opening link…")
122 .padding()
123 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
124 }
125 }
126 }
127 }
128
129 // MARK: - Deep Link Handling
130
131 private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
132 switch newPhase {
133 case .launching:
134 break
135 case .unauthenticated:
136 homePath = NavigationPath()
137 morePath = NavigationPath()
138 repoPath = NavigationPath()
139 buildsPath = NavigationPath()
140 ticketsPath = NavigationPath()
141 appState.selectedTab = .home
142 isResolvingDeepLink = false
143 case .authenticated:
144 consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
145 }
146 }
147
148 private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
149 guard appState.isAuthenticated, let link else { return }
150 handleDeepLink(link)
151 appState.pendingDeepLink = nil
152 }
153
154 private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
155 guard appState.isAuthenticated, let target else { return }
156 handleTabNavigation(target)
157 appState.pendingTabNavigation = nil
158 }
159
160 private func handleDeepLink(_ link: DeepLink) {
161 guard appState.isAuthenticated else { return }
162
163 switch link {
164 case .home:
165 homePath = NavigationPath()
166 appState.selectedTab = .home
167
168 case .repository(let owner, let repo):
169 resolveRepositoryLink(owner: owner, repo: repo)
170
171 case .build(let jobId):
172 buildsPath = NavigationPath()
173 appState.selectedTab = .builds
174 Task {
175 await settleNavigationTransition()
176 buildsPath.append(jobId)
177 }
178
179 case .ticket(let owner, let tracker, let ticketId):
180 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
181 }
182 }
183
184 private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
185 switch target {
186 case .repository(let repository):
187 repoPath = NavigationPath()
188 appState.selectedTab = .repositories
189 Task {
190 await settleNavigationTransition()
191 repoPath.append(repository)
192 }
193
194 case .tracker(let tracker):
195 ticketsPath = NavigationPath()
196 appState.selectedTab = .tickets
197 Task {
198 await settleNavigationTransition()
199 ticketsPath.append(tracker)
200 }
201
202 case .mailingList(let mailingList):
203 morePath = NavigationPath()
204 appState.selectedTab = .more
205 Task {
206 await settleNavigationTransition()
207 morePath.append(MoreRoute.lists)
208 morePath.append(MoreRoute.mailingList(mailingList))
209 }
210 }
211 }
212
213 private func resolveRepositoryLink(owner: String, repo: String) {
214 isResolvingDeepLink = true
215 Task {
216 defer { isResolvingDeepLink = false }
217 do {
218 let summary = try await appState.resolveRepository(owner: owner, name: repo)
219 repoPath = NavigationPath()
220 appState.selectedTab = .repositories
221 await settleNavigationTransition()
222 repoPath.append(summary)
223 } catch {
224 appState.presentRepositoryDeepLinkError()
225 }
226 }
227 }
228
229 private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
230 isResolvingDeepLink = true
231 Task {
232 defer { isResolvingDeepLink = false }
233 do {
234 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
235 ticketsPath = NavigationPath()
236 appState.selectedTab = .tickets
237 await settleNavigationTransition()
238 ticketsPath.append(trackerSummary)
239 ticketsPath.append(TicketDeepLinkTarget(
240 ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
241 trackerName: trackerSummary.name,
242 trackerId: trackerSummary.id,
243 trackerRid: trackerSummary.rid,
244 ticketId: ticketId
245 ))
246 } catch {
247 appState.presentTicketDeepLinkError()
248 }
249 }
250 }
251
252 @MainActor
253 private func settleNavigationTransition() async {
254 await Task.yield()
255 await Task.yield()
256 }
257}
258
259enum MoreDestination: Hashable {
260 case lists
261 case pastes
262 case settings
263}
264
265enum MoreRoute: Hashable {
266 case lists
267 case pastes
268 case settings
269 case mailingList(InboxMailingListReference)
270 case thread(InboxThreadSummary)
271}
272
273private struct MoreNavigationRoot: View {
274 var body: some View {
275 MoreView()
276 .navigationDestination(for: MoreRoute.self) { route in
277 switch route {
278 case .lists:
279 MailingListListView()
280 case .pastes:
281 PasteListView()
282 case .settings:
283 SettingsView()
284 case .mailingList(let mailingList):
285 MailingListDetailView(mailingList: mailingList)
286 case .thread(let thread):
287 ThreadDetailView(
288 thread: thread,
289 onViewed: {
290 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
291 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
292 },
293 onMarkRead: {
294 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
295 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
296 },
297 onMarkUnread: {
298 InboxReadStateStore.markUnread(for: thread.id)
299 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
300 }
301 )
302 }
303 }
304 }
305}
306
307// MARK: - Ticket Deep Link Navigation Target
308
309/// Hashable wrapper to push a ticket detail view from a deep link.
310struct TicketDeepLinkTarget: Hashable {
311 let ownerUsername: String
312 let trackerName: String
313 let trackerId: Int
314 let trackerRid: String
315 let ticketId: Int
316}