gitbay/Views/Discovery/ProfileView.swift

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

181 lines · 7505 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    @State private var model: ProfileViewModel
  8
  9    /// Someone else's profile is titled with their name. Your own is
 10    /// reached from a tab and titled to match it.
 11    private let title: String?
 12
 13    init(client: GitbayClient, name: String, title: String? = nil) {
 14        _model = State(initialValue: ProfileViewModel(client: client, name: name))
 15        self.title = title
 16    }
 17
 18    var body: some View {
 19        List {
 20            if let profile = model.state.value {
 21                header(profile)
 22                peopleSection(profile)
 23                aboutSection(profile)
 24                activitySection(profile)
 25                reposSection(profile)
 26                if profile.isOrg {
 27                    Section {
 28                        NavigationLink(value: OrgRoute.org(profile.name)) {
 29                            Label("Members & Teams", systemImage: "person.3")
 30                        }
 31                    }
 32                }
 33            }
 34        }
 35        .overlay { LoadStateOverlay(state: model.state) }
 36        .navigationTitle(title ?? model.name)
 37        .navigationBarTitleDisplayMode(.inline)
 38        .task { await model.load() }
 39        .refreshable { await model.load() }
 40    }
 41
 42    // MARK: - Sections
 43
 44    private func header(_ profile: ProfileViewModel.Profile) -> some View {
 45        Section {
 46            VStack(alignment: .leading, spacing: 8) {
 47                HStack(spacing: 8) {
 48                    Image(systemName: profile.isOrg ? "building.2" : "person.crop.circle")
 49                        .font(.gbSans(.title2))
 50                        .foregroundStyle(Color.gbAccent)
 51                    Text(profile.name)
 52                        .font(.gbSans(.title3).weight(.semibold))
 53                    GBChip(profile.kind, .secondary)
 54                }
 55                if let description = profile.description, !description.isEmpty {
 56                    Text(description)
 57                        .font(.gbSans(.subheadline))
 58                        .foregroundStyle(.secondary)
 59                }
 60                if let website = profile.website, !website.isEmpty,
 61                   let url = URL(string: website) {
 62                    Link(destination: url) {
 63                        Label(website, systemImage: "link")
 64                            .font(.gbSans(.caption))
 65                            .lineLimit(1)
 66                    }
 67                }
 68                if let links = profile.links, !links.isEmpty {
 69                    ForEach(Array(links.enumerated()), id: \.offset) { _, link in
 70                        if let url = URL(string: link.url) {
 71                            Link(destination: url) {
 72                                Label(link.title, systemImage: "arrow.up.right")
 73                                    .font(.gbSans(.caption))
 74                                    .lineLimit(1)
 75                            }
 76                        }
 77                    }
 78                }
 79            }
 80            .padding(.vertical, 4)
 81        }
 82    }
 83
 84    /// The long-form about text, in whichever format it was written.
 85    @ViewBuilder
 86    private func aboutSection(_ profile: ProfileViewModel.Profile) -> some View {
 87        if let about = profile.about, !about.isEmpty {
 88            Section("About") {
 89                ReadmeView(name: profile.aboutFile, content: about)
 90                    .padding(.vertical, 4)
 91            }
 92        }
 93    }
 94
 95    /// A user's organizations, or an organization's members — either way,
 96    /// who they work with, each one openable.
 97    @ViewBuilder
 98    private func peopleSection(_ profile: ProfileViewModel.Profile) -> some View {
 99        let people = profile.isOrg ? (profile.members ?? []) : (profile.orgs ?? [])
100        if !people.isEmpty {
101            Section(profile.isOrg ? "Members" : "Organizations") {
102                ForEach(people) { person in
103                    NavigationLink(value: RepoRoute.profile(person.name)) {
104                        HStack {
105                            Label(person.name, systemImage: profile.isOrg
106                                  ? "person.crop.circle" : "building.2")
107                                .font(.gbSans(.subheadline))
108                            Spacer()
109                            if let role = person.role, !role.isEmpty {
110                                GBChip(role, .secondary)
111                            }
112                        }
113                    }
114                }
115            }
116        }
117    }
118
119    @ViewBuilder
120    private func activitySection(_ profile: ProfileViewModel.Profile) -> some View {
121        if let activity = profile.activity, !activity.isEmpty {
122            Section {
123                ActivityGraph(days: activity)
124            } header: {
125                Text("Activity")
126            } footer: {
127                Text("\(profile.activityTotal) contributions in the last year.")
128            }
129        }
130    }
131
132    private func reposSection(_ profile: ProfileViewModel.Profile) -> some View {
133        Section("Repositories \(profile.repos.count)") {
134            if profile.repos.isEmpty {
135                Text("No visible repositories.")
136                    .font(.gbSans(.subheadline))
137                    .foregroundStyle(.secondary)
138            } else {
139                ForEach(profile.repos) { repo in
140                    NavigationLink(value: RepoRoute.repo(repo.path)) {
141                        VStack(alignment: .leading, spacing: 3) {
142                            HStack(spacing: 6) {
143                                Text(repo.name)
144                                    .font(.gbSans(.subheadline).weight(.medium))
145                                if repo.isPrivate {
146                                    Image(systemName: "lock.fill")
147                                        .font(.gbSans(.caption2))
148                                        .foregroundStyle(.secondary)
149                                }
150                                if repo.isArchived {
151                                    GBChip("archived", .secondary)
152                                }
153                            }
154                            if let description = repo.description, !description.isEmpty {
155                                Text(description)
156                                    .font(.gbSans(.caption))
157                                    .foregroundStyle(.secondary)
158                                    .lineLimit(2)
159                            }
160                            if let topics = repo.topics, !topics.isEmpty {
161                                ScrollView(.horizontal, showsIndicators: false) {
162                                    HStack(spacing: 6) {
163                                        ForEach(topics, id: \.self) { topic in
164                                            GBChip(topic, .gbAccent)
165                                        }
166                                    }
167                                }
168                            }
169                            if !repo.meta.isEmpty {
170                                Text(repo.meta)
171                                    .font(.gbSans(.caption2))
172                                    .foregroundStyle(.secondary)
173                                    .lineLimit(1)
174                            }
175                        }
176                    }
177                }
178            }
179        }
180    }
181}