krz/hutch

an ios client for sourcehut

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

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