krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.3.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("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 .repository(let owner, let repo):
165 resolveRepositoryLink(owner: owner, repo: repo)
166
167 case .build(let jobId):
168 buildsPath = NavigationPath()
169 appState.selectedTab = .builds
170 Task {
171 await settleNavigationTransition()
172 buildsPath.append(jobId)
173 }
174
175 case .ticket(let owner, let tracker, let ticketId):
176 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
177 }
178 }
179
180 private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
181 switch target {
182 case .repository(let repository):
183 repoPath = NavigationPath()
184 appState.selectedTab = .repositories
185 Task {
186 await settleNavigationTransition()
187 repoPath.append(repository)
188 }
189
190 case .tracker(let tracker):
191 ticketsPath = NavigationPath()
192 appState.selectedTab = .tickets
193 Task {
194 await settleNavigationTransition()
195 ticketsPath.append(tracker)
196 }
197
198 case .mailingList(let mailingList):
199 morePath = NavigationPath()
200 appState.selectedTab = .more
201 Task {
202 await settleNavigationTransition()
203 morePath.append(MoreRoute.lists)
204 morePath.append(MoreRoute.mailingList(mailingList))
205 }
206 }
207 }
208
209 private func resolveRepositoryLink(owner: String, repo: String) {
210 isResolvingDeepLink = true
211 Task {
212 defer { isResolvingDeepLink = false }
213 do {
214 let summary = try await appState.resolveRepository(owner: owner, name: repo)
215 repoPath = NavigationPath()
216 appState.selectedTab = .repositories
217 await settleNavigationTransition()
218 repoPath.append(summary)
219 } catch {
220 appState.presentRepositoryDeepLinkError()
221 }
222 }
223 }
224
225 private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
226 isResolvingDeepLink = true
227 Task {
228 defer { isResolvingDeepLink = false }
229 do {
230 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
231 ticketsPath = NavigationPath()
232 appState.selectedTab = .tickets
233 await settleNavigationTransition()
234 ticketsPath.append(trackerSummary)
235 ticketsPath.append(TicketDeepLinkTarget(
236 ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
237 trackerName: trackerSummary.name,
238 trackerId: trackerSummary.id,
239 trackerRid: trackerSummary.rid,
240 ticketId: ticketId
241 ))
242 } catch {
243 appState.presentTicketDeepLinkError()
244 }
245 }
246 }
247
248 @MainActor
249 private func settleNavigationTransition() async {
250 await Task.yield()
251 await Task.yield()
252 }
253}
254
255enum MoreDestination: Hashable {
256 case lists
257 case pastes
258 case settings
259}
260
261enum MoreRoute: Hashable {
262 case lists
263 case pastes
264 case settings
265 case mailingList(InboxMailingListReference)
266 case thread(InboxThreadSummary)
267}
268
269private struct MoreNavigationRoot: View {
270 var body: some View {
271 MoreView()
272 .navigationDestination(for: MoreRoute.self) { route in
273 switch route {
274 case .lists:
275 MailingListListView()
276 case .pastes:
277 PasteListView()
278 case .settings:
279 SettingsView()
280 case .mailingList(let mailingList):
281 MailingListDetailView(mailingList: mailingList)
282 case .thread(let thread):
283 ThreadDetailView(
284 thread: thread,
285 onViewed: {
286 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
287 },
288 onMarkRead: {
289 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
290 },
291 onMarkUnread: {
292 InboxReadStateStore.markUnread(for: thread.id)
293 }
294 )
295 }
296 }
297 }
298}
299
300// MARK: - Ticket Deep Link Navigation Target
301
302/// Hashable wrapper to push a ticket detail view from a deep link.
303struct TicketDeepLinkTarget: Hashable {
304 let ownerUsername: String
305 let trackerName: String
306 let trackerId: Int
307 let trackerRid: String
308 let ticketId: Int
309}