gitbay/Views/Dashboard/DashboardView.swift
245 lines · 9070 bytes
7 symbols in this file
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}