gitbay/Views/Dashboard/DashboardView.swift
191 lines · 6861 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 pinnedSection(data.pinned)
44 activitySection(data.recentActivity)
45 }
46 }
47 .overlay { LoadStateOverlay(state: model.state) }
48 .navigationTitle("Dashboard")
49 .toolbar {
50 ToolbarItem(placement: .topBarTrailing) {
51 RouteLink(value: RepoRoute.notifications) {
52 HStack(spacing: 2) {
53 Image(systemName: "bell")
54 if let unread = model.state.value?.unread, unread > 0 {
55 Text(String(unread))
56 .font(.gbSans(.caption2))
57 }
58 }
59 }
60 .accessibilityIdentifier("dashboard-notifications-button")
61 .accessibilityLabel(bellLabel)
62 }
63 ToolbarItem(placement: .topBarTrailing) {
64 Button {
65 composing = true
66 } label: {
67 Image(systemName: "plus")
68 }
69 .accessibilityLabel("New Repository")
70 .accessibilityIdentifier("dashboard-create-button")
71 }
72 }
73 .sheet(isPresented: $composing) {
74 RepoCreateSheet(
75 model: createModel,
76 ownerPrefix: session.current?.username ?? ""
77 ) {
78 composing = false
79 Task { await model.load() }
80 }
81 }
82 .task { await model.load() }
83 .refreshable { await model.load() }
84 .onChange(of: model.state.value?.unread) { _, count in
85 if let count { unread = count }
86 }
87 }
88
89 private var bellLabel: String {
90 guard let unread = model.state.value?.unread, unread > 0 else { return "Notifications" }
91 return "Notifications, \(unread) unread"
92 }
93
94 private func itemSection(
95 _ title: String,
96 items: [DashboardItem],
97 empty: String,
98 marker: String,
99 route: @escaping (DashboardItem) -> some Hashable
100 ) -> some View {
101 Section {
102 if items.isEmpty {
103 Text(empty)
104 .font(.gbSans(.subheadline))
105 .foregroundStyle(.secondary)
106 } else {
107 ForEach(items) { item in
108 NavigationLink(value: route(item)) {
109 dashboardItem(item, marker: marker)
110 }
111 }
112 }
113 } header: {
114 sectionHeader(title, count: items.count)
115 }
116 }
117
118 private func dashboardItem(_ item: DashboardItem, marker: String) -> some View {
119 VStack(alignment: .leading, spacing: 3) {
120 Text(item.title)
121 .font(.gbSans(.subheadline).weight(.semibold))
122 .lineLimit(2)
123 HStack(spacing: 4) {
124 Text(item.repo + marker + String(item.number))
125 .font(.gbSans(.caption))
126 Text("·")
127 Text(item.author)
128 .foregroundStyle(Color.gbAccent)
129 Text("·")
130 Text(item.updatedAt, format: .relative(presentation: .named))
131 if item.state == "source_gone" {
132 Text("·")
133 GBChip(stateLabel(item.state), .secondary)
134 }
135 }
136 .font(.gbSans(.caption))
137 .foregroundStyle(.secondary)
138 .lineLimit(1)
139 }
140 .padding(.vertical, 2)
141 }
142
143 private func pinnedSection(_ pinned: [RepoSummary]) -> some View {
144 Section {
145 if pinned.isEmpty {
146 Text("Pin a repository to keep it here")
147 .font(.gbSans(.subheadline))
148 .foregroundStyle(.secondary)
149 } else {
150 ForEach(pinned) { repo in
151 NavigationLink(value: RepoRoute.repo(repo.path)) {
152 HStack(spacing: 6) {
153 Text(repo.path)
154 .font(.gbSans(.subheadline).weight(.medium))
155 .foregroundStyle(Color.gbAccent)
156 if repo.isPrivate {
157 GBChip("Private", .secondary)
158 }
159 }
160 }
161 }
162 }
163 } header: {
164 Text("Pinned")
165 }
166 }
167
168 private func activitySection(_ events: [FeedEvent]) -> some View {
169 Section {
170 if events.isEmpty {
171 Text("No activity yet")
172 .font(.gbSans(.subheadline))
173 .foregroundStyle(.secondary)
174 } else {
175 ForEach(events) { event in
176 FeedLink(event: event)
177 }
178 }
179 } header: {
180 Text("Recent activity")
181 }
182 }
183
184 private func sectionHeader(_ title: String, count: Int) -> some View {
185 HStack(alignment: .firstTextBaseline, spacing: 4) {
186 Text(title)
187 Text(String(count))
188 .foregroundStyle(.secondary)
189 }
190 }
191}