gitbay/Views/Dashboard/DashboardView.swift

v1.14.1
gitbay-ios/gitbay/Views/Dashboard/DashboardView.swift history · blame · raw

245 lines · 9070 bytes

  1import SwiftUI
  2
  3/// The native representation of `gitbay dashboard`, ordered and phrased
  4/// like the logged-in web dashboard.
  5struct DashboardView: View {
  6
  7    @Environment(SessionStore.self) private var session
  8    @State private var model: DashboardViewModel
  9    @State private var createModel: RepoCreateViewModel
 10    @State private var composing = false
 11    /// The unread count, for the Dashboard tab's badge.
 12    @Binding var unread: Int
 13
 14    init(client: GitbayClient, unread: Binding<Int>) {
 15        _unread = unread
 16        _model = State(initialValue: DashboardViewModel(client: client))
 17        _createModel = State(initialValue: RepoCreateViewModel(client: client))
 18    }
 19
 20    var body: some View {
 21        RoutingList {
 22            if let data = model.state.value {
 23                itemSection(
 24                    "Waiting on your review", items: data.reviewQueue,
 25                    empty: "Nothing waiting on you", marker: "!"
 26                ) { MRRoute.mr(repo: $0.repo, number: $0.number) }
 27
 28                itemSection(
 29                    "Assigned to you", items: data.assignedIssues,
 30                    empty: "Nothing assigned to you", marker: "#"
 31                ) { IssueRoute.issue(repo: $0.repo, number: $0.number) }
 32
 33                itemSection(
 34                    "Open merge requests", items: data.openMRs,
 35                    empty: "No open merge requests", marker: "!"
 36                ) { MRRoute.mr(repo: $0.repo, number: $0.number) }
 37
 38                itemSection(
 39                    "Open issues", items: data.openIssues,
 40                    empty: "No open issues", marker: "#"
 41                ) { IssueRoute.issue(repo: $0.repo, number: $0.number) }
 42
 43                ForEach(data.queries) { query in
 44                    querySection(query)
 45                }
 46
 47                pinnedSection(data.pinned)
 48                activitySection(data.recentActivity)
 49            }
 50        }
 51        .overlay { LoadStateOverlay(state: model.state) }
 52        .navigationTitle("Dashboard")
 53        .toolbar {
 54            ToolbarItem(placement: .topBarLeading) {
 55                RouteLink(value: RepoRoute.savedQueries) {
 56                    Image(systemName: "line.3.horizontal.decrease.circle")
 57                }
 58                .accessibilityLabel("Saved Queries")
 59                .accessibilityIdentifier("dashboard-queries-button")
 60            }
 61            ToolbarItem(placement: .topBarTrailing) {
 62                RouteLink(value: RepoRoute.notifications) {
 63                    HStack(spacing: 2) {
 64                        Image(systemName: "bell")
 65                        if let unread = model.state.value?.unread, unread > 0 {
 66                            Text(String(unread))
 67                                .font(.gbSans(.caption2))
 68                        }
 69                    }
 70                }
 71                .accessibilityIdentifier("dashboard-notifications-button")
 72                .accessibilityLabel(bellLabel)
 73            }
 74            ToolbarItem(placement: .topBarTrailing) {
 75                Button {
 76                    composing = true
 77                } label: {
 78                    Image(systemName: "plus")
 79                }
 80                .accessibilityLabel("New Repository")
 81                .accessibilityIdentifier("dashboard-create-button")
 82            }
 83        }
 84        .sheet(isPresented: $composing) {
 85            RepoCreateSheet(
 86                model: createModel,
 87                ownerPrefix: session.current?.username ?? ""
 88            ) {
 89                composing = false
 90                Task { await model.load() }
 91            }
 92        }
 93        .task { await model.load() }
 94        .refreshable { await model.load() }
 95        .onReceive(NotificationCenter.default.publisher(for: .gitbaySavedQueriesChanged)) { _ in
 96            Task { await model.load() }
 97        }
 98        // A pinned repository deleted or moved elsewhere.
 99        .onReceive(NotificationCenter.default.publisher(for: .gitbayRepositoryChanged)) { _ in
100            Task { await model.load() }
101        }
102        .onChange(of: model.state.value?.unread) { _, count in
103            if let count { unread = count }
104        }
105    }
106
107    private var bellLabel: String {
108        guard let unread = model.state.value?.unread, unread > 0 else { return "Notifications" }
109        return "Notifications, \(unread) unread"
110    }
111
112    private func itemSection(
113        _ title: String,
114        items: [DashboardItem],
115        empty: String,
116        marker: String,
117        route: @escaping (DashboardItem) -> some Hashable
118    ) -> some View {
119        Section {
120            if items.isEmpty {
121                Text(empty)
122                    .font(.gbSans(.subheadline))
123                    .foregroundStyle(.secondary)
124            } else {
125                ForEach(items) { item in
126                    NavigationLink(value: route(item)) {
127                        dashboardItem(item, marker: marker)
128                    }
129                }
130            }
131        } header: {
132            sectionHeader(title, count: items.count)
133        }
134    }
135
136    private func dashboardItem(_ item: DashboardItem, marker: String) -> some View {
137        VStack(alignment: .leading, spacing: 3) {
138            Text(item.title)
139                .font(.gbSans(.subheadline).weight(.semibold))
140                .lineLimit(2)
141            HStack(spacing: 4) {
142                Text(item.repo + marker + String(item.number))
143                    .font(.gbSans(.caption))
144                Text("·")
145                Text(item.author)
146                    .foregroundStyle(Color.gbAccent)
147                Text("·")
148                Text(item.updatedAt, format: .relative(presentation: .named))
149                if item.state == "source_gone" {
150                    Text("·")
151                    GBChip(stateLabel(item.state), .secondary)
152                }
153            }
154            .font(.gbSans(.caption))
155            .foregroundStyle(.secondary)
156            .lineLimit(1)
157        }
158        .padding(.vertical, 2)
159    }
160
161    /// A pinned saved query: its first rows, and the rest behind its name,
162    /// as the web dashboard shows it.
163    private func querySection(_ query: DashboardQuery) -> some View {
164        Section {
165            if let error = query.error {
166                Text(error)
167                    .font(.gbSans(.subheadline))
168                    .foregroundStyle(.secondary)
169            } else if query.items.isEmpty {
170                Text("Nothing matches")
171                    .font(.gbSans(.subheadline))
172                    .foregroundStyle(.secondary)
173            } else {
174                ForEach(query.items) { item in
175                    NavigationLink(value: item.route) {
176                        QueryItemRow(item: item)
177                    }
178                }
179                if query.count > query.items.count {
180                    NavigationLink(value: RepoRoute.queryResults(name: query.name)) {
181                        Text("All \(query.count)")
182                            .font(.gbSans(.subheadline))
183                            .foregroundStyle(Color.gbAccent)
184                    }
185                }
186            }
187        } header: {
188            VStack(alignment: .leading, spacing: 2) {
189                sectionHeader(query.name, count: query.count)
190                Text(query.query)
191                    .font(.gbMono(.caption2))
192                    .textCase(nil)
193            }
194        }
195    }
196
197    private func pinnedSection(_ pinned: [RepoSummary]) -> some View {
198        Section {
199            if pinned.isEmpty {
200                Text("Pin a repository to keep it here")
201                    .font(.gbSans(.subheadline))
202                    .foregroundStyle(.secondary)
203            } else {
204                ForEach(pinned) { repo in
205                    NavigationLink(value: RepoRoute.repo(repo.path)) {
206                        HStack(spacing: 6) {
207                            Text(repo.path)
208                                .font(.gbSans(.subheadline).weight(.medium))
209                                .foregroundStyle(Color.gbAccent)
210                            if repo.isPrivate {
211                                GBChip("Private", .secondary)
212                            }
213                        }
214                    }
215                }
216            }
217        } header: {
218            Text("Pinned")
219        }
220    }
221
222    private func activitySection(_ events: [FeedEvent]) -> some View {
223        Section {
224            if events.isEmpty {
225                Text("No activity yet")
226                    .font(.gbSans(.subheadline))
227                    .foregroundStyle(.secondary)
228            } else {
229                ForEach(events) { event in
230                    FeedLink(event: event)
231                }
232            }
233        } header: {
234            Text("Recent activity")
235        }
236    }
237
238    private func sectionHeader(_ title: String, count: Int) -> some View {
239        HStack(alignment: .firstTextBaseline, spacing: 4) {
240            Text(title)
241            Text(String(count))
242                .foregroundStyle(.secondary)
243        }
244    }
245}