krz/hutch

an ios client for sourcehut

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

v3.1.3: 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 mailingList(InboxMailingListReference)
340    case thread(InboxThreadSummary)
341    case manPageBrowser
342    case manPage(URL)
343}
344
345private struct MoreNavigationRoot: View {
346    @Environment(AppState.self) private var appState
347
348    var body: some View {
349        MoreView()
350            .navigationDestination(for: MoreRoute.self) { route in
351                switch route {
352                case .lookup:
353                    LookupView()
354                case .projects:
355                    ProjectsListView()
356                case .lists:
357                    MailingListListView()
358                case .pastes:
359                    PasteListView()
360                case .profile:
361                    ProfileView()
362                case .systemStatus:
363                    SystemStatusView()
364                case .settings:
365                    SettingsView()
366                case .mailingList(let mailingList):
367                    MailingListDetailView(mailingList: mailingList)
368                case .thread(let thread):
369                    ThreadDetailView(
370                        thread: thread,
371                        onViewed: {
372                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
373                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
374                        },
375                        onMarkRead: {
376                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: appState.accountDefaults)
377                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: appState.activeAccountID)
378                        },
379                        onMarkUnread: {
380                            InboxReadStateStore.markUnread(for: thread.id, defaults: appState.accountDefaults)
381                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: appState.activeAccountID)
382                        }
383                    )
384                case .manPageBrowser:
385                    ManPageBrowserView()
386                case .manPage(let url):
387                    ManPageDetailView(url: url)
388                }
389            }
390    }
391}
392
393// MARK: - Ticket Deep Link Navigation Target
394
395/// Hashable wrapper to push a ticket detail view from a deep link.
396struct TicketDeepLinkTarget: Hashable {
397    let ownerUsername: String
398    let trackerName: String
399    let trackerId: Int
400    let trackerRid: String
401    let ticketId: Int
402}
403
404// MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard
405
406/// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad.
407private struct TabKeyboardShortcuts: ViewModifier {
408    @Binding var selectedTab: AppState.Tab
409
410    private static let tabMap: [String: AppState.Tab] = [
411        "1": .home,
412        "2": .repositories,
413        "3": .tickets,
414        "4": .builds,
415        "5": .more,
416    ]
417
418    func body(content: Content) -> some View {
419        content
420            .onKeyPress(characters: .decimalDigits, phases: .down) { press in
421                guard press.modifiers == .command else { return .ignored }
422                let key = String(press.characters)
423                if let tab = Self.tabMap[key] {
424                    selectedTab = tab
425                    return .handled
426                }
427                return .ignored
428            }
429    }
430}
431
432// MARK: - AMOLED Toolbar Styling
433
434/// Applies true-black backgrounds to the tab bar and navigation bar when the AMOLED theme is active.
435private struct AMOLEDToolbarStyle: ViewModifier {
436    let isAMOLED: Bool
437
438    func body(content: Content) -> some View {
439        if isAMOLED {
440            content
441                .toolbarBackground(Color.black, for: .tabBar)
442                .toolbarBackground(.visible, for: .tabBar)
443                .toolbarBackground(Color.black, for: .navigationBar)
444                .toolbarBackground(.visible, for: .navigationBar)
445        } else {
446            content
447        }
448    }
449}
450
451// MARK: - iPad Sidebar Adaptable
452
453/// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar
454/// becomes a full sidebar on iPad, while falling back to the standard tab
455/// bar on earlier releases.
456private struct SidebarAdaptableTabStyle: ViewModifier {
457    func body(content: Content) -> some View {
458        if #available(iOS 18.0, *) {
459            content.tabViewStyle(.sidebarAdaptable)
460        } else {
461            content
462        }
463    }
464}