import SwiftUI struct UserProfileView: View { @Environment(AppState.self) private var appState @Environment(\.isAMOLEDTheme) private var isAMOLED @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true let user: User @State private var profileViewModel: UserProfileViewModel? @State private var pinChangeCount = 0 private static let iso8601Formatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] formatter.timeZone = TimeZone(secondsFromGMT: 0) return formatter }() private var currentUserKey: String? { appState.currentUser?.canonicalName } private var isPinnedToHome: Bool { _ = pinChangeCount guard let currentUserKey else { return false } return HomePinStore.isPinned(.user(user), for: currentUserKey, defaults: appState.accountDefaults) } var body: some View { List { if let avatarURL = user.avatar.flatMap(URL.init(string:)) { Section { HStack { Spacer() AsyncImage(url: avatarURL) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() case .failure, .empty: Image(systemName: "person.crop.circle.fill") .resizable() .scaledToFit() .foregroundStyle(.secondary) .padding(20) @unknown default: EmptyView() } } .frame(width: 96, height: 96) .clipShape(Circle()) .overlay { Circle() .stroke(Color.secondary.opacity(0.2), lineWidth: 1) } Spacer() } .listRowBackground(isAMOLED ? Color.black : Color.clear) } } Section { LabeledContent("Username", value: user.username) .themedRow() LabeledContent("Canonical Name", value: user.canonicalName) .themedRow() if let userType = user.userType { LabeledContent("User Type", value: userType) .themedRow() } if let pronouns = user.pronouns { LabeledContent("Pronouns", value: pronouns) .themedRow() } if let suspensionNotice = user.suspensionNotice { LabeledContent("Suspension Notice", value: suspensionNotice) .themedRow() } } Section { LabeledContent("Email", value: user.email) .themedRow() if let urlString = user.url, let url = URL(string: urlString) { LabeledContent("URL") { Link(urlString, destination: url) } .themedRow() } if let location = user.location { LabeledContent("Location", value: location) .themedRow() } } if let bio = user.bio, !bio.isEmpty { Section("Bio") { Text(profileBioAttributedString(bio)) .frame(maxWidth: .infinity, alignment: .leading) .tint(.accentColor) .textSelection(.enabled) .themedRow() } } if user.created != nil || user.updated != nil { Section { if let created = user.created { LabeledContent("Joined", value: formattedTimestamp(created)) .themedRow() } if let updated = user.updated { LabeledContent("Updated", value: formattedTimestamp(updated)) .themedRow() } } } if let viewModel = profileViewModel { if contributionGraphsEnabled { Section { ContributionProfileCard( actor: viewModel.actor, weeks: viewModel.contributionCalendar.map { ContributionCalendarLayout.weekColumns(from: $0.days) } ?? [], stats: viewModel.contributionStats, isLoading: viewModel.isLoadingContributions, error: viewModel.contributionsError ?? viewModel.contributionStatusText, isIndexedButEmpty: viewModel.isContributionActivityIndexedButEmpty ) .themedRow() } } Section { if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty { ProgressView() .themedRow() } else if viewModel.repositories.isEmpty { Text("No public repositories.") .foregroundStyle(.secondary) .themedRow() } else { ForEach(viewModel.repositories.prefix(4)) { repo in NavigationLink { RepositoryDetailView(repository: repo) { updatedRepository in viewModel.updateRepository(updatedRepository) } } label: { RepositoryRowView(repository: repo, buildStatus: .none) } } .themedRow() if viewModel.repositories.count > 4 { NavigationLink("See All") { UserRepositoriesView(viewModel: viewModel) } .themedRow() } } } header: { Text("Repositories") } Section { if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty { ProgressView() .themedRow() } else if viewModel.trackers.isEmpty { Text("No public trackers.") .foregroundStyle(.secondary) .themedRow() } else { ForEach(viewModel.trackers.prefix(4)) { tracker in NavigationLink { TicketListView(tracker: tracker) } label: { UserProfileTrackerRowView(tracker: tracker) } } .themedRow() if viewModel.trackers.count > 4 { NavigationLink("See All") { UserTrackersView(viewModel: viewModel) } .themedRow() } } } header: { Text("Trackers") } } } .themedList() .listStyle(.insetGrouped) .navigationTitle(user.canonicalName) .navigationBarTitleDisplayMode(.inline) .toolbar { if currentUserKey != nil { ToolbarItem(placement: .topBarTrailing) { Button { togglePinnedState() } label: { Image(systemName: isPinnedToHome ? "pin.fill" : "pin") } .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home") } } } .task(id: user.canonicalName) { let owner = user.canonicalName.hasPrefix("~") ? String(user.canonicalName.dropFirst()) : user.canonicalName let actor = user.canonicalName.hasPrefix("~") ? user.canonicalName : "~\(user.canonicalName)" let vm: UserProfileViewModel if let existingViewModel = profileViewModel, existingViewModel.actor == actor, existingViewModel.ownerUsername == owner { vm = existingViewModel } else { let newViewModel = UserProfileViewModel( ownerUsername: owner, actor: actor, client: appState.client, statsService: HutchStatsService( configuration: appState.configuration, currentActor: appState.currentUser?.canonicalName ) ) profileViewModel = newViewModel vm = newViewModel } async let repos: () = vm.loadRepositories() async let trackers: () = vm.loadTrackers() if contributionGraphsEnabled { async let contributions: () = vm.loadContributions() _ = await (repos, trackers, contributions) } else { _ = await (repos, trackers) } } } private func togglePinnedState() { guard let currentUserKey else { return } HomePinStore.togglePin(.user(user), for: currentUserKey, defaults: appState.accountDefaults) pinChangeCount += 1 } private func formattedTimestamp(_ value: String) -> String { guard let date = Self.iso8601Formatter.date(from: value) else { return value } return date.formatted(date: .abbreviated, time: .shortened) } } struct UserProfileTrackerRowView: View { let tracker: TrackerSummary var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(tracker.name) .font(.subheadline.weight(.medium)) Spacer() VisibilityBadge(visibility: tracker.visibility) } if let owner = tracker.owner.canonicalName.split(separator: "~").last { Text("~\(owner)") .font(.caption) .foregroundStyle(.secondary) } if let description = tracker.description, !description.isEmpty { Text(description) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } Text(tracker.updated.relativeDescription) .font(.caption2) .foregroundStyle(.tertiary) } .padding(.vertical, 2) } }