gitbay/Views/Dashboard/DashboardView.swift
162 lines · 6238 bytes
1import SwiftUI
2
3/// What needs me, in one read.
4struct DashboardView: View {
5
6 @State private var model: DashboardViewModel
7
8 init(client: GitbayClient) {
9 _model = State(initialValue: DashboardViewModel(client: client))
10 }
11
12 var body: some View {
13 List {
14 if let data = model.state.value {
15 if !data.pinned.isEmpty {
16 pinnedSection(data.pinned)
17 }
18 itemSection(
19 "Needs review", items: data.openMRs,
20 empty: "No open merge requests.",
21 marker: "!"
22 ) { MRRoute.mr(repo: $0.repo, number: $0.number) }
23 itemSection(
24 "Assigned to me", items: data.assignedIssues,
25 empty: "No assigned issues.",
26 marker: "#"
27 ) { IssueRoute.issue(repo: $0.repo, number: $0.number) }
28 buildsSection(data.builds)
29 }
30 }
31 .overlay { LoadStateOverlay(state: model.state) }
32 .navigationTitle("Dashboard")
33 .toolbar {
34 ToolbarItem(placement: .topBarTrailing) {
35 NavigationLink(value: RepoRoute.account) {
36 Image(systemName: "key")
37 }
38 .accessibilityIdentifier("account-screen-link")
39 }
40 AccountMenu()
41 }
42 .task { await model.load() }
43 .refreshable { await model.load() }
44 }
45
46 private func pinnedSection(_ pinned: [RepoSummary]) -> some View {
47 Section("Pinned") {
48 ForEach(pinned) { repo in
49 NavigationLink(value: RepoRoute.repo(repo.path)) {
50 VStack(alignment: .leading, spacing: 2) {
51 HStack(spacing: 6) {
52 Text(repo.path)
53 .font(.subheadline.weight(.medium))
54 .lineLimit(1)
55 if repo.isPrivate {
56 Image(systemName: "lock.fill")
57 .font(.caption2)
58 .foregroundStyle(.secondary)
59 }
60 }
61 if let description = repo.description, !description.isEmpty {
62 Text(description)
63 .font(.caption)
64 .foregroundStyle(.secondary)
65 .lineLimit(1)
66 }
67 }
68 }
69 }
70 }
71 }
72
73 private func itemSection(
74 _ title: String,
75 items: [DashboardItem],
76 empty: String,
77 marker: String,
78 route: @escaping (DashboardItem) -> some Hashable
79 ) -> some View {
80 Section(title) {
81 if items.isEmpty {
82 Text(empty)
83 .font(.subheadline)
84 .foregroundStyle(.secondary)
85 } else {
86 ForEach(items) { item in
87 NavigationLink(value: route(item)) {
88 VStack(alignment: .leading, spacing: 2) {
89 Text(item.repo)
90 .font(.caption)
91 .foregroundStyle(.secondary)
92 HStack(alignment: .firstTextBaseline, spacing: 6) {
93 Text(marker + String(item.number))
94 .font(.caption.monospaced())
95 .foregroundStyle(.secondary)
96 Text(item.title)
97 .font(.subheadline.weight(.medium))
98 .lineLimit(2)
99 }
100 HStack(spacing: 6) {
101 Text("by \(item.author)")
102 Text(item.updatedAt, format: .relative(presentation: .named))
103 .foregroundStyle(.tertiary)
104 }
105 .font(.caption)
106 .foregroundStyle(.secondary)
107 }
108 }
109 }
110 }
111 }
112 }
113
114 private func buildsSection(_ builds: [DashboardBuild]) -> some View {
115 Section("Recent builds") {
116 if builds.isEmpty {
117 Text("No recent builds.")
118 .font(.subheadline)
119 .foregroundStyle(.secondary)
120 } else {
121 ForEach(builds) { build in
122 NavigationLink(value: BuildRoute.log(repo: build.repo, number: build.number)) {
123 HStack(spacing: 8) {
124 Image(systemName: buildIcon(build.status))
125 .foregroundStyle(buildColor(build.status))
126 VStack(alignment: .leading, spacing: 2) {
127 Text(build.repo)
128 .font(.caption)
129 .foregroundStyle(.secondary)
130 Text("#\(build.number) \(build.job)")
131 .font(.subheadline)
132 }
133 Spacer()
134 Text(build.createdAt, format: .relative(presentation: .named))
135 .font(.caption)
136 .foregroundStyle(.tertiary)
137 }
138 }
139 }
140 }
141 }
142 }
143
144 private func buildIcon(_ status: String) -> String {
145 switch status {
146 case "success": "checkmark.circle.fill"
147 case "failure", "error": "xmark.circle.fill"
148 case "running": "circle.dotted"
149 case "queued", "pending": "clock"
150 default: "questionmark.circle"
151 }
152 }
153
154 private func buildColor(_ status: String) -> Color {
155 switch status {
156 case "success": .green
157 case "failure", "error": .red
158 case "running", "queued", "pending": .orange
159 default: .secondary
160 }
161 }
162}