gitbay/Views/Discovery/ProfileView.swift

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

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}