krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.15.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}