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