krz/hutch

an ios client for sourcehut

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

v2.10.2: 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}