krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2: 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 Group {
16 switch appState.authPhase {
17 case .launching:
18 ProgressView("Connecting…")
19 .task {
20 await appState.validateOnLaunch()
21 }
22
23 case .unauthenticated:
24 // Full-screen token entry that cannot be dismissed.
25 TokenEntryView()
26
27 case .authenticated:
28 tabContent
29 }
30 }
31 .onChange(of: appState.pendingDeepLink) { _, newValue in
32 consumePendingDeepLinkIfPossible(newValue)
33 }
34 .onChange(of: appState.authPhase) { _, newPhase in
35 handleAuthPhaseChange(newPhase)
36 }
37 .onChange(of: appState.pendingTabNavigation) { _, newValue in
38 consumePendingTabNavigationIfPossible(newValue)
39 }
40 }
41
42 // MARK: - Tab View
43
44 private var tabContent: some View {
45 @Bindable var appState = appState
46
47 return TabView(selection: $appState.selectedTab) {
48 NavigationStack(path: $homePath) {
49 HomeView()
50 }
51 .tag(AppState.Tab.home)
52 .tabItem {
53 Label("Home", systemImage: "house")
54 }
55
56 NavigationStack(path: $repoPath) {
57 RepositoryListView()
58 }
59 .tag(AppState.Tab.repositories)
60 .tabItem {
61 Label("Repositories", systemImage: "book.closed")
62 }
63
64 NavigationStack(path: $ticketsPath) {
65 TrackerListView()
66 // Deep link destination for jumping straight to a ticket.
67 .navigationDestination(for: TicketDeepLinkTarget.self) { target in
68 TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
69 }
70 }
71 .tag(AppState.Tab.tickets)
72 .tabItem {
73 Label("Tickets", systemImage: "ticket")
74 }
75
76 NavigationStack(path: $buildsPath) {
77 BuildListView()
78 // Int destination used by deep links (hutch://builds/<id>).
79 // JobSummary destination is registered inside BuildListView.
80 .navigationDestination(for: Int.self) { jobId in
81 BuildDetailView(jobId: jobId)
82 }
83 }
84 .tag(AppState.Tab.builds)
85 .tabItem {
86 Label("Builds", systemImage: "hammer")
87 }
88
89 NavigationStack(path: $morePath) {
90 MoreNavigationRoot()
91 }
92 .tag(AppState.Tab.more)
93 .tabItem {
94 Label("More", systemImage: "ellipsis.circle")
95 }
96 }
97 .overlay {
98 if isResolvingDeepLink {
99 ZStack {
100 Color.black.opacity(0.3)
101 .ignoresSafeArea()
102 ProgressView("Opening link…")
103 .padding()
104 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
105 }
106 }
107 }
108 }
109
110 // MARK: - Deep Link Handling
111
112 private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
113 switch newPhase {
114 case .launching:
115 break
116 case .unauthenticated:
117 homePath = NavigationPath()
118 morePath = NavigationPath()
119 repoPath = NavigationPath()
120 buildsPath = NavigationPath()
121 ticketsPath = NavigationPath()
122 appState.selectedTab = .home
123 isResolvingDeepLink = false
124 case .authenticated:
125 consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
126 }
127 }
128
129 private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
130 guard appState.isAuthenticated, let link else { return }
131 handleDeepLink(link)
132 appState.pendingDeepLink = nil
133 }
134
135 private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
136 guard appState.isAuthenticated, let target else { return }
137 handleTabNavigation(target)
138 appState.pendingTabNavigation = nil
139 }
140
141 private func handleDeepLink(_ link: DeepLink) {
142 guard appState.isAuthenticated else { return }
143
144 switch link {
145 case .repository(let owner, let repo):
146 resolveRepositoryLink(owner: owner, repo: repo)
147
148 case .build(let jobId):
149 // Reset the builds navigation and push the detail
150 buildsPath = NavigationPath()
151 appState.selectedTab = .builds
152 // Defer the push slightly so the tab switch takes effect
153 Task { @MainActor in
154 try? await Task.sleep(for: .milliseconds(100))
155 buildsPath.append(jobId)
156 }
157
158 case .ticket(let owner, let tracker, let ticketId):
159 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
160 }
161 }
162
163 private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
164 switch target {
165 case .repository(let repository):
166 repoPath = NavigationPath()
167 appState.selectedTab = .repositories
168 Task { @MainActor in
169 try? await Task.sleep(for: .milliseconds(100))
170 repoPath.append(repository)
171 }
172
173 case .tracker(let tracker):
174 ticketsPath = NavigationPath()
175 appState.selectedTab = .tickets
176 Task { @MainActor in
177 try? await Task.sleep(for: .milliseconds(100))
178 ticketsPath.append(tracker)
179 }
180
181 case .mailingList(let mailingList):
182 morePath = NavigationPath()
183 appState.selectedTab = .more
184 Task { @MainActor in
185 try? await Task.sleep(for: .milliseconds(100))
186 morePath.append(MoreRoute.lists)
187 try? await Task.sleep(for: .milliseconds(100))
188 morePath.append(MoreRoute.mailingList(mailingList))
189 }
190 }
191 }
192
193 private func resolveRepositoryLink(owner: String, repo: String) {
194 isResolvingDeepLink = true
195 Task {
196 defer { isResolvingDeepLink = false }
197 do {
198 let summary = try await appState.resolveRepository(owner: owner, name: repo)
199 repoPath = NavigationPath()
200 appState.selectedTab = .repositories
201 try? await Task.sleep(for: .milliseconds(100))
202 repoPath.append(summary)
203 } catch {
204 // Silently fail — the repo may not exist or be inaccessible
205 }
206 }
207 }
208
209 private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
210 isResolvingDeepLink = true
211 Task {
212 defer { isResolvingDeepLink = false }
213 do {
214 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
215 ticketsPath = NavigationPath()
216 appState.selectedTab = .tickets
217 try? await Task.sleep(for: .milliseconds(100))
218 ticketsPath.append(trackerSummary)
219 try? await Task.sleep(for: .milliseconds(100))
220 ticketsPath.append(TicketDeepLinkTarget(
221 ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
222 trackerName: trackerSummary.name,
223 trackerId: trackerSummary.id,
224 trackerRid: trackerSummary.rid,
225 ticketId: ticketId
226 ))
227 } catch {
228 // Silently fail
229 }
230 }
231 }
232}
233
234enum MoreDestination: Hashable {
235 case lists
236 case pastes
237 case settings
238}
239
240enum MoreRoute: Hashable {
241 case lists
242 case pastes
243 case settings
244 case mailingList(InboxMailingListReference)
245 case thread(InboxThreadSummary)
246}
247
248private struct MoreNavigationRoot: View {
249 var body: some View {
250 MoreView()
251 .navigationDestination(for: MoreRoute.self) { route in
252 switch route {
253 case .lists:
254 MailingListListView()
255 case .pastes:
256 PasteListView()
257 case .settings:
258 SettingsView()
259 case .mailingList(let mailingList):
260 MailingListDetailView(mailingList: mailingList)
261 case .thread(let thread):
262 ThreadDetailView(
263 thread: thread,
264 onViewed: {
265 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
266 },
267 onMarkRead: {
268 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
269 },
270 onMarkUnread: {
271 InboxReadStateStore.markUnread(for: thread.id)
272 }
273 )
274 }
275 }
276 }
277}
278
279// MARK: - Ticket Deep Link Navigation Target
280
281/// Hashable wrapper to push a ticket detail view from a deep link.
282struct TicketDeepLinkTarget: Hashable {
283 let ownerUsername: String
284 let trackerName: String
285 let trackerId: Int
286 let trackerRid: String
287 let ticketId: Int
288}