gitbay/Views/Discovery/ProfileView.swift

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

229 lines · 9437 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,
 49                !profile.isOrg, session.current?.username == profile.name {
 50                ToolbarItem(placement: .topBarTrailing) {
 51                    Button {
 52                        editingProfile = true
 53                    } label: {
 54                        Image(systemName: "pencil")
 55                    }
 56                    .disabled(model.working)
 57                    .accessibilityIdentifier("profile-edit-button")
 58                }
 59                ToolbarItem(placement: .topBarTrailing) {
 60                    Button {
 61                        newOrgName = ""
 62                        creatingOrg = true
 63                    } label: {
 64                        Image(systemName: "plus.circle")
 65                    }
 66                    .disabled(model.working)
 67                    .accessibilityIdentifier("org-create-button")
 68                }
 69            }
 70        }
 71        .task { await model.load() }
 72        .refreshable { await model.load() }
 73        .sheet(isPresented: $editingProfile) {
 74            if let profile = model.state.value {
 75                ProfileEditSheet(model: model, profile: profile)
 76            }
 77        }
 78        .alert("New Organization", isPresented: $creatingOrg) {
 79            TextField("Name", text: $newOrgName)
 80                .autocorrectionDisabled()
 81                .textInputAutocapitalization(.never)
 82            Button("Create") {
 83                Task { await model.createOrg(newOrgName) }
 84            }
 85            .disabled(newOrgName.trimmingCharacters(in: .whitespaces).isEmpty)
 86            Button("Cancel", role: .cancel) {}
 87        }
 88    }
 89
 90    // MARK: - Sections
 91
 92    private func header(_ profile: ProfileViewModel.Profile) -> some View {
 93        Section {
 94            VStack(alignment: .leading, spacing: 8) {
 95                HStack(spacing: 8) {
 96                    Image(systemName: profile.isOrg ? "building.2" : "person.crop.circle")
 97                        .font(.gbSans(.title2))
 98                        .foregroundStyle(Color.gbAccent)
 99                    Text(profile.name)
100                        .font(.gbSans(.title3).weight(.semibold))
101                    GBChip(profile.kind, .secondary)
102                }
103                if let description = profile.description, !description.isEmpty {
104                    Text(description)
105                        .font(.gbSans(.subheadline))
106                        .foregroundStyle(.secondary)
107                }
108                if let website = profile.website, !website.isEmpty,
109                   let url = URL(string: website) {
110                    Link(destination: url) {
111                        Label(website, systemImage: "link")
112                            .font(.gbSans(.caption))
113                            .lineLimit(1)
114                    }
115                }
116                if let links = profile.links, !links.isEmpty {
117                    ForEach(Array(links.enumerated()), id: \.offset) { _, link in
118                        if let url = URL(string: link.url) {
119                            Link(destination: url) {
120                                Label(link.title, systemImage: "arrow.up.right")
121                                    .font(.gbSans(.caption))
122                                    .lineLimit(1)
123                            }
124                        }
125                    }
126                }
127            }
128            .padding(.vertical, 4)
129        }
130    }
131
132    /// The long-form about text, in whichever format it was written.
133    @ViewBuilder
134    private func aboutSection(_ profile: ProfileViewModel.Profile) -> some View {
135        if let about = profile.about, !about.isEmpty {
136            Section("About") {
137                ReadmeView(name: profile.aboutFile, content: about)
138                    .padding(.vertical, 4)
139            }
140        }
141    }
142
143    /// A user's organizations, or an organization's members — either way,
144    /// who they work with, each one openable.
145    @ViewBuilder
146    private func peopleSection(_ profile: ProfileViewModel.Profile) -> some View {
147        let people = profile.isOrg ? (profile.members ?? []) : (profile.orgs ?? [])
148        if !people.isEmpty {
149            Section(profile.isOrg ? "Members" : "Organizations") {
150                ForEach(people) { person in
151                    NavigationLink(value: RepoRoute.profile(person.name)) {
152                        HStack {
153                            Label(person.name, systemImage: profile.isOrg
154                                  ? "person.crop.circle" : "building.2")
155                                .font(.gbSans(.subheadline))
156                            Spacer()
157                            if let role = person.role, !role.isEmpty {
158                                GBChip(role, .secondary)
159                            }
160                        }
161                    }
162                }
163            }
164        }
165    }
166
167    @ViewBuilder
168    private func activitySection(_ profile: ProfileViewModel.Profile) -> some View {
169        if let activity = profile.activity, !activity.isEmpty {
170            Section {
171                ActivityGraph(days: activity)
172            } header: {
173                Text("Activity")
174            } footer: {
175                Text("\(profile.activityTotal) contributions in the last year.")
176            }
177        }
178    }
179
180    private func reposSection(_ profile: ProfileViewModel.Profile) -> some View {
181        Section("Repositories \(profile.repos.count)") {
182            if profile.repos.isEmpty {
183                Text("No visible repositories.")
184                    .font(.gbSans(.subheadline))
185                    .foregroundStyle(.secondary)
186            } else {
187                ForEach(profile.repos) { repo in
188                    NavigationLink(value: RepoRoute.repo(repo.path)) {
189                        VStack(alignment: .leading, spacing: 3) {
190                            HStack(spacing: 6) {
191                                Text(repo.name)
192                                    .font(.gbSans(.subheadline).weight(.medium))
193                                if repo.isPrivate {
194                                    Image(systemName: "lock.fill")
195                                        .font(.gbSans(.caption2))
196                                        .foregroundStyle(.secondary)
197                                }
198                                if repo.isArchived {
199                                    GBChip("archived", .secondary)
200                                }
201                            }
202                            if let description = repo.description, !description.isEmpty {
203                                Text(description)
204                                    .font(.gbSans(.caption))
205                                    .foregroundStyle(.secondary)
206                                    .lineLimit(2)
207                            }
208                            if let topics = repo.topics, !topics.isEmpty {
209                                ScrollView(.horizontal, showsIndicators: false) {
210                                    HStack(spacing: 6) {
211                                        ForEach(topics, id: \.self) { topic in
212                                            GBChip(topic, .gbAccent)
213                                        }
214                                    }
215                                }
216                            }
217                            if !repo.meta.isEmpty {
218                                Text(repo.meta)
219                                    .font(.gbSans(.caption2))
220                                    .foregroundStyle(.secondary)
221                                    .lineLimit(1)
222                            }
223                        }
224                    }
225                }
226            }
227        }
228    }
229}