gitbay/Views/Dashboard/DashboardView.swift
184 lines · 6546 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
12 init(client: GitbayClient) {
13 _model = State(initialValue: DashboardViewModel(client: client))
14 _createModel = State(initialValue: RepoCreateViewModel(client: client))
15 }
16
17 var body: some View {
18 List {
19 if let data = model.state.value {
20 itemSection(
21 "Waiting on your review", items: data.reviewQueue,
22 empty: "Nothing waiting on you", marker: "!"
23 ) { MRRoute.mr(repo: $0.repo, number: $0.number) }
24
25 itemSection(
26 "Assigned to you", items: data.assignedIssues,
27 empty: "Nothing assigned to you", marker: "#"
28 ) { IssueRoute.issue(repo: $0.repo, number: $0.number) }
29
30 itemSection(
31 "Open merge requests", items: data.openMRs,
32 empty: "No open merge requests", marker: "!"
33 ) { MRRoute.mr(repo: $0.repo, number: $0.number) }
34
35 itemSection(
36 "Open issues", items: data.openIssues,
37 empty: "No open issues", marker: "#"
38 ) { IssueRoute.issue(repo: $0.repo, number: $0.number) }
39
40 pinnedSection(data.pinned)
41 activitySection(data.recentActivity)
42 }
43 }
44 .overlay { LoadStateOverlay(state: model.state) }
45 .navigationTitle("Dashboard")
46 .toolbar {
47 ToolbarItem(placement: .topBarTrailing) {
48 NavigationLink(value: RepoRoute.notifications) {
49 HStack(spacing: 2) {
50 Image(systemName: "bell")
51 if let unread = model.state.value?.unread, unread > 0 {
52 Text(String(unread))
53 .font(.gbSans(.caption2))
54 }
55 }
56 }
57 .accessibilityIdentifier("dashboard-notifications-button")
58 .accessibilityLabel(bellLabel)
59 }
60 ToolbarItem(placement: .topBarTrailing) {
61 Button {
62 composing = true
63 } label: {
64 Image(systemName: "plus")
65 }
66 .accessibilityIdentifier("dashboard-create-button")
67 }
68 }
69 .sheet(isPresented: $composing) {
70 RepoCreateSheet(
71 model: createModel,
72 ownerPrefix: session.current?.username ?? ""
73 ) {
74 composing = false
75 Task { await model.load() }
76 }
77 }
78 .task { await model.load() }
79 .refreshable { await model.load() }
80 }
81
82 private var bellLabel: String {
83 guard let unread = model.state.value?.unread, unread > 0 else { return "Notifications" }
84 return "Notifications, \(unread) unread"
85 }
86
87 private func itemSection(
88 _ title: String,
89 items: [DashboardItem],
90 empty: String,
91 marker: String,
92 route: @escaping (DashboardItem) -> some Hashable
93 ) -> some View {
94 Section {
95 if items.isEmpty {
96 Text(empty)
97 .font(.gbSans(.subheadline))
98 .foregroundStyle(.secondary)
99 } else {
100 ForEach(items) { item in
101 NavigationLink(value: route(item)) {
102 dashboardItem(item, marker: marker)
103 }
104 }
105 }
106 } header: {
107 sectionHeader(title, count: items.count)
108 }
109 }
110
111 private func dashboardItem(_ item: DashboardItem, marker: String) -> some View {
112 VStack(alignment: .leading, spacing: 3) {
113 Text(item.title)
114 .font(.gbSans(.subheadline).weight(.semibold))
115 .lineLimit(2)
116 HStack(spacing: 4) {
117 Text(item.repo + marker + String(item.number))
118 .font(.gbSans(.caption))
119 Text("·")
120 Text(item.author)
121 .foregroundStyle(Color.gbAccent)
122 Text("·")
123 Text(item.updatedAt, format: .relative(presentation: .named))
124 if item.state == "source_gone" {
125 Text("·")
126 GBChip("source gone", .secondary)
127 }
128 }
129 .font(.gbSans(.caption))
130 .foregroundStyle(.secondary)
131 .lineLimit(1)
132 }
133 .padding(.vertical, 2)
134 }
135
136 private func pinnedSection(_ pinned: [RepoSummary]) -> some View {
137 Section {
138 if pinned.isEmpty {
139 Text("Pin a repository to keep it here")
140 .font(.gbSans(.subheadline))
141 .foregroundStyle(.secondary)
142 } else {
143 ForEach(pinned) { repo in
144 NavigationLink(value: RepoRoute.repo(repo.path)) {
145 HStack(spacing: 6) {
146 Text(repo.path)
147 .font(.gbSans(.subheadline).weight(.medium))
148 .foregroundStyle(Color.gbAccent)
149 if repo.isPrivate {
150 GBChip("Private", .secondary)
151 }
152 }
153 }
154 }
155 }
156 } header: {
157 Text("Pinned")
158 }
159 }
160
161 private func activitySection(_ events: [FeedEvent]) -> some View {
162 Section {
163 if events.isEmpty {
164 Text("No activity yet")
165 .font(.gbSans(.subheadline))
166 .foregroundStyle(.secondary)
167 } else {
168 ForEach(events) { event in
169 FeedLink(event: event)
170 }
171 }
172 } header: {
173 Text("Recent activity")
174 }
175 }
176
177 private func sectionHeader(_ title: String, count: Int) -> some View {
178 HStack(alignment: .firstTextBaseline, spacing: 4) {
179 Text(title)
180 Text(String(count))
181 .foregroundStyle(.secondary)
182 }
183 }
184}