diff options
| author | Christian Cleberg <[email protected]> | 2026-04-02 00:17:30 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-02 00:17:30 -0500 |
| commit | 7f79cadf99ecf014834855e2e4a5c7d60dd709ac (patch) | |
| tree | e1435c08b416390064e0d2772d6a7703a1737db1 /Hutch | |
| parent | cd19a6fb24ebd5550068a28c0daefd07697bebda (diff) | |
| download | hutch-2.9.0.tar.gz hutch-2.9.0.tar.bz2 hutch-2.9.0.zip | |
feat: browse user repositories and trackers from profilev2.9.0
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/Views/Lookup/UserProfileView.swift | 98 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserProfileViewModel.swift | 151 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserRepositoriesView.swift | 28 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserTrackersView.swift | 28 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 19 |
5 files changed, 314 insertions, 10 deletions
diff --git a/Hutch/Views/Lookup/UserProfileView.swift b/Hutch/Views/Lookup/UserProfileView.swift index 4c91244..dc9197c 100644 --- a/Hutch/Views/Lookup/UserProfileView.swift +++ b/Hutch/Views/Lookup/UserProfileView.swift @@ -1,7 +1,10 @@ import SwiftUI struct UserProfileView: View { + @Environment(AppState.self) private var appState + let user: User + @State private var profileViewModel: UserProfileViewModel? private static let iso8601Formatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() @@ -86,10 +89,70 @@ struct UserProfileView: View { } } } + + if let viewModel = profileViewModel { + Section { + if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty { + ProgressView() + } else if viewModel.repositories.isEmpty { + Text("No public repositories.") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.repositories.prefix(4)) { repo in + NavigationLink { + RepositoryDetailView(repository: repo) + } label: { + RepositoryRowView(repository: repo, buildStatus: .none) + } + } + if viewModel.repositories.count > 4 { + NavigationLink("See All") { + UserRepositoriesView(viewModel: viewModel) + } + } + } + } header: { + Text("Repositories") + } + + Section { + if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty { + ProgressView() + } else if viewModel.trackers.isEmpty { + Text("No public trackers.") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.trackers.prefix(4)) { tracker in + NavigationLink { + TicketListView(tracker: tracker) + } label: { + UserProfileTrackerRowView(tracker: tracker) + } + } + if viewModel.trackers.count > 4 { + NavigationLink("See All") { + UserTrackersView(viewModel: viewModel) + } + } + } + } header: { + Text("Trackers") + } + } } .listStyle(.insetGrouped) .navigationTitle(user.canonicalName) .navigationBarTitleDisplayMode(.inline) + .task { + let owner = user.canonicalName.hasPrefix("~") + ? String(user.canonicalName.dropFirst()) + : user.canonicalName + let vm = UserProfileViewModel(ownerUsername: owner, client: appState.client) + profileViewModel = vm + async let repos: () = vm.loadRepositories() + async let trackers: () = vm.loadTrackers() + _ = await (repos, trackers) + } } private func formattedTimestamp(_ value: String) -> String { @@ -100,3 +163,38 @@ struct UserProfileView: View { 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) + } +} diff --git a/Hutch/Views/Lookup/UserProfileViewModel.swift b/Hutch/Views/Lookup/UserProfileViewModel.swift new file mode 100644 index 0000000..bec79fb --- /dev/null +++ b/Hutch/Views/Lookup/UserProfileViewModel.swift @@ -0,0 +1,151 @@ +import Foundation + +@Observable +@MainActor +final class UserProfileViewModel { + private(set) var repositories: [RepositorySummary] = [] + private(set) var trackers: [TrackerSummary] = [] + private(set) var isLoadingRepositories = false + private(set) var isLoadingTrackers = false + var repositoriesError: String? + var trackersError: String? + + private let client: SRHTClient + let ownerUsername: String + + init(ownerUsername: String, client: SRHTClient) { + self.ownerUsername = ownerUsername + self.client = client + } + + func loadRepositories() async { + isLoadingRepositories = true + repositoriesError = nil + defer { isLoadingRepositories = false } + + do { + let result = try await client.execute( + service: .git, + query: Self.repositoriesQuery, + variables: ["owner": ownerUsername], + responseType: UserRepositoriesResponse.self + ) + repositories = result.user.repositories.results.map { $0.repositorySummary(service: .git) } + } catch { + repositoriesError = error.userFacingMessage + } + } + + func loadTrackers() async { + isLoadingTrackers = true + trackersError = nil + defer { isLoadingTrackers = false } + + do { + let result = try await client.execute( + service: .todo, + query: Self.trackersQuery, + variables: ["owner": ownerUsername], + responseType: UserTrackersResponse.self + ) + trackers = result.user.trackers.results + } catch { + trackersError = error.userFacingMessage + } + } + + private static let repositoriesQuery = """ + query userRepositories($owner: String!) { + user(username: $owner) { + repositories { + results { + id + rid + name + description + visibility + updated + owner { canonicalName } + HEAD { name target } + } + cursor + } + } + } + """ + + private static let trackersQuery = """ + query userTrackers($owner: String!) { + user(username: $owner) { + trackers { + results { + id + rid + name + description + visibility + updated + owner { canonicalName } + } + cursor + } + } + } + """ + + private struct UserRepositoriesResponse: Decodable, Sendable { + let user: UserRepositoriesContainer + } + + private struct UserRepositoriesContainer: Decodable, Sendable { + let repositories: RepositoriesPage + } + + private struct RepositoriesPage: Decodable, Sendable { + let results: [RepositoryPayload] + let cursor: String? + } + + private struct RepositoryPayload: Decodable, Sendable { + let id: Int + let rid: String + let name: String + let description: String? + let visibility: Visibility + let updated: Date + let owner: Entity + let head: Reference? + + enum CodingKeys: String, CodingKey { + case id, rid, name, description, visibility, updated, owner + case head = "HEAD" + } + + func repositorySummary(service: SRHTService) -> RepositorySummary { + RepositorySummary( + id: id, + rid: rid, + service: service, + name: name, + description: description, + visibility: visibility, + updated: updated, + owner: owner, + head: head + ) + } + } + + private struct UserTrackersResponse: Decodable, Sendable { + let user: UserTrackersContainer + } + + private struct UserTrackersContainer: Decodable, Sendable { + let trackers: TrackersPage + } + + private struct TrackersPage: Decodable, Sendable { + let results: [TrackerSummary] + let cursor: String? + } +} diff --git a/Hutch/Views/Lookup/UserRepositoriesView.swift b/Hutch/Views/Lookup/UserRepositoriesView.swift new file mode 100644 index 0000000..52d3c15 --- /dev/null +++ b/Hutch/Views/Lookup/UserRepositoriesView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct UserRepositoriesView: View { + let viewModel: UserProfileViewModel + + var body: some View { + List { + ForEach(viewModel.repositories) { repo in + NavigationLink { + RepositoryDetailView(repository: repo) + } label: { + RepositoryRowView(repository: repo, buildStatus: .none) + } + } + } + .listStyle(.plain) + .navigationTitle("Repositories") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty { + SRHTLoadingStateView(message: "Loading repositories…") + } + } + .refreshable { + await viewModel.loadRepositories() + } + } +} diff --git a/Hutch/Views/Lookup/UserTrackersView.swift b/Hutch/Views/Lookup/UserTrackersView.swift new file mode 100644 index 0000000..17916da --- /dev/null +++ b/Hutch/Views/Lookup/UserTrackersView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct UserTrackersView: View { + let viewModel: UserProfileViewModel + + var body: some View { + List { + ForEach(viewModel.trackers) { tracker in + NavigationLink { + TicketListView(tracker: tracker) + } label: { + UserProfileTrackerRowView(tracker: tracker) + } + } + } + .listStyle(.plain) + .navigationTitle("Trackers") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty { + SRHTLoadingStateView(message: "Loading trackers…") + } + } + .refreshable { + await viewModel.loadTrackers() + } + } +} diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift index a1cea6f..4a48377 100644 --- a/Hutch/Views/Tickets/TicketListView.swift +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -190,7 +190,15 @@ struct TicketListView: View { // Tickets ForEach(viewModel.filteredTickets) { ticket in - NavigationLink(value: ticket) { + NavigationLink { + TicketDetailView( + ownerUsername: String(tracker.owner.canonicalName.dropFirst()), + trackerName: tracker.name, + trackerId: tracker.id, + trackerRid: tracker.rid, + ticketId: ticket.id + ) + } label: { TicketRowView(ticket: ticket) } .swipeActions(edge: .leading, allowsFullSwipe: true) { @@ -250,15 +258,6 @@ struct TicketListView: View { .refreshable { await viewModel.loadTickets() } - .navigationDestination(for: TicketSummary.self) { ticket in - TicketDetailView( - ownerUsername: String(tracker.owner.canonicalName.dropFirst()), - trackerName: tracker.name, - trackerId: tracker.id, - trackerRid: tracker.rid, - ticketId: ticket.id - ) - } } private var trackerActionsMenu: some View { |
