summaryrefslogtreecommitdiff
path: root/Hutch/Views/Lookup
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-02 00:17:30 -0500
committerChristian Cleberg <[email protected]>2026-04-02 00:17:30 -0500
commit7f79cadf99ecf014834855e2e4a5c7d60dd709ac (patch)
treee1435c08b416390064e0d2772d6a7703a1737db1 /Hutch/Views/Lookup
parentcd19a6fb24ebd5550068a28c0daefd07697bebda (diff)
downloadhutch-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/Views/Lookup')
-rw-r--r--Hutch/Views/Lookup/UserProfileView.swift98
-rw-r--r--Hutch/Views/Lookup/UserProfileViewModel.swift151
-rw-r--r--Hutch/Views/Lookup/UserRepositoriesView.swift28
-rw-r--r--Hutch/Views/Lookup/UserTrackersView.swift28
4 files changed, 305 insertions, 0 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()
+ }
+ }
+}