gitbay/Views/Discovery/ProfileView.swift
266 lines · 11310 bytes
6 symbols in this file
1import SwiftUI
2
3/// A user or organization: who they are, who they work with, what they
4/// have been doing, and what they own that you can see.
5struct ProfileView: View {
6
7 @Environment(SessionStore.self) private var session
8 @State private var model: ProfileViewModel
9 @State private var creatingOrg = false
10 @State private var newOrgName = ""
11 @State private var editingProfile = false
12
13 /// Someone else's profile is titled with their name. Your own is
14 /// reached from a tab and titled to match it.
15 private let title: String?
16
17 init(client: GitbayClient, name: String, title: String? = nil) {
18 _model = State(initialValue: ProfileViewModel(client: client, name: name))
19 self.title = title
20 }
21
22 var body: some View {
23 List {
24 if let profile = model.state.value {
25 if let error = model.actionError {
26 Section {
27 GBNotice(error, .gbWarn)
28 }
29 }
30 header(profile)
31 peopleSection(profile)
32 aboutSection(profile)
33 activitySection(profile)
34 reposSection(profile)
35 if profile.isOrg {
36 Section {
37 NavigationLink(value: OrgRoute.org(profile.name)) {
38 Label("Members & Teams", systemImage: "person.3")
39 }
40 }
41 } else {
42 Section {
43 NavigationLink(value: SnippetRoute.list(owner: profile.name)) {
44 Label("Snippets", systemImage: "scissors")
45 }
46 }
47 }
48 }
49 }
50 .overlay { LoadStateOverlay(state: model.state) }
51 .navigationTitle(title ?? model.name)
52 .navigationBarTitleDisplayMode(.inline)
53 .toolbar {
54 if let profile = model.state.value, profile.canEdit(by: session.current?.username) {
55 ToolbarItem(placement: .topBarTrailing) {
56 Button {
57 editingProfile = true
58 } label: {
59 Image(systemName: "pencil")
60 }
61 .accessibilityLabel("Edit Profile")
62 .disabled(model.working)
63 .accessibilityIdentifier("profile-edit-button")
64 }
65 }
66 if let profile = model.state.value,
67 !profile.isOrg, session.current?.username == profile.name {
68 ToolbarItem(placement: .topBarTrailing) {
69 Button {
70 newOrgName = ""
71 creatingOrg = true
72 } label: {
73 Image(systemName: "plus.circle")
74 }
75 .accessibilityLabel("New Organization")
76 .disabled(model.working)
77 .accessibilityIdentifier("org-create-button")
78 }
79 }
80 }
81 .task { await model.load() }
82 .refreshable { await model.load() }
83 // The organization screen, pushed from an org's profile, may delete it.
84 .onReceive(NotificationCenter.default.publisher(for: .gitbayOrganizationsChanged)) { note in
85 if note.object as? String == model.name { Task { await model.load() } }
86 }
87 .sheet(isPresented: $editingProfile) {
88 if let profile = model.state.value {
89 ProfileEditSheet(model: model, profile: profile)
90 }
91 }
92 .alert("New Organization", isPresented: $creatingOrg) {
93 TextField("Name", text: $newOrgName)
94 .autocorrectionDisabled()
95 .textInputAutocapitalization(.never)
96 Button("Create") {
97 Task { await model.createOrg(newOrgName) }
98 }
99 .disabled(newOrgName.trimmingCharacters(in: .whitespaces).isEmpty)
100 Button("Cancel", role: .cancel) {}
101 }
102 }
103
104 // MARK: - Sections
105
106 private func header(_ profile: ProfileViewModel.Profile) -> some View {
107 Section {
108 VStack(alignment: .leading, spacing: 8) {
109 HStack(spacing: 8) {
110 Image(systemName: profile.isOrg ? "building.2" : "person.crop.circle")
111 .font(.gbSans(.title2))
112 .foregroundStyle(Color.gbAccent)
113 Text(profile.name)
114 .font(.gbSans(.title3).weight(.semibold))
115 GBChip(profile.kind, .secondary)
116 }
117 if let description = profile.description, !description.isEmpty {
118 Text(description)
119 .font(.gbSans(.subheadline))
120 .foregroundStyle(.secondary)
121 }
122 if let website = profile.website, !website.isEmpty,
123 let url = URL(string: website) {
124 Link(destination: url) {
125 Label(website, systemImage: "link")
126 .font(.gbSans(.caption))
127 .lineLimit(1)
128 }
129 }
130 if let links = profile.links, !links.isEmpty {
131 ForEach(Array(links.enumerated()), id: \.offset) { _, link in
132 if let url = URL(string: link.url) {
133 Link(destination: url) {
134 Label(link.title, systemImage: "arrow.up.right")
135 .font(.gbSans(.caption))
136 .lineLimit(1)
137 }
138 }
139 }
140 }
141 }
142 .padding(.vertical, 4)
143 }
144 }
145
146 /// The long-form about text, rendered under the file it came from. A
147 /// viewer who may write the profile gets the file itself to edit, or,
148 /// when there is none yet, the offer to create it.
149 @ViewBuilder
150 private func aboutSection(_ profile: ProfileViewModel.Profile) -> some View {
151 let canEdit = profile.canEdit(by: session.current?.username)
152 if let about = profile.about, !about.isEmpty {
153 Section("About") {
154 ReadmeView(name: profile.aboutPath ?? "", content: about)
155 .padding(.vertical, 4)
156 if canEdit, let path = profile.aboutPath {
157 NavigationLink(value: RepoRoute.file(repo: model.aboutRepo, path: path, ref: nil)) {
158 Label("Edit About", systemImage: "pencil")
159 }
160 .accessibilityIdentifier("profile-about-edit")
161 }
162 }
163 } else if canEdit {
164 Section {
165 Button {
166 Task { await model.createAboutFile() }
167 } label: {
168 Label("Create About File", systemImage: "doc.badge.plus")
169 }
170 .disabled(model.working)
171 .accessibilityIdentifier("profile-about-create")
172 } header: {
173 Text("About")
174 } footer: {
175 Text("Adds profile/README.md to \(model.aboutRepo), creating the repository if needed.")
176 }
177 }
178 }
179
180 /// A user's organizations, or an organization's members — either way,
181 /// who they work with, each one openable.
182 @ViewBuilder
183 private func peopleSection(_ profile: ProfileViewModel.Profile) -> some View {
184 let people = profile.isOrg ? (profile.members ?? []) : (profile.orgs ?? [])
185 if !people.isEmpty {
186 Section(profile.isOrg ? "Members" : "Organizations") {
187 ForEach(people) { person in
188 NavigationLink(value: RepoRoute.profile(person.name)) {
189 HStack {
190 Label(person.name, systemImage: profile.isOrg
191 ? "person.crop.circle" : "building.2")
192 .font(.gbSans(.subheadline))
193 Spacer()
194 if let role = person.role, !role.isEmpty {
195 GBChip(role, .secondary)
196 }
197 }
198 }
199 }
200 }
201 }
202 }
203
204 @ViewBuilder
205 private func activitySection(_ profile: ProfileViewModel.Profile) -> some View {
206 if let activity = profile.activity, !activity.isEmpty {
207 Section {
208 ActivityGraph(days: activity)
209 } header: {
210 Text("Activity")
211 } footer: {
212 Text("\(profile.activityTotal) contributions in the last year.")
213 }
214 }
215 }
216
217 private func reposSection(_ profile: ProfileViewModel.Profile) -> some View {
218 Section("Repositories \(profile.repos.count)") {
219 if profile.repos.isEmpty {
220 Text("No visible repositories.")
221 .font(.gbSans(.subheadline))
222 .foregroundStyle(.secondary)
223 } else {
224 ForEach(profile.repos) { repo in
225 NavigationLink(value: RepoRoute.repo(repo.path)) {
226 VStack(alignment: .leading, spacing: 3) {
227 HStack(spacing: 6) {
228 Text(repo.name)
229 .font(.gbSans(.subheadline).weight(.medium))
230 if repo.isPrivate {
231 Image(systemName: "lock.fill")
232 .font(.gbSans(.caption2))
233 .foregroundStyle(.secondary)
234 }
235 if repo.isArchived {
236 GBChip("archived", .secondary)
237 }
238 }
239 if let description = repo.description, !description.isEmpty {
240 Text(description)
241 .font(.gbSans(.caption))
242 .foregroundStyle(.secondary)
243 .lineLimit(2)
244 }
245 if let topics = repo.topics, !topics.isEmpty {
246 ScrollView(.horizontal, showsIndicators: false) {
247 HStack(spacing: 6) {
248 ForEach(topics, id: \.self) { topic in
249 GBChip(topic, .gbAccent)
250 }
251 }
252 }
253 }
254 if !repo.meta.isEmpty {
255 Text(repo.meta)
256 .font(.gbSans(.caption2))
257 .foregroundStyle(.secondary)
258 .lineLimit(1)
259 }
260 }
261 }
262 }
263 }
264 }
265 }
266}