gitbay/Views/Discovery/ProfileView.swift

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

260 lines · 10918 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, rendered under the file it came from. A
141    /// viewer who may write the profile gets the file itself to edit, or,
142    /// when there is none yet, the offer to create it.
143    @ViewBuilder
144    private func aboutSection(_ profile: ProfileViewModel.Profile) -> some View {
145        let canEdit = profile.canEdit(by: session.current?.username)
146        if let about = profile.about, !about.isEmpty {
147            Section("About") {
148                ReadmeView(name: profile.aboutPath ?? "", content: about)
149                    .padding(.vertical, 4)
150                if canEdit, let path = profile.aboutPath {
151                    NavigationLink(value: RepoRoute.file(repo: model.aboutRepo, path: path, ref: nil)) {
152                        Label("Edit About", systemImage: "pencil")
153                    }
154                    .accessibilityIdentifier("profile-about-edit")
155                }
156            }
157        } else if canEdit {
158            Section {
159                Button {
160                    Task { await model.createAboutFile() }
161                } label: {
162                    Label("Create About File", systemImage: "doc.badge.plus")
163                }
164                .disabled(model.working)
165                .accessibilityIdentifier("profile-about-create")
166            } header: {
167                Text("About")
168            } footer: {
169                Text("Adds profile/README.md to \(model.aboutRepo), creating the repository if needed.")
170            }
171        }
172    }
173
174    /// A user's organizations, or an organization's members — either way,
175    /// who they work with, each one openable.
176    @ViewBuilder
177    private func peopleSection(_ profile: ProfileViewModel.Profile) -> some View {
178        let people = profile.isOrg ? (profile.members ?? []) : (profile.orgs ?? [])
179        if !people.isEmpty {
180            Section(profile.isOrg ? "Members" : "Organizations") {
181                ForEach(people) { person in
182                    NavigationLink(value: RepoRoute.profile(person.name)) {
183                        HStack {
184                            Label(person.name, systemImage: profile.isOrg
185                                  ? "person.crop.circle" : "building.2")
186                                .font(.gbSans(.subheadline))
187                            Spacer()
188                            if let role = person.role, !role.isEmpty {
189                                GBChip(role, .secondary)
190                            }
191                        }
192                    }
193                }
194            }
195        }
196    }
197
198    @ViewBuilder
199    private func activitySection(_ profile: ProfileViewModel.Profile) -> some View {
200        if let activity = profile.activity, !activity.isEmpty {
201            Section {
202                ActivityGraph(days: activity)
203            } header: {
204                Text("Activity")
205            } footer: {
206                Text("\(profile.activityTotal) contributions in the last year.")
207            }
208        }
209    }
210
211    private func reposSection(_ profile: ProfileViewModel.Profile) -> some View {
212        Section("Repositories \(profile.repos.count)") {
213            if profile.repos.isEmpty {
214                Text("No visible repositories.")
215                    .font(.gbSans(.subheadline))
216                    .foregroundStyle(.secondary)
217            } else {
218                ForEach(profile.repos) { repo in
219                    NavigationLink(value: RepoRoute.repo(repo.path)) {
220                        VStack(alignment: .leading, spacing: 3) {
221                            HStack(spacing: 6) {
222                                Text(repo.name)
223                                    .font(.gbSans(.subheadline).weight(.medium))
224                                if repo.isPrivate {
225                                    Image(systemName: "lock.fill")
226                                        .font(.gbSans(.caption2))
227                                        .foregroundStyle(.secondary)
228                                }
229                                if repo.isArchived {
230                                    GBChip("archived", .secondary)
231                                }
232                            }
233                            if let description = repo.description, !description.isEmpty {
234                                Text(description)
235                                    .font(.gbSans(.caption))
236                                    .foregroundStyle(.secondary)
237                                    .lineLimit(2)
238                            }
239                            if let topics = repo.topics, !topics.isEmpty {
240                                ScrollView(.horizontal, showsIndicators: false) {
241                                    HStack(spacing: 6) {
242                                        ForEach(topics, id: \.self) { topic in
243                                            GBChip(topic, .gbAccent)
244                                        }
245                                    }
246                                }
247                            }
248                            if !repo.meta.isEmpty {
249                                Text(repo.meta)
250                                    .font(.gbSans(.caption2))
251                                    .foregroundStyle(.secondary)
252                                    .lineLimit(1)
253                            }
254                        }
255                    }
256                }
257            }
258        }
259    }
260}