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