gitbay/Views/Discovery/ProfileView.swift
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}