gitbay/Views/Discovery/ProfileView.swift

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

237 lines · 9810 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, in whichever format it was written.
141    @ViewBuilder
142    private func aboutSection(_ profile: ProfileViewModel.Profile) -> some View {
143        if let about = profile.about, !about.isEmpty {
144            Section("About") {
145                ReadmeView(name: profile.aboutFile, content: about)
146                    .padding(.vertical, 4)
147            }
148        }
149    }
150
151    /// A user's organizations, or an organization's members — either way,
152    /// who they work with, each one openable.
153    @ViewBuilder
154    private func peopleSection(_ profile: ProfileViewModel.Profile) -> some View {
155        let people = profile.isOrg ? (profile.members ?? []) : (profile.orgs ?? [])
156        if !people.isEmpty {
157            Section(profile.isOrg ? "Members" : "Organizations") {
158                ForEach(people) { person in
159                    NavigationLink(value: RepoRoute.profile(person.name)) {
160                        HStack {
161                            Label(person.name, systemImage: profile.isOrg
162                                  ? "person.crop.circle" : "building.2")
163                                .font(.gbSans(.subheadline))
164                            Spacer()
165                            if let role = person.role, !role.isEmpty {
166                                GBChip(role, .secondary)
167                            }
168                        }
169                    }
170                }
171            }
172        }
173    }
174
175    @ViewBuilder
176    private func activitySection(_ profile: ProfileViewModel.Profile) -> some View {
177        if let activity = profile.activity, !activity.isEmpty {
178            Section {
179                ActivityGraph(days: activity)
180            } header: {
181                Text("Activity")
182            } footer: {
183                Text("\(profile.activityTotal) contributions in the last year.")
184            }
185        }
186    }
187
188    private func reposSection(_ profile: ProfileViewModel.Profile) -> some View {
189        Section("Repositories \(profile.repos.count)") {
190            if profile.repos.isEmpty {
191                Text("No visible repositories.")
192                    .font(.gbSans(.subheadline))
193                    .foregroundStyle(.secondary)
194            } else {
195                ForEach(profile.repos) { repo in
196                    NavigationLink(value: RepoRoute.repo(repo.path)) {
197                        VStack(alignment: .leading, spacing: 3) {
198                            HStack(spacing: 6) {
199                                Text(repo.name)
200                                    .font(.gbSans(.subheadline).weight(.medium))
201                                if repo.isPrivate {
202                                    Image(systemName: "lock.fill")
203                                        .font(.gbSans(.caption2))
204                                        .foregroundStyle(.secondary)
205                                }
206                                if repo.isArchived {
207                                    GBChip("archived", .secondary)
208                                }
209                            }
210                            if let description = repo.description, !description.isEmpty {
211                                Text(description)
212                                    .font(.gbSans(.caption))
213                                    .foregroundStyle(.secondary)
214                                    .lineLimit(2)
215                            }
216                            if let topics = repo.topics, !topics.isEmpty {
217                                ScrollView(.horizontal, showsIndicators: false) {
218                                    HStack(spacing: 6) {
219                                        ForEach(topics, id: \.self) { topic in
220                                            GBChip(topic, .gbAccent)
221                                        }
222                                    }
223                                }
224                            }
225                            if !repo.meta.isEmpty {
226                                Text(repo.meta)
227                                    .font(.gbSans(.caption2))
228                                    .foregroundStyle(.secondary)
229                                    .lineLimit(1)
230                            }
231                        }
232                    }
233                }
234            }
235        }
236    }
237}