gitbay/ContentView.swift

v1.4.0
gitbay-ios/gitbay/ContentView.swift history · blame · raw

292 lines · 11809 bytes

  1import SwiftUI
  2
  3struct ContentView: View {
  4
  5    @Environment(SessionStore.self) private var session
  6    @Environment(PushRouter.self) private var router
  7
  8    enum Tabs { case dashboard, feed, repositories, explore, profile }
  9
 10    @State private var selectedTab = Tabs.dashboard
 11    @State private var dashboardPath = NavigationPath()
 12
 13    /// Which account `dashboardPath`'s routes were resolved against.
 14    ///
 15    /// `.id(account.id)` sits on the `TabView`, not on `ContentView`, so
 16    /// this view's own state outlives an account switch while the stack
 17    /// it feeds is rebuilt. A path carried across the switch would be
 18    /// re-pushed against the new account's client — someone else's
 19    /// issue number, usually a 404. Empty while signed out.
 20    @State private var pathAccount = ""
 21
 22    var body: some View {
 23        if let client = session.client, let account = session.current {
 24            TabView(selection: $selectedTab) {
 25                Tab("Dashboard", systemImage: "square.grid.2x2", value: Tabs.dashboard) {
 26                    NavigationStack(path: $dashboardPath) {
 27                        DashboardView(client: client)
 28                            .navigationDestinations(client: client)
 29                    }
 30                }
 31                Tab("Feed", systemImage: "bolt", value: Tabs.feed) {
 32                    NavigationStack {
 33                        FeedView(client: client)
 34                            .navigationDestinations(client: client)
 35                    }
 36                }
 37                Tab("Repositories", systemImage: "books.vertical", value: Tabs.repositories) {
 38                    NavigationStack {
 39                        RepoListView(client: client)
 40                            .navigationDestinations(client: client)
 41                    }
 42                }
 43                Tab("Explore", systemImage: "safari", value: Tabs.explore) {
 44                    NavigationStack {
 45                        ExploreView(client: client)
 46                            .navigationDestinations(client: client)
 47                    }
 48                }
 49                Tab("My Profile", systemImage: "person.crop.circle", value: Tabs.profile) {
 50                    NavigationStack {
 51                        ProfileView(client: client, name: account.username,
 52                                    title: "My Profile")
 53                            .toolbar { AccountMenu() }
 54                            .navigationDestinations(client: client)
 55                    }
 56                }
 57            }
 58            .id(account.id) // a fresh tab bar and stacks per account
 59            .task(id: DrainKey(account: account.id, target: router.pending)) {
 60                await routePendingNotification(account)
 61            }
 62        } else {
 63            NavigationStack {
 64                SignInView()
 65            }
 66            // Signing out leaves the stale path behind in this view's
 67            // state; disowning it makes the next sign-in clear it.
 68            .task { pathAccount = "" }
 69        }
 70    }
 71
 72    /// What makes the drain run again.
 73    ///
 74    /// The account is in here because the cross-account pass leaves
 75    /// `pending` set on purpose, so the target alone does not change
 76    /// across the switch it performs. Keying on both means the drain
 77    /// does not depend on whether `.id()` propagates its identity
 78    /// outward through the `.task` modifier attached after it.
 79    nonisolated private struct DrainKey: Equatable, Sendable {
 80        let account: String
 81        let target: PushTarget?
 82    }
 83
 84    /// Resets the navigation state an account switch invalidated, then
 85    /// routes a tapped notification, switching accounts first when it
 86    /// belongs to another one.
 87    ///
 88    /// A switch changes `account.id`, which rebuilds the tab bar. That
 89    /// is why the target is left pending across it: this runs again
 90    /// against the new account and finds it already active, so the
 91    /// second pass does the navigating. The drain is the mechanism, not
 92    /// a workaround for it — and it is also where the path is reset,
 93    /// because every switch comes through here whether a notification
 94    /// is pending or not.
 95    private func routePendingNotification(_ account: Account) async {
 96        if pathAccount != account.id {
 97            dashboardPath = NavigationPath()
 98            selectedTab = .dashboard
 99            pathAccount = account.id
100        }
101        guard let target = router.pending else { return }
102        if account.id != target.accountID {
103            guard let other = session.accounts.first(where: { $0.id == target.accountID }) else {
104                router.pending = nil   // signed out of it since the tap
105                return
106            }
107            // A token gone from the Keychain drops the account and
108            // leaves `current` alone, so nothing would rebuild and
109            // drain; the target would outlive every later tap, which
110            // cannot change the key either.
111            guard session.activate(other) else {
112                router.pending = nil
113                return
114            }
115            return                      // the rebuilt tree drains it
116        }
117        selectedTab = .dashboard
118        switch target.destination {
119        case .issue(let repo, let number):
120            dashboardPath.append(IssueRoute.issue(repo: repo, number: number))
121        case .mr(let repo, let number):
122            dashboardPath.append(MRRoute.mr(repo: repo, number: number))
123        case .build(let repo, let number):
124            dashboardPath.append(BuildRoute.detail(repo: repo, number: number))
125        }
126        router.pending = nil
127    }
128
129}
130
131/// Every value-routed screen, attachable to any stack root.
132private struct RouteDestinations: ViewModifier {
133
134    let client: GitbayClient
135
136    func body(content: Content) -> some View {
137        content
138            .navigationDestination(for: RepoRoute.self) { route in
139                destination(route, client: client)
140            }
141            .navigationDestination(for: MRRoute.self) { route in
142                destination(route, client: client)
143            }
144            .navigationDestination(for: IssueRoute.self) { route in
145                destination(route, client: client)
146            }
147            .navigationDestination(for: BuildRoute.self) { route in
148                destination(route, client: client)
149            }
150            .navigationDestination(for: ReleaseRoute.self) { route in
151                destination(route, client: client)
152            }
153            .navigationDestination(for: OrgRoute.self) { route in
154                destination(route, client: client)
155            }
156            .navigationDestination(for: SnippetRoute.self) { route in
157                destination(route, client: client)
158            }
159    }
160
161    @ViewBuilder
162    private func destination(_ route: RepoRoute, client: GitbayClient) -> some View {
163        switch route {
164        case .repo(let path):
165            RepoView(client: client, path: path)
166        case .tree(let repo, let directory, let ref):
167            TreeView(client: client, repo: repo, directory: directory, ref: ref)
168        case .file(let repo, let path, let ref):
169            FileView(client: client, repo: repo, path: path, ref: ref)
170        case .log(let repo, let ref, let path):
171            LogView(client: client, repo: repo, ref: ref, path: path)
172        case .settings(let repo):
173            RepoSettingsView(client: client, repo: repo)
174        case .grep(let repo):
175            GrepView(client: client, repo: repo)
176        case .blame(let repo, let path, let ref):
177            BlameView(client: client, repo: repo, path: path, ref: ref)
178        case .refs(let repo):
179            RefsView(client: client, repo: repo)
180        case .compare(let repo, let defaultBranch):
181            CompareView(client: client, repo: repo, defaultBranch: defaultBranch)
182        case .milestones(let repo):
183            MilestoneListView(client: client, repo: repo)
184        case .labels(let repo):
185            LabelListView(client: client, repo: repo)
186        case .bookmarks:
187            BookmarkListView(client: client)
188        case .webhooks(let repo):
189            WebhookListView(client: client, repo: repo)
190        case .webhookDeliveries(let repo):
191            WebhookDeliveriesView(client: client, repo: repo)
192        case .wiki(let repo):
193            WikiView(client: client, repo: repo)
194        case .wikiPage(let repo, let page):
195            WikiPageView(client: client, repo: repo, page: page)
196        case .commit(let repo, let sha):
197            CommitView(client: client, repo: repo, sha: sha)
198        case .profile(let name):
199            ProfileView(client: client, name: name)
200        case .account:
201            AccountView(client: client)
202        case .addAccount:
203            SignInView()
204        case .notifications:
205            NotificationsView(client: client)
206        }
207    }
208
209    @ViewBuilder
210    private func destination(_ route: MRRoute, client: GitbayClient) -> some View {
211        switch route {
212        case .list(let repo):
213            MRListView(client: client, repo: repo)
214        case .mr(let repo, let number):
215            MRView(client: client, repo: repo, number: number)
216        case .diff(let repo, let number):
217            DiffView(client: client, repo: repo, number: number)
218        case .rangeDiff(let repo, let number, let from, let to):
219            RangeDiffView(client: client, repo: repo, number: number, from: from, to: to)
220        }
221    }
222
223    @ViewBuilder
224    private func destination(_ route: IssueRoute, client: GitbayClient) -> some View {
225        switch route {
226        case .list(let repo):
227            IssueListView(client: client, repo: repo)
228        case .issue(let repo, let number):
229            IssueView(client: client, repo: repo, number: number)
230        }
231    }
232
233    @ViewBuilder
234    private func destination(_ route: OrgRoute, client: GitbayClient) -> some View {
235        switch route {
236        case .org(let name):
237            OrgView(client: client, org: name)
238        case .team(let org, let team):
239            TeamView(client: client, org: org, team: team)
240        case .labels(let org):
241            LabelListView(client: client, owner: .org(org))
242        case .milestones(let org):
243            MilestoneListView(client: client, owner: .org(org))
244        }
245    }
246
247    @ViewBuilder
248    private func destination(_ route: SnippetRoute, client: GitbayClient) -> some View {
249        switch route {
250        case .list(let owner):
251            SnippetListView(client: client, owner: owner)
252        case .snippet(let id):
253            SnippetView(client: client, id: id)
254        }
255    }
256
257    @ViewBuilder
258    private func destination(_ route: ReleaseRoute, client: GitbayClient) -> some View {
259        switch route {
260        case .list(let repo):
261            ReleaseListView(client: client, repo: repo)
262        case .release(let repo, let tag):
263            ReleaseView(client: client, repo: repo, tag: tag)
264        }
265    }
266
267    @ViewBuilder
268    private func destination(_ route: BuildRoute, client: GitbayClient) -> some View {
269        switch route {
270        case .list(let repo):
271            BuildListView(client: client, repo: repo)
272        case .detail(let repo, let number):
273            BuildDetailView(client: client, repo: repo, number: number)
274        }
275    }
276}
277
278extension View {
279    func navigationDestinations(client: GitbayClient) -> some View {
280        modifier(RouteDestinations(client: client))
281    }
282}
283
284#Preview {
285    // Every environment value the signed-in branch reads, so the
286    // preview does not depend on the machine's Keychain being empty.
287    let session = SessionStore()
288    ContentView()
289        .environment(session)
290        .environment(PushRouter())
291        .environment(PushRegistrar(session: session))
292}