krz/hutch

an ios client for sourcehut

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

v3.1.7: 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    @Environment(\.isAMOLEDTheme) private var isAMOLED
  8    @State private var homePath = NavigationPath()
  9    @State private var morePath = NavigationPath()
 10    @State private var repoPath = NavigationPath()
 11    @State private var buildsPath = NavigationPath()
 12    @State private var ticketsPath = NavigationPath()
 13    @State private var isResolvingDeepLink = false
 14    @State private var hasValidatedLaunch = false
 15
 16    var body: some View {
 17        @Bindable var appState = appState
 18
 19        Group {
 20            switch appState.authPhase {
 21            case .launching:
 22                ProgressView(appState.authStatusMessage)
 23                    .task {
 24                        guard !hasValidatedLaunch else { return }
 25                        hasValidatedLaunch = true
 26                        await appState.validateOnLaunch()
 27                    }
 28
 29            case .unauthenticated:
 30                // Full-screen token entry that cannot be dismissed.
 31                TokenEntryView()
 32
 33            case .authenticated:
 34                tabContent
 35            }
 36        }
 37        .onChange(of: appState.pendingDeepLink) { _, newValue in
 38            consumePendingDeepLinkIfPossible(newValue)
 39        }
 40        .onChange(of: appState.authPhase) { _, newPhase in
 41            handleAuthPhaseChange(newPhase)
 42        }
 43        .onChange(of: appState.pendingTabNavigation) { _, newValue in
 44            consumePendingTabNavigationIfPossible(newValue)
 45        }
 46        .alert(
 47            "Couldn't Open Link",
 48            isPresented: Binding(
 49                get: { appState.deepLinkError != nil },
 50                set: { isPresented in
 51                    if !isPresented {
 52                        appState.deepLinkError = nil
 53                    }
 54                }
 55            )
 56        ) {
 57            Button("OK") {
 58                appState.deepLinkError = nil
 59            }
 60        } message: {
 61            Text(appState.deepLinkError ?? "")
 62        }
 63    }
 64
 65    // MARK: - Tab View
 66
 67    private var tabContent: some View {
 68        @Bindable var appState = appState
 69
 70        return TabView(selection: $appState.selectedTab) {
 71            NavigationStack(path: $homePath) {
 72                HomeView()
 73                    .navigationDestination(for: HomeRoute.self) { route in
 74                        switch route {
 75                        case .work:
 76                            WorkView()
 77                        }
 78                    }
 79            }
 80            .tag(AppState.Tab.home)
 81            .tabItem {
 82                Label("Home", systemImage: "house")
 83            }
 84
 85            NavigationStack(path: $repoPath) {
 86                RepositoryListView()
 87            }
 88            .tag(AppState.Tab.repositories)
 89            .tabItem {
 90                Label("Repositories", systemImage: "book.closed")
 91            }
 92
 93            NavigationStack(path: $ticketsPath) {
 94                TrackerListView()
 95                    // Deep link destination for jumping straight to a ticket.
 96                    .navigationDestination(for: TicketDeepLinkTarget.self) { target in
 97                        TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
 98                    }
 99            }
100            .tag(AppState.Tab.tickets)
101            .tabItem {
102                Label("Trackers", systemImage: "checklist")
103            }
104
105            NavigationStack(path: $buildsPath) {
106                BuildListView()
107                    // Int destination used by deep links (hutch://builds/<id>).
108                    // JobSummary destination is registered inside BuildListView.
109                    .navigationDestination(for: Int.self) { jobId in
110                        BuildDetailView(jobId: jobId)
111                    }
112            }
113            .tag(AppState.Tab.builds)
114            .tabItem {
115                Label("Builds", systemImage: "hammer")
116            }
117
118            NavigationStack(path: $morePath) {
119                MoreNavigationRoot()
120            }
121            .tag(AppState.Tab.more)
122            .tabItem {
123                Label("More", systemImage: "ellipsis.circle")
124            }
125        }
126        .id(appState.sessionIdentity)
127        .defaultAppStorage(appState.accountDefaults)
128        .modifier(SidebarAdaptableTabStyle())
129        .modifier(AMOLEDToolbarStyle(isAMOLED: isAMOLED))
130        .modifier(TabKeyboardShortcuts(selectedTab: Binding(
131            get: { appState.selectedTab },
132            set: { appState.selectedTab = $0 }
133        )))
134        .safeAreaInset(edge: .bottom) {
135            if let message = appState.copyConfirmationMessage {
136                CopyConfirmationBadge(message: message)
137                    .padding(.bottom, 4)
138                    .transition(.move(edge: .bottom).combined(with: .opacity))
139            }
140        }
141        .overlay {
142            if isResolvingDeepLink {
143                ZStack {
144                    Color.black.opacity(0.3)
145                        .ignoresSafeArea()
146                    ProgressView("Opening link…")
147                        .padding()
148                        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
149                }
150            }
151        }
152    }
153
154    // MARK: - Deep Link Handling
155
156    private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
157        switch newPhase {
158        case .launching:
159            break
160        case .unauthenticated:
161            homePath = NavigationPath()
162            morePath = NavigationPath()
163            repoPath = NavigationPath()
164            buildsPath = NavigationPath()
165            ticketsPath = NavigationPath()
166            appState.selectedTab = .home
167            isResolvingDeepLink = false
168        case .authenticated:
169            consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
170        }
171    }
172
173    private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
174        guard appState.isAuthenticated, let link else { return }
175        handleDeepLink(link)
176        appState.pendingDeepLink = nil
177    }
178
179    private func consumePendingTabNavigationIfPossible(_ target: AppState.TabNavigationTarget?) {
180        guard appState.isAuthenticated, let target else { return }
181        handleTabNavigation(target)
182        appState.pendingTabNavigation = nil
183    }
184
185    private func handleDeepLink(_ link: DeepLink) {
186        guard appState.isAuthenticated else { return }
187
188        switch link {
189        case .home:
190            homePath = NavigationPath()
191            appState.selectedTab = .home
192
193        case .repository(let owner, let repo):
194            resolveRepositoryLink(owner: owner, repo: repo)
195
196        case .build(let jobId):
197            buildsPath = NavigationPath()
198            appState.selectedTab = .builds
199            Task {
200                await settleNavigationTransition()
201                buildsPath.append(jobId)
202            }
203
204        case .ticket(let owner, let tracker, let ticketId):
205            resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
206
207        case .work:
208            homePath = NavigationPath()
209            appState.selectedTab = .home
210            Task {
211                await settleNavigationTransition()
212                homePath.append(HomeRoute.work)
213            }
214
215        case .buildsTab:
216            buildsPath = NavigationPath()
217            appState.selectedTab = .builds
218
219        case .repositoriesTab:
220            repoPath = NavigationPath()
221            appState.selectedTab = .repositories
222
223        case .trackersTab:
224            ticketsPath = NavigationPath()
225            appState.selectedTab = .tickets
226
227        case .systemStatus:
228            appState.navigateToSystemStatus()
229
230        case .lookup:
231            morePath = NavigationPath()
232            appState.selectedTab = .more
233            Task {
234                await settleNavigationTransition()
235                morePath.append(MoreRoute.lookup)
236            }
237        }
238    }
239
240    private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
241        switch target {
242        case .repository(let repository):
243            repoPath = NavigationPath()
244            appState.selectedTab = .repositories
245            Task {
246                await settleNavigationTransition()
247                repoPath.append(repository)
248            }
249
250        case .tracker(let tracker):
251            ticketsPath = NavigationPath()
252            appState.selectedTab = .tickets
253            Task {
254                await settleNavigationTransition()
255                ticketsPath.append(tracker)
256            }
257
258        case .mailingList(let mailingList):
259            morePath = NavigationPath()
260            appState.selectedTab = .more
261            Task {
262                await settleNavigationTransition()
263                morePath.append(MoreRoute.lists)
264                morePath.append(MoreRoute.mailingList(mailingList))
265            }
266        case .systemStatus:
267            morePath = NavigationPath()
268            appState.selectedTab = .more
269            Task {
270                await settleNavigationTransition()
271                morePath.append(MoreRoute.systemStatus)
272            }
273        case .builds:
274            buildsPath = NavigationPath()
275            appState.selectedTab = .builds
276        }
277    }
278
279    private func resolveRepositoryLink(owner: String, repo: String) {
280        isResolvingDeepLink = true
281        Task {
282            defer { isResolvingDeepLink = false }
283            do {
284                let summary = try await appState.resolveRepository(owner: owner, name: repo)
285                repoPath = NavigationPath()
286                appState.selectedTab = .repositories
287                await settleNavigationTransition()
288                repoPath.append(summary)
289            } catch {
290                appState.presentRepositoryDeepLinkError()
291            }
292        }
293    }
294
295    private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
296        isResolvingDeepLink = true
297        Task {
298            defer { isResolvingDeepLink = false }
299            do {
300                let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
301                ticketsPath = NavigationPath()
302                appState.selectedTab = .tickets
303                await settleNavigationTransition()
304                ticketsPath.append(trackerSummary)
305                ticketsPath.append(TicketDeepLinkTarget(
306                    ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
307                    trackerName: trackerSummary.name,
308                    trackerId: trackerSummary.id,
309                    trackerRid: trackerSummary.rid,
310                    ticketId: ticketId
311                ))
312            } catch {
313                appState.presentTicketDeepLinkError()
314            }
315        }
316    }
317
318    @MainActor
319    private func settleNavigationTransition() async {
320        await Task.yield()
321        await Task.yield()
322    }
323}
324
325enum MoreDestination: Hashable {
326    case lists
327    case pastes
328    case settings
329}
330
331enum MoreRoute: Hashable {
332    case lookup
333    case projects
334    case lists
335    case pastes
336    case profile
337    case systemStatus
338    case settings
339    case about
340    case mailingList(InboxMailingListReference)
341    case thread(InboxThreadSummary)
342    case manPageBrowser
343    case manPage(URL)
344}
345
346private struct MoreNavigationRoot: View {
347    @Environment(AppState.self) private var appState
348
349    var body: some View {
350        MoreView()
351            .navigationDestination(for: MoreRoute.self) { route in
352                switch route {
353                case .lookup:
354                    LookupView()
355                case .projects:
356                    ProjectsListView()
357                case .lists:
358                    MailingListListView()
359                case .pastes:
360                    PasteListView()
361                case .profile:
362                    ProfileView()
363                case .systemStatus:
364                    SystemStatusView()
365                case .settings:
366                    SettingsView()
367                case .about:
368                    AboutView()
369                case .mailingList(let mailingList):
370                    MailingListDetailView(mailingList: mailingList)
371                case .thread(let thread):
372                    ThreadDetailView(
373                        thread: thread,
374                        onViewed: {
375                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
376                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
377                        },
378                        onMarkRead: {
379                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
380                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
381                        },
382                        onMarkUnread: {
383                            InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
384                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
385                        }
386                    )
387                case .manPageBrowser:
388                    ManPageBrowserView()
389                case .manPage(let url):
390                    ManPageDetailView(url: url)
391                }
392            }
393    }
394}
395
396// MARK: - Ticket Deep Link Navigation Target
397
398/// Hashable wrapper to push a ticket detail view from a deep link.
399struct TicketDeepLinkTarget: Hashable {
400    let ownerUsername: String
401    let trackerName: String
402    let trackerId: Int
403    let trackerRid: String
404    let ticketId: Int
405}
406
407// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
408
409/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
410private struct TabKeyboardShortcuts: ViewModifier {
411    @Binding var selectedTab: AppState.Tab
412
413    private static let tabMap: [String: AppState.Tab] = [
414        "1": .home,
415        "2": .repositories,
416        "3": .tickets,
417        "4": .builds,
418        "5": .more,
419    ]
420
421    func body(content: Content) -> some View {
422        content
423            .onKeyPress(characters: .decimalDigits, phases: .down) { press in
424                guard press.modifiers == .command else { return .ignored }
425                let key = String(press.characters)
426                if let tab = Self.tabMap[key] {
427                    selectedTab = tab
428                    return .handled
429                }
430                return .ignored
431            }
432    }
433}
434
435// MARK: - AMOLED Toolbar Styling
436
437/// Applies true-black backgrounds to the tab bar and navigation bar when the AMOLED theme is active.
438private struct AMOLEDToolbarStyle: ViewModifier {
439    let isAMOLED: Bool
440
441    func body(content: Content) -> some View {
442        if isAMOLED {
443            content
444                .toolbarBackground(Color.black, for: .tabBar)
445                .toolbarBackground(.visible, for: .tabBar)
446                .toolbarBackground(Color.black, for: .navigationBar)
447                .toolbarBackground(.visible, for: .navigationBar)
448        } else {
449            content
450        }
451    }
452}
453
454// MARK: - iPad Sidebar Adaptable
455
456/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
457/// becomes a full sidebar on iPad, while falling back to the standard tab
458/// bar on earlier releases.
459private struct SidebarAdaptableTabStyle: ViewModifier {
460    func body(content: Content) -> some View {
461        if #available(iOS 18.0, *) {
462            content.tabViewStyle(.sidebarAdaptable)
463        } else {
464            content
465        }
466    }
467}