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