import SwiftUI /// A user or organization: who they are, who they work with, what they /// have been doing, and what they own that you can see. struct ProfileView: View { @Environment(SessionStore.self) private var session @State private var model: ProfileViewModel @State private var creatingOrg = false @State private var newOrgName = "" @State private var editingProfile = false /// Someone else's profile is titled with their name. Your own is /// reached from a tab and titled to match it. private let title: String? init(client: GitbayClient, name: String, title: String? = nil) { _model = State(initialValue: ProfileViewModel(client: client, name: name)) self.title = title } var body: some View { List { if let profile = model.state.value { if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } header(profile) peopleSection(profile) aboutSection(profile) activitySection(profile) reposSection(profile) if profile.isOrg { Section { NavigationLink(value: OrgRoute.org(profile.name)) { Label("Members & Teams", systemImage: "person.3") } } } else { Section { NavigationLink(value: SnippetRoute.list(owner: profile.name)) { Label("Snippets", systemImage: "scissors") } } } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle(title ?? model.name) .navigationBarTitleDisplayMode(.inline) .toolbar { if let profile = model.state.value, profile.canEdit(by: session.current?.username) { ToolbarItem(placement: .topBarTrailing) { Button { editingProfile = true } label: { Image(systemName: "pencil") } .accessibilityLabel("Edit Profile") .disabled(model.working) .accessibilityIdentifier("profile-edit-button") } } if let profile = model.state.value, !profile.isOrg, session.current?.username == profile.name { ToolbarItem(placement: .topBarTrailing) { Button { newOrgName = "" creatingOrg = true } label: { Image(systemName: "plus.circle") } .accessibilityLabel("New Organization") .disabled(model.working) .accessibilityIdentifier("org-create-button") } } } .task { await model.load() } .refreshable { await model.load() } .sheet(isPresented: $editingProfile) { if let profile = model.state.value { ProfileEditSheet(model: model, profile: profile) } } .alert("New Organization", isPresented: $creatingOrg) { TextField("Name", text: $newOrgName) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button("Create") { Task { await model.createOrg(newOrgName) } } .disabled(newOrgName.trimmingCharacters(in: .whitespaces).isEmpty) Button("Cancel", role: .cancel) {} } } // MARK: - Sections private func header(_ profile: ProfileViewModel.Profile) -> some View { Section { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Image(systemName: profile.isOrg ? "building.2" : "person.crop.circle") .font(.gbSans(.title2)) .foregroundStyle(Color.gbAccent) Text(profile.name) .font(.gbSans(.title3).weight(.semibold)) GBChip(profile.kind, .secondary) } if let description = profile.description, !description.isEmpty { Text(description) .font(.gbSans(.subheadline)) .foregroundStyle(.secondary) } if let website = profile.website, !website.isEmpty, let url = URL(string: website) { Link(destination: url) { Label(website, systemImage: "link") .font(.gbSans(.caption)) .lineLimit(1) } } if let links = profile.links, !links.isEmpty { ForEach(Array(links.enumerated()), id: \.offset) { _, link in if let url = URL(string: link.url) { Link(destination: url) { Label(link.title, systemImage: "arrow.up.right") .font(.gbSans(.caption)) .lineLimit(1) } } } } } .padding(.vertical, 4) } } /// The long-form about text, rendered under the file it came from. A /// viewer who may write the profile gets the file itself to edit, or, /// when there is none yet, the offer to create it. @ViewBuilder private func aboutSection(_ profile: ProfileViewModel.Profile) -> some View { let canEdit = profile.canEdit(by: session.current?.username) if let about = profile.about, !about.isEmpty { Section("About") { ReadmeView(name: profile.aboutPath ?? "", content: about) .padding(.vertical, 4) if canEdit, let path = profile.aboutPath { NavigationLink(value: RepoRoute.file(repo: model.aboutRepo, path: path, ref: nil)) { Label("Edit About", systemImage: "pencil") } .accessibilityIdentifier("profile-about-edit") } } } else if canEdit { Section { Button { Task { await model.createAboutFile() } } label: { Label("Create About File", systemImage: "doc.badge.plus") } .disabled(model.working) .accessibilityIdentifier("profile-about-create") } header: { Text("About") } footer: { Text("Adds profile/README.md to \(model.aboutRepo), creating the repository if needed.") } } } /// A user's organizations, or an organization's members — either way, /// who they work with, each one openable. @ViewBuilder private func peopleSection(_ profile: ProfileViewModel.Profile) -> some View { let people = profile.isOrg ? (profile.members ?? []) : (profile.orgs ?? []) if !people.isEmpty { Section(profile.isOrg ? "Members" : "Organizations") { ForEach(people) { person in NavigationLink(value: RepoRoute.profile(person.name)) { HStack { Label(person.name, systemImage: profile.isOrg ? "person.crop.circle" : "building.2") .font(.gbSans(.subheadline)) Spacer() if let role = person.role, !role.isEmpty { GBChip(role, .secondary) } } } } } } } @ViewBuilder private func activitySection(_ profile: ProfileViewModel.Profile) -> some View { if let activity = profile.activity, !activity.isEmpty { Section { ActivityGraph(days: activity) } header: { Text("Activity") } footer: { Text("\(profile.activityTotal) contributions in the last year.") } } } private func reposSection(_ profile: ProfileViewModel.Profile) -> some View { Section("Repositories \(profile.repos.count)") { if profile.repos.isEmpty { Text("No visible repositories.") .font(.gbSans(.subheadline)) .foregroundStyle(.secondary) } else { ForEach(profile.repos) { repo in NavigationLink(value: RepoRoute.repo(repo.path)) { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(repo.name) .font(.gbSans(.subheadline).weight(.medium)) if repo.isPrivate { Image(systemName: "lock.fill") .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } if repo.isArchived { GBChip("archived", .secondary) } } if let description = repo.description, !description.isEmpty { Text(description) .font(.gbSans(.caption)) .foregroundStyle(.secondary) .lineLimit(2) } if let topics = repo.topics, !topics.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(topics, id: \.self) { topic in GBChip(topic, .gbAccent) } } } } if !repo.meta.isEmpty { Text(repo.meta) .font(.gbSans(.caption2)) .foregroundStyle(.secondary) .lineLimit(1) } } } } } } } }