krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.15.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        @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("Trackers", systemImage: "checklist")
 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        .modifier(SidebarAdaptableTabStyle())
117        .modifier(TabKeyboardShortcuts(selectedTab: Binding(
118            get: { appState.selectedTab },
119            set: { appState.selectedTab = $0 }
120        )))
121        .overlay {
122            if isResolvingDeepLink {
123                ZStack {
124                    Color.black.opacity(0.3)
125                        .ignoresSafeArea()
126                    ProgressView("Opening link…")
127                        .padding()
128                        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
129                }
130            }
131        }
132    }
133
134    // MARK: - Deep Link Handling
135
136    private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
137        switch newPhase {
138        case .launching:
139            break
140        case .unauthenticated:
141            homePath = NavigationPath()
142            morePath = NavigationPath()
143            repoPath = NavigationPath()
144            buildsPath = NavigationPath()
145            ticketsPath = NavigationPath()
146            appState.selectedTab = .home
147            isResolvingDeepLink = false
148        case .authenticated:
149            consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
150        }
151    }
152
153    private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
154        guard appState.isAuthenticated, let link else { return }
155        handleDeepLink(link)
156        appState.pendingDeepLink = nil
157    }
158
159    private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
160        guard appState.isAuthenticated, let target else { return }
161        handleTabNavigation(target)
162        appState.pendingTabNavigation = nil
163    }
164
165    private func handleDeepLink(_ link: DeepLink) {
166        guard appState.isAuthenticated else { return }
167
168        switch link {
169        case .home:
170            homePath = NavigationPath()
171            appState.selectedTab = .home
172
173        case .repository(let owner, let repo):
174            resolveRepositoryLink(owner: owner, repo: repo)
175
176        case .build(let jobId):
177            buildsPath = NavigationPath()
178            appState.selectedTab = .builds
179            Task {
180                await settleNavigationTransition()
181                buildsPath.append(jobId)
182            }
183
184        case .ticket(let owner, let tracker, let ticketId):
185            resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
186
187        case .buildsTab:
188            buildsPath = NavigationPath()
189            appState.selectedTab = .builds
190
191        case .repositoriesTab:
192            repoPath = NavigationPath()
193            appState.selectedTab = .repositories
194
195        case .trackersTab:
196            ticketsPath = NavigationPath()
197            appState.selectedTab = .tickets
198
199        case .systemStatus:
200            appState.navigateToSystemStatus()
201
202        case .lookup:
203            morePath = NavigationPath()
204            appState.selectedTab = .more
205            Task {
206                await settleNavigationTransition()
207                morePath.append(MoreRoute.lookup)
208            }
209        }
210    }
211
212    private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
213        switch target {
214        case .repository(let repository):
215            repoPath = NavigationPath()
216            appState.selectedTab = .repositories
217            Task {
218                await settleNavigationTransition()
219                repoPath.append(repository)
220            }
221
222        case .tracker(let tracker):
223            ticketsPath = NavigationPath()
224            appState.selectedTab = .tickets
225            Task {
226                await settleNavigationTransition()
227                ticketsPath.append(tracker)
228            }
229
230        case .mailingList(let mailingList):
231            morePath = NavigationPath()
232            appState.selectedTab = .more
233            Task {
234                await settleNavigationTransition()
235                morePath.append(MoreRoute.lists)
236                morePath.append(MoreRoute.mailingList(mailingList))
237            }
238        case .systemStatus:
239            morePath = NavigationPath()
240            appState.selectedTab = .more
241            Task {
242                await settleNavigationTransition()
243                morePath.append(MoreRoute.systemStatus)
244            }
245        }
246    }
247
248    private func resolveRepositoryLink(owner: String, repo: String) {
249        isResolvingDeepLink = true
250        Task {
251            defer { isResolvingDeepLink = false }
252            do {
253                let summary = try await appState.resolveRepository(owner: owner, name: repo)
254                repoPath = NavigationPath()
255                appState.selectedTab = .repositories
256                await settleNavigationTransition()
257                repoPath.append(summary)
258            } catch {
259                appState.presentRepositoryDeepLinkError()
260            }
261        }
262    }
263
264    private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
265        isResolvingDeepLink = true
266        Task {
267            defer { isResolvingDeepLink = false }
268            do {
269                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
270                ticketsPath = NavigationPath()
271                appState.selectedTab = .tickets
272                await settleNavigationTransition()
273                ticketsPath.append(trackerSummary)
274                ticketsPath.append(TicketDeepLinkTarget(
275                    ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
276                    trackerName: trackerSummary.name,
277                    trackerId: trackerSummary.id,
278                    trackerRid: trackerSummary.rid,
279                    ticketId: ticketId
280                ))
281            } catch {
282                appState.presentTicketDeepLinkError()
283            }
284        }
285    }
286
287    @MainActor
288    private func settleNavigationTransition() async {
289        await Task.yield()
290        await Task.yield()
291    }
292}
293
294enum MoreDestination: Hashable {
295    case lists
296    case pastes
297    case settings
298}
299
300enum MoreRoute: Hashable {
301    case lookup
302    case lists
303    case pastes
304    case profile
305    case systemStatus
306    case settings
307    case mailingList(InboxMailingListReference)
308    case thread(InboxThreadSummary)
309    case manPageBrowser
310    case manPage(URL)
311}
312
313private struct MoreNavigationRoot: View {
314    var body: some View {
315        MoreView()
316            .navigationDestination(for: MoreRoute.self) { route in
317                switch route {
318                case .lookup:
319                    LookupView()
320                case .lists:
321                    MailingListListView()
322                case .pastes:
323                    PasteListView()
324                case .profile:
325                    ProfileView()
326                case .systemStatus:
327                    SystemStatusView()
328                case .settings:
329                    SettingsView()
330                case .mailingList(let mailingList):
331                    MailingListDetailView(mailingList: mailingList)
332                case .thread(let thread):
333                    ThreadDetailView(
334                        thread: thread,
335                        onViewed: {
336                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
337                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
338                        },
339                        onMarkRead: {
340                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
341                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
342                        },
343                        onMarkUnread: {
344                            InboxReadStateStore.markUnread(for: thread.id)
345                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
346                        }
347                    )
348                case .manPageBrowser:
349                    ManPageBrowserView()
350                case .manPage(let url):
351                    ManPageDetailView(url: url)
352                }
353            }
354    }
355}
356
357// MARK: - Ticket Deep Link Navigation Target
358
359/// Hashable wrapper to push a ticket detail view from a deep link.
360struct TicketDeepLinkTarget: Hashable {
361    let ownerUsername: String
362    let trackerName: String
363    let trackerId: Int
364    let trackerRid: String
365    let ticketId: Int
366}
367
368// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
369
370/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
371private struct TabKeyboardShortcuts: ViewModifier {
372    @Binding var selectedTab: AppState.Tab
373
374    private static let tabMap: [String: AppState.Tab] = [
375        "1": .home,
376        "2": .repositories,
377        "3": .tickets,
378        "4": .builds,
379        "5": .more,
380    ]
381
382    func body(content: Content) -> some View {
383        content
384            .onKeyPress(characters: .decimalDigits, phases: .down) { press in
385                guard press.modifiers == .command else { return .ignored }
386                let key = String(press.characters)
387                if let tab = Self.tabMap[key] {
388                    selectedTab = tab
389                    return .handled
390                }
391                return .ignored
392            }
393    }
394}
395
396// MARK: - iPad Sidebar Adaptable
397
398/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
399/// becomes a full sidebar on iPad, while falling back to the standard tab
400/// bar on earlier releases.
401private struct SidebarAdaptableTabStyle: ViewModifier {
402    func body(content: Content) -> some View {
403        if #available(iOS 18.0, *) {
404            content.tabViewStyle(.sidebarAdaptable)
405        } else {
406            content
407        }
408    }
409}