krz/hutch

an ios client for sourcehut

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

v2.16.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("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        case .builds:
246            buildsPath = NavigationPath()
247            appState.selectedTab = .builds
248        }
249    }
250
251    private func resolveRepositoryLink(owner: String, repo: String) {
252        isResolvingDeepLink = true
253        Task {
254            defer { isResolvingDeepLink = false }
255            do {
256                let summary = try await appState.resolveRepository(owner: owner, name: repo)
257                repoPath = NavigationPath()
258                appState.selectedTab = .repositories
259                await settleNavigationTransition()
260                repoPath.append(summary)
261            } catch {
262                appState.presentRepositoryDeepLinkError()
263            }
264        }
265    }
266
267    private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
268        isResolvingDeepLink = true
269        Task {
270            defer { isResolvingDeepLink = false }
271            do {
272                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
273                ticketsPath = NavigationPath()
274                appState.selectedTab = .tickets
275                await settleNavigationTransition()
276                ticketsPath.append(trackerSummary)
277                ticketsPath.append(TicketDeepLinkTarget(
278                    ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
279                    trackerName: trackerSummary.name,
280                    trackerId: trackerSummary.id,
281                    trackerRid: trackerSummary.rid,
282                    ticketId: ticketId
283                ))
284            } catch {
285                appState.presentTicketDeepLinkError()
286            }
287        }
288    }
289
290    @MainActor
291    private func settleNavigationTransition() async {
292        await Task.yield()
293        await Task.yield()
294    }
295}
296
297enum MoreDestination: Hashable {
298    case lists
299    case pastes
300    case settings
301}
302
303enum MoreRoute: Hashable {
304    case lookup
305    case lists
306    case pastes
307    case profile
308    case systemStatus
309    case settings
310    case mailingList(InboxMailingListReference)
311    case thread(InboxThreadSummary)
312    case manPageBrowser
313    case manPage(URL)
314}
315
316private struct MoreNavigationRoot: View {
317    var body: some View {
318        MoreView()
319            .navigationDestination(for: MoreRoute.self) { route in
320                switch route {
321                case .lookup:
322                    LookupView()
323                case .lists:
324                    MailingListListView()
325                case .pastes:
326                    PasteListView()
327                case .profile:
328                    ProfileView()
329                case .systemStatus:
330                    SystemStatusView()
331                case .settings:
332                    SettingsView()
333                case .mailingList(let mailingList):
334                    MailingListDetailView(mailingList: mailingList)
335                case .thread(let thread):
336                    ThreadDetailView(
337                        thread: thread,
338                        onViewed: {
339                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
340                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
341                        },
342                        onMarkRead: {
343                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
344                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
345                        },
346                        onMarkUnread: {
347                            InboxReadStateStore.markUnread(for: thread.id)
348                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
349                        }
350                    )
351                case .manPageBrowser:
352                    ManPageBrowserView()
353                case .manPage(let url):
354                    ManPageDetailView(url: url)
355                }
356            }
357    }
358}
359
360// MARK: - Ticket Deep Link Navigation Target
361
362/// Hashable wrapper to push a ticket detail view from a deep link.
363struct TicketDeepLinkTarget: Hashable {
364    let ownerUsername: String
365    let trackerName: String
366    let trackerId: Int
367    let trackerRid: String
368    let ticketId: Int
369}
370
371// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
372
373/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
374private struct TabKeyboardShortcuts: ViewModifier {
375    @Binding var selectedTab: AppState.Tab
376
377    private static let tabMap: [String: AppState.Tab] = [
378        "1": .home,
379        "2": .repositories,
380        "3": .tickets,
381        "4": .builds,
382        "5": .more,
383    ]
384
385    func body(content: Content) -> some View {
386        content
387            .onKeyPress(characters: .decimalDigits, phases: .down) { press in
388                guard press.modifiers == .command else { return .ignored }
389                let key = String(press.characters)
390                if let tab = Self.tabMap[key] {
391                    selectedTab = tab
392                    return .handled
393                }
394                return .ignored
395            }
396    }
397}
398
399// MARK: - iPad Sidebar Adaptable
400
401/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
402/// becomes a full sidebar on iPad, while falling back to the standard tab
403/// bar on earlier releases.
404private struct SidebarAdaptableTabStyle: ViewModifier {
405    func body(content: Content) -> some View {
406        if #available(iOS 18.0, *) {
407            content.tabViewStyle(.sidebarAdaptable)
408        } else {
409            content
410        }
411    }
412}