krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.13.0: Hutch/Views/Lookup/UserProfileView.swift · raw
1import SwiftUI
2
3struct UserProfileView: View {
4 @Environment(AppState.self) private var appState
5 @AppStorage(AppStorageKeys.contributionGraphsEnabled) private var contributionGraphsEnabled = true
6
7 let user: User
8 @State private var profileViewModel: UserProfileViewModel?
9
10 private static let iso8601Formatter: ISO8601DateFormatter = {
11 let formatter = ISO8601DateFormatter()
12 formatter.formatOptions = [.withInternetDateTime]
13 formatter.timeZone = TimeZone(secondsFromGMT: 0)
14 return formatter
15 }()
16
17 var body: some View {
18 List {
19 if let avatarURL = user.avatar.flatMap(URL.init(string:)) {
20 Section {
21 HStack {
22 Spacer()
23 AsyncImage(url: avatarURL) { phase in
24 switch phase {
25 case .success(let image):
26 image
27 .resizable()
28 .scaledToFill()
29 case .failure, .empty:
30 Image(systemName: "person.crop.circle.fill")
31 .resizable()
32 .scaledToFit()
33 .foregroundStyle(.secondary)
34 .padding(20)
35 @unknown default:
36 EmptyView()
37 }
38 }
39 .frame(width: 96, height: 96)
40 .clipShape(Circle())
41 .overlay {
42 Circle()
43 .stroke(Color.secondary.opacity(0.2), lineWidth: 1)
44 }
45 Spacer()
46 }
47 .listRowBackground(Color.clear)
48 }
49 }
50
51 Section {
52 LabeledContent("Username", value: user.username)
53 LabeledContent("Canonical Name", value: user.canonicalName)
54 if let userType = user.userType {
55 LabeledContent("User Type", value: userType)
56 }
57 if let pronouns = user.pronouns {
58 LabeledContent("Pronouns", value: pronouns)
59 }
60 if let suspensionNotice = user.suspensionNotice {
61 LabeledContent("Suspension Notice", value: suspensionNotice)
62 }
63 }
64
65 Section {
66 LabeledContent("Email", value: user.email)
67 if let urlString = user.url, let url = URL(string: urlString) {
68 LabeledContent("URL") {
69 Link(urlString, destination: url)
70 }
71 }
72 if let location = user.location {
73 LabeledContent("Location", value: location)
74 }
75 }
76
77 if let bio = user.bio {
78 Section("Bio") {
79 Text(bio)
80 }
81 }
82
83 if user.created != nil || user.updated != nil {
84 Section {
85 if let created = user.created {
86 LabeledContent("Joined", value: formattedTimestamp(created))
87 }
88 if let updated = user.updated {
89 LabeledContent("Updated", value: formattedTimestamp(updated))
90 }
91 }
92 }
93
94 if let viewModel = profileViewModel {
95 if contributionGraphsEnabled {
96 Section {
97 ContributionProfileCard(
98 actor: viewModel.actor,
99 weeks: viewModel.contributionCalendar.map {
100 ContributionCalendarLayout.weekColumns(from: $0.days)
101 } ?? [],
102 stats: viewModel.contributionStats,
103 isLoading: viewModel.isLoadingContributions,
104 error: viewModel.contributionsError ?? viewModel.contributionStatusText,
105 isIndexedButEmpty: viewModel.isContributionActivityIndexedButEmpty
106 )
107 }
108 }
109
110 Section {
111 if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty {
112 ProgressView()
113 } else if viewModel.repositories.isEmpty {
114 Text("No public repositories.")
115 .foregroundStyle(.secondary)
116 } else {
117 ForEach(viewModel.repositories.prefix(4)) { repo in
118 NavigationLink {
119 RepositoryDetailView(repository: repo)
120 } label: {
121 RepositoryRowView(repository: repo, buildStatus: .none)
122 }
123 }
124 if viewModel.repositories.count > 4 {
125 NavigationLink("See All") {
126 UserRepositoriesView(viewModel: viewModel)
127 }
128 }
129 }
130 } header: {
131 Text("Repositories")
132 }
133
134 Section {
135 if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty {
136 ProgressView()
137 } else if viewModel.trackers.isEmpty {
138 Text("No public trackers.")
139 .foregroundStyle(.secondary)
140 } else {
141 ForEach(viewModel.trackers.prefix(4)) { tracker in
142 NavigationLink {
143 TicketListView(tracker: tracker)
144 } label: {
145 UserProfileTrackerRowView(tracker: tracker)
146 }
147 }
148 if viewModel.trackers.count > 4 {
149 NavigationLink("See All") {
150 UserTrackersView(viewModel: viewModel)
151 }
152 }
153 }
154 } header: {
155 Text("Trackers")
156 }
157 }
158 }
159 .listStyle(.insetGrouped)
160 .navigationTitle(user.canonicalName)
161 .navigationBarTitleDisplayMode(.inline)
162 .task(id: user.canonicalName) {
163 let owner = user.canonicalName.hasPrefix("~")
164 ? String(user.canonicalName.dropFirst())
165 : user.canonicalName
166 let actor = user.canonicalName.hasPrefix("~") ? user.canonicalName : "~\(user.canonicalName)"
167
168 let vm: UserProfileViewModel
169 if let existingViewModel = profileViewModel,
170 existingViewModel.actor == actor,
171 existingViewModel.ownerUsername == owner {
172 vm = existingViewModel
173 } else {
174 let newViewModel = UserProfileViewModel(
175 ownerUsername: owner,
176 actor: actor,
177 client: appState.client,
178 statsService: HutchStatsService(configuration: appState.configuration)
179 )
180 profileViewModel = newViewModel
181 vm = newViewModel
182 }
183
184 async let repos: () = vm.loadRepositories()
185 async let trackers: () = vm.loadTrackers()
186 if contributionGraphsEnabled {
187 async let contributions: () = vm.loadContributions()
188 _ = await (repos, trackers, contributions)
189 } else {
190 _ = await (repos, trackers)
191 }
192 }
193 }
194
195 private func formattedTimestamp(_ value: String) -> String {
196 guard let date = Self.iso8601Formatter.date(from: value) else {
197 return value
198 }
199
200 return date.formatted(date: .abbreviated, time: .shortened)
201 }
202}
203
204struct UserProfileTrackerRowView: View {
205 let tracker: TrackerSummary
206
207 var body: some View {
208 VStack(alignment: .leading, spacing: 4) {
209 HStack {
210 Text(tracker.name)
211 .font(.subheadline.weight(.medium))
212
213 Spacer()
214
215 VisibilityBadge(visibility: tracker.visibility)
216 }
217
218 if let owner = tracker.owner.canonicalName.split(separator: "~").last {
219 Text("~\(owner)")
220 .font(.caption)
221 .foregroundStyle(.secondary)
222 }
223
224 if let description = tracker.description, !description.isEmpty {
225 Text(description)
226 .font(.caption)
227 .foregroundStyle(.secondary)
228 .lineLimit(2)
229 }
230
231 Text(tracker.updated.relativeDescription)
232 .font(.caption2)
233 .foregroundStyle(.tertiary)
234 }
235 .padding(.vertical, 2)
236 }
237}