gitbay/Views/Discovery/ProfileView.swift
143 lines · 5710 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 activitySection(profile)
24 reposSection(profile)
25 if profile.isOrg {
26 Section {
27 NavigationLink(value: OrgRoute.org(profile.name)) {
28 Label("Members & Teams", systemImage: "person.3")
29 }
30 }
31 }
32 }
33 }
34 .overlay { LoadStateOverlay(state: model.state) }
35 .navigationTitle(title ?? model.name)
36 .navigationBarTitleDisplayMode(.inline)
37 .task { await model.load() }
38 .refreshable { await model.load() }
39 }
40
41 // MARK: - Sections
42
43 private func header(_ profile: ProfileViewModel.Profile) -> some View {
44 Section {
45 VStack(alignment: .leading, spacing: 8) {
46 HStack(spacing: 8) {
47 Image(systemName: profile.isOrg ? "building.2" : "person.crop.circle")
48 .font(.gbSans(.title2))
49 .foregroundStyle(Color.gbAccent)
50 Text(profile.name)
51 .font(.gbSans(.title3).weight(.semibold))
52 GBChip(profile.kind, .secondary)
53 }
54 if let description = profile.description, !description.isEmpty {
55 Text(description)
56 .font(.gbSans(.subheadline))
57 .foregroundStyle(.secondary)
58 }
59 if let website = profile.website, !website.isEmpty,
60 let url = URL(string: website) {
61 Link(destination: url) {
62 Label(website, systemImage: "link")
63 .font(.gbSans(.caption))
64 .lineLimit(1)
65 }
66 }
67 }
68 .padding(.vertical, 4)
69 }
70 }
71
72 /// A user's organizations, or an organization's members — either way,
73 /// who they work with, each one openable.
74 @ViewBuilder
75 private func peopleSection(_ profile: ProfileViewModel.Profile) -> some View {
76 let people = profile.isOrg ? (profile.members ?? []) : (profile.orgs ?? [])
77 if !people.isEmpty {
78 Section(profile.isOrg ? "Members" : "Organizations") {
79 ForEach(people) { person in
80 NavigationLink(value: RepoRoute.profile(person.name)) {
81 HStack {
82 Label(person.name, systemImage: profile.isOrg
83 ? "person.crop.circle" : "building.2")
84 .font(.gbSans(.subheadline))
85 Spacer()
86 if let role = person.role, !role.isEmpty {
87 GBChip(role, .secondary)
88 }
89 }
90 }
91 }
92 }
93 }
94 }
95
96 @ViewBuilder
97 private func activitySection(_ profile: ProfileViewModel.Profile) -> some View {
98 if let activity = profile.activity, !activity.isEmpty {
99 Section {
100 ActivityGraph(days: activity)
101 } header: {
102 Text("Activity")
103 } footer: {
104 Text("\(profile.activityTotal) contributions in the last year.")
105 }
106 }
107 }
108
109 private func reposSection(_ profile: ProfileViewModel.Profile) -> some View {
110 Section("Repositories \(profile.repos.count)") {
111 if profile.repos.isEmpty {
112 Text("No visible repositories.")
113 .font(.gbSans(.subheadline))
114 .foregroundStyle(.secondary)
115 } else {
116 ForEach(profile.repos) { repo in
117 NavigationLink(value: RepoRoute.repo(repo.path)) {
118 VStack(alignment: .leading, spacing: 3) {
119 HStack(spacing: 6) {
120 Text(repo.name)
121 .font(.gbSans(.subheadline).weight(.medium))
122 if repo.isPrivate {
123 Image(systemName: "lock.fill")
124 .font(.gbSans(.caption2))
125 .foregroundStyle(.secondary)
126 }
127 if repo.isArchived {
128 GBChip("archived", .secondary)
129 }
130 }
131 if let description = repo.description, !description.isEmpty {
132 Text(description)
133 .font(.gbSans(.caption))
134 .foregroundStyle(.secondary)
135 .lineLimit(2)
136 }
137 }
138 }
139 }
140 }
141 }
142 }
143}