gitbay/Views/Discovery/ProfileView.swift

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

262 lines · 11034 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        .sheet(isPresented: $editingProfile) {
 84            if let profile = model.state.value {
 85                ProfileEditSheet(model: model, profile: profile)
 86            }
 87        }
 88        .alert("New Organization", isPresented: $creatingOrg) {
 89            TextField("Name", text: $newOrgName)
 90                .autocorrectionDisabled()
 91                .textInputAutocapitalization(.never)
 92            Button("Create") {
 93                Task { await model.createOrg(newOrgName) }
 94            }
 95            .disabled(newOrgName.trimmingCharacters(in: .whitespaces).isEmpty)
 96            Button("Cancel", role: .cancel) {}
 97        }
 98    }
 99
100    // MARK: - Sections
101
102    private func header(_ profile: ProfileViewModel.Profile) -> some View {
103        Section {
104            VStack(alignment: .leading, spacing: 8) {
105                HStack(spacing: 8) {
106                    Image(systemName: profile.isOrg ? "building.2" : "person.crop.circle")
107                        .font(.gbSans(.title2))
108                        .foregroundStyle(Color.gbAccent)
109                    Text(profile.name)
110                        .font(.gbSans(.title3).weight(.semibold))
111                    GBChip(profile.kind, .secondary)
112                }
113                if let description = profile.description, !description.isEmpty {
114                    Text(description)
115                        .font(.gbSans(.subheadline))
116                        .foregroundStyle(.secondary)
117                }
118                if let website = profile.website, !website.isEmpty,
119                   let url = URL(string: website) {
120                    Link(destination: url) {
121                        Label(website, systemImage: "link")
122                            .font(.gbSans(.caption))
123                            .lineLimit(1)
124                    }
125                }
126                if let links = profile.links, !links.isEmpty {
127                    ForEach(Array(links.enumerated()), id: \.offset) { _, link in
128                        if let url = URL(string: link.url) {
129                            Link(destination: url) {
130                                Label(link.title, systemImage: "arrow.up.right")
131                                    .font(.gbSans(.caption))
132                                    .lineLimit(1)
133                            }
134                        }
135                    }
136                }
137            }
138            .padding(.vertical, 4)
139        }
140    }
141
142    /// The long-form about text, rendered under the file it came from. A
143    /// viewer who may write the profile gets the file itself to edit, or,
144    /// when there is none yet, the offer to create it.
145    @ViewBuilder
146    private func aboutSection(_ profile: ProfileViewModel.Profile) -> some View {
147        let canEdit = profile.canEdit(by: session.current?.username)
148        if let about = profile.about, !about.isEmpty {
149            Section("About") {
150                ReadmeView(name: profile.aboutPath ?? "", content: about)
151                    .padding(.vertical, 4)
152                if canEdit, let path = profile.aboutPath {
153                    NavigationLink(value: RepoRoute.file(repo: model.aboutRepo, path: path, ref: nil)) {
154                        Label("Edit About", systemImage: "pencil")
155                    }
156                    .accessibilityIdentifier("profile-about-edit")
157                }
158            }
159        } else if canEdit {
160            Section {
161                Button {
162                    Task { await model.createAboutFile() }
163                } label: {
164                    Label("Create About File", systemImage: "doc.badge.plus")
165                }
166                .disabled(model.working)
167                .accessibilityIdentifier("profile-about-create")
168            } header: {
169                Text("About")
170            } footer: {
171                Text("Adds profile/README.md to \(model.aboutRepo), creating the repository if needed.")
172            }
173        }
174    }
175
176    /// A user's organizations, or an organization's members — either way,
177    /// who they work with, each one openable.
178    @ViewBuilder
179    private func peopleSection(_ profile: ProfileViewModel.Profile) -> some View {
180        let people = profile.isOrg ? (profile.members ?? []) : (profile.orgs ?? [])
181        if !people.isEmpty {
182            Section(profile.isOrg ? "Members" : "Organizations") {
183                ForEach(people) { person in
184                    NavigationLink(value: RepoRoute.profile(person.name)) {
185                        HStack {
186                            Label(person.name, systemImage: profile.isOrg
187                                  ? "person.crop.circle" : "building.2")
188                                .font(.gbSans(.subheadline))
189                            Spacer()
190                            if let role = person.role, !role.isEmpty {
191                                GBChip(role, .secondary)
192                            }
193                        }
194                    }
195                }
196            }
197        }
198    }
199
200    @ViewBuilder
201    private func activitySection(_ profile: ProfileViewModel.Profile) -> some View {
202        if let activity = profile.activity, !activity.isEmpty {
203            Section {
204                ActivityGraph(days: activity)
205            } header: {
206                Text("Activity")
207            } footer: {
208                Text("\(profile.activityTotal) contributions in the last year.")
209            }
210        }
211    }
212
213    private func reposSection(_ profile: ProfileViewModel.Profile) -> some View {
214        Section("Repositories \(profile.repos.count)") {
215            if profile.repos.isEmpty {
216                Text("No visible repositories.")
217                    .font(.gbSans(.subheadline))
218                    .foregroundStyle(.secondary)
219            } else {
220                ForEach(profile.repos) { repo in
221                    NavigationLink(value: RepoRoute.repo(repo.path)) {
222                        VStack(alignment: .leading, spacing: 3) {
223                            HStack(spacing: 6) {
224                                Text(repo.name)
225                                    .font(.gbSans(.subheadline).weight(.medium))
226                                if repo.isPrivate {
227                                    Image(systemName: "lock.fill")
228                                        .font(.gbSans(.caption2))
229                                        .foregroundStyle(.secondary)
230                                }
231                                if repo.isArchived {
232                                    GBChip("archived", .secondary)
233                                }
234                            }
235                            if let description = repo.description, !description.isEmpty {
236                                Text(description)
237                                    .font(.gbSans(.caption))
238                                    .foregroundStyle(.secondary)
239                                    .lineLimit(2)
240                            }
241                            if let topics = repo.topics, !topics.isEmpty {
242                                ScrollView(.horizontal, showsIndicators: false) {
243                                    HStack(spacing: 6) {
244                                        ForEach(topics, id: \.self) { topic in
245                                            GBChip(topic, .gbAccent)
246                                        }
247                                    }
248                                }
249                            }
250                            if !repo.meta.isEmpty {
251                                Text(repo.meta)
252                                    .font(.gbSans(.caption2))
253                                    .foregroundStyle(.secondary)
254                                    .lineLimit(1)
255                            }
256                        }
257                    }
258                }
259            }
260        }
261    }
262}