gitbay/ContentView.swift

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

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