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