gitbay/Views/Discovery/ProfileView.swift
237 lines · 9810 bytes
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 .disabled(model.working)
62 .accessibilityIdentifier("profile-edit-button")
63 }
64 }
65 if let profile = model.state.value,
66 !profile.isOrg, session.current?.username == profile.name {
67 ToolbarItem(placement: .topBarTrailing) {
68 Button {
69 newOrgName = ""
70 creatingOrg = true
71 } label: {
72 Image(systemName: "plus.circle")
73 }
74 .disabled(model.working)
75 .accessibilityIdentifier("org-create-button")
76 }
77 }
78 }
79 .task { await model.load() }
80 .refreshable { await model.load() }
81 .sheet(isPresented: $editingProfile) {
82 if let profile = model.state.value {
83 ProfileEditSheet(model: model, profile: profile)
84 }
85 }
86 .alert("New Organization", isPresented: $creatingOrg) {
87 TextField("Name", text: $newOrgName)
88 .autocorrectionDisabled()
89 .textInputAutocapitalization(.never)
90 Button("Create") {
91 Task { await model.createOrg(newOrgName) }
92 }
93 .disabled(newOrgName.trimmingCharacters(in: .whitespaces).isEmpty)
94 Button("Cancel", role: .cancel) {}
95 }
96 }
97
98 // MARK: - Sections
99
100 private func header(_ profile: ProfileViewModel.Profile) -> some View {
101 Section {
102 VStack(alignment: .leading, spacing: 8) {
103 HStack(spacing: 8) {
104 Image(systemName: profile.isOrg ? "building.2" : "person.crop.circle")
105 .font(.gbSans(.title2))
106 .foregroundStyle(Color.gbAccent)
107 Text(profile.name)
108 .font(.gbSans(.title3).weight(.semibold))
109 GBChip(profile.kind, .secondary)
110 }
111 if let description = profile.description, !description.isEmpty {
112 Text(description)
113 .font(.gbSans(.subheadline))
114 .foregroundStyle(.secondary)
115 }
116 if let website = profile.website, !website.isEmpty,
117 let url = URL(string: website) {
118 Link(destination: url) {
119 Label(website, systemImage: "link")
120 .font(.gbSans(.caption))
121 .lineLimit(1)
122 }
123 }
124 if let links = profile.links, !links.isEmpty {
125 ForEach(Array(links.enumerated()), id: \.offset) { _, link in
126 if let url = URL(string: link.url) {
127 Link(destination: url) {
128 Label(link.title, systemImage: "arrow.up.right")
129 .font(.gbSans(.caption))
130 .lineLimit(1)
131 }
132 }
133 }
134 }
135 }
136 .padding(.vertical, 4)
137 }
138 }
139
140 /// The long-form about text, in whichever format it was written.
141 @ViewBuilder
142 private func aboutSection(_ profile: ProfileViewModel.Profile) -> some View {
143 if let about = profile.about, !about.isEmpty {
144 Section("About") {
145 ReadmeView(name: profile.aboutFile, content: about)
146 .padding(.vertical, 4)
147 }
148 }
149 }
150
151 /// A user's organizations, or an organization's members — either way,
152 /// who they work with, each one openable.
153 @ViewBuilder
154 private func peopleSection(_ profile: ProfileViewModel.Profile) -> some View {
155 let people = profile.isOrg ? (profile.members ?? []) : (profile.orgs ?? [])
156 if !people.isEmpty {
157 Section(profile.isOrg ? "Members" : "Organizations") {
158 ForEach(people) { person in
159 NavigationLink(value: RepoRoute.profile(person.name)) {
160 HStack {
161 Label(person.name, systemImage: profile.isOrg
162 ? "person.crop.circle" : "building.2")
163 .font(.gbSans(.subheadline))
164 Spacer()
165 if let role = person.role, !role.isEmpty {
166 GBChip(role, .secondary)
167 }
168 }
169 }
170 }
171 }
172 }
173 }
174
175 @ViewBuilder
176 private func activitySection(_ profile: ProfileViewModel.Profile) -> some View {
177 if let activity = profile.activity, !activity.isEmpty {
178 Section {
179 ActivityGraph(days: activity)
180 } header: {
181 Text("Activity")
182 } footer: {
183 Text("\(profile.activityTotal) contributions in the last year.")
184 }
185 }
186 }
187
188 private func reposSection(_ profile: ProfileViewModel.Profile) -> some View {
189 Section("Repositories \(profile.repos.count)") {
190 if profile.repos.isEmpty {
191 Text("No visible repositories.")
192 .font(.gbSans(.subheadline))
193 .foregroundStyle(.secondary)
194 } else {
195 ForEach(profile.repos) { repo in
196 NavigationLink(value: RepoRoute.repo(repo.path)) {
197 VStack(alignment: .leading, spacing: 3) {
198 HStack(spacing: 6) {
199 Text(repo.name)
200 .font(.gbSans(.subheadline).weight(.medium))
201 if repo.isPrivate {
202 Image(systemName: "lock.fill")
203 .font(.gbSans(.caption2))
204 .foregroundStyle(.secondary)
205 }
206 if repo.isArchived {
207 GBChip("archived", .secondary)
208 }
209 }
210 if let description = repo.description, !description.isEmpty {
211 Text(description)
212 .font(.gbSans(.caption))
213 .foregroundStyle(.secondary)
214 .lineLimit(2)
215 }
216 if let topics = repo.topics, !topics.isEmpty {
217 ScrollView(.horizontal, showsIndicators: false) {
218 HStack(spacing: 6) {
219 ForEach(topics, id: \.self) { topic in
220 GBChip(topic, .gbAccent)
221 }
222 }
223 }
224 }
225 if !repo.meta.isEmpty {
226 Text(repo.meta)
227 .font(.gbSans(.caption2))
228 .foregroundStyle(.secondary)
229 .lineLimit(1)
230 }
231 }
232 }
233 }
234 }
235 }
236 }
237}