gitbay/Views/Discovery/ProfileView.swift

v1.14.1
gitbay-ios/gitbay/Views/Discovery/ProfileView.swift history · blame · raw

266 lines · 11310 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                    .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}