Commit e2749ef0c0
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -539,7 +539,7 @@ | |||
| 539 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 539 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 540 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 540 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 541 | CODE_SIGN_STYLE = Automatic; | 541 | CODE_SIGN_STYLE = Automatic; |
| 542 | CURRENT_PROJECT_VERSION = 21; | 542 | CURRENT_PROJECT_VERSION = 22; |
| 543 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 543 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 544 | ENABLE_PREVIEWS = YES; | 544 | ENABLE_PREVIEWS = YES; |
| 545 | GENERATE_INFOPLIST_FILE = YES; | 545 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -556,7 +556,7 @@ | |||
| 556 | "$(inherited)", | 556 | "$(inherited)", |
| 557 | "@executable_path/Frameworks", | 557 | "@executable_path/Frameworks", |
| 558 | ); | 558 | ); |
| 559 | MARKETING_VERSION = 2.8.2; | 559 | MARKETING_VERSION = 2.9.0; |
| 560 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 560 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 561 | PRODUCT_NAME = "$(TARGET_NAME)"; | 561 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 562 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 562 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -576,7 +576,7 @@ | |||
| 576 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 576 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 577 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 577 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 578 | CODE_SIGN_STYLE = Automatic; | 578 | CODE_SIGN_STYLE = Automatic; |
| 579 | CURRENT_PROJECT_VERSION = 21; | 579 | CURRENT_PROJECT_VERSION = 22; |
| 580 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 580 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 581 | ENABLE_PREVIEWS = YES; | 581 | ENABLE_PREVIEWS = YES; |
| 582 | GENERATE_INFOPLIST_FILE = YES; | 582 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -593,7 +593,7 @@ | |||
| 593 | "$(inherited)", | 593 | "$(inherited)", |
| 594 | "@executable_path/Frameworks", | 594 | "@executable_path/Frameworks", |
| 595 | ); | 595 | ); |
| 596 | MARKETING_VERSION = 2.8.2; | 596 | MARKETING_VERSION = 2.9.0; |
| 597 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 597 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 598 | PRODUCT_NAME = "$(TARGET_NAME)"; | 598 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 599 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 599 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -656,7 +656,7 @@ | |||
| 656 | APPLICATION_EXTENSION_API_ONLY = YES; | 656 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 657 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 657 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 658 | CODE_SIGN_STYLE = Automatic; | 658 | CODE_SIGN_STYLE = Automatic; |
| 659 | CURRENT_PROJECT_VERSION = 21; | 659 | CURRENT_PROJECT_VERSION = 22; |
| 660 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 660 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 661 | GENERATE_INFOPLIST_FILE = NO; | 661 | GENERATE_INFOPLIST_FILE = NO; |
| 662 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 662 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -666,7 +666,7 @@ | |||
| 666 | "@executable_path/Frameworks", | 666 | "@executable_path/Frameworks", |
| 667 | "@executable_path/../../Frameworks", | 667 | "@executable_path/../../Frameworks", |
| 668 | ); | 668 | ); |
| 669 | MARKETING_VERSION = 2.8.2; | 669 | MARKETING_VERSION = 2.8.3; |
| 670 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 670 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 671 | PRODUCT_NAME = "$(TARGET_NAME)"; | 671 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 672 | SKIP_INSTALL = YES; | 672 | SKIP_INSTALL = YES; |
| @@ -685,7 +685,7 @@ | |||
| 685 | APPLICATION_EXTENSION_API_ONLY = YES; | 685 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 686 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 686 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 687 | CODE_SIGN_STYLE = Automatic; | 687 | CODE_SIGN_STYLE = Automatic; |
| 688 | CURRENT_PROJECT_VERSION = 21; | 688 | CURRENT_PROJECT_VERSION = 22; |
| 689 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 689 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 690 | GENERATE_INFOPLIST_FILE = NO; | 690 | GENERATE_INFOPLIST_FILE = NO; |
| 691 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 691 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -695,7 +695,7 @@ | |||
| 695 | "@executable_path/Frameworks", | 695 | "@executable_path/Frameworks", |
| 696 | "@executable_path/../../Frameworks", | 696 | "@executable_path/../../Frameworks", |
| 697 | ); | 697 | ); |
| 698 | MARKETING_VERSION = 2.8.2; | 698 | MARKETING_VERSION = 2.8.3; |
| 699 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 699 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 700 | PRODUCT_NAME = "$(TARGET_NAME)"; | 700 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 701 | SKIP_INSTALL = YES; | 701 | SKIP_INSTALL = YES; |
Hutch/Views/Lookup/UserProfileView.swift +98
| @@ -1,7 +1,10 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | struct UserProfileView: View { | 3 | struct UserProfileView: View { |
| 4 | @Environment(AppState.self) private var appState | ||
| 5 | |||
| 4 | let user: User | 6 | let user: User |
| 7 | @State private var profileViewModel: UserProfileViewModel? | ||
| 5 | 8 | ||
| 6 | private static let iso8601Formatter: ISO8601DateFormatter = { | 9 | private static let iso8601Formatter: ISO8601DateFormatter = { |
| 7 | let formatter = ISO8601DateFormatter() | 10 | let formatter = ISO8601DateFormatter() |
| @@ -86,10 +89,70 @@ struct UserProfileView: View { | |||
| 86 | } | 89 | } |
| 87 | } | 90 | } |
| 88 | } | 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 | } | ||
| 89 | } | 142 | } |
| 90 | .listStyle(.insetGrouped) | 143 | .listStyle(.insetGrouped) |
| 91 | .navigationTitle(user.canonicalName) | 144 | .navigationTitle(user.canonicalName) |
| 92 | .navigationBarTitleDisplayMode(.inline) | 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 | } | ||
| 93 | } | 156 | } |
| 94 | 157 | ||
| 95 | private func formattedTimestamp(_ value: String) -> String { | 158 | private func formattedTimestamp(_ value: String) -> String { |
| @@ -100,3 +163,38 @@ struct UserProfileView: View { | |||
| 100 | return date.formatted(date: .abbreviated, time: .shortened) | 163 | return date.formatted(date: .abbreviated, time: .shortened) |
| 101 | } | 164 | } |
| 102 | } | 165 | } |
| 166 | |||
| 167 | struct 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 | } | ||
Hutch/Views/Lookup/UserProfileViewModel.swift added +151
| @@ -0,0 +1,151 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | @Observable | ||
| 4 | @MainActor | ||
| 5 | final class UserProfileViewModel { | ||
| 6 | private(set) var repositories: [RepositorySummary] = [] | ||
| 7 | private(set) var trackers: [TrackerSummary] = [] | ||
| 8 | private(set) var isLoadingRepositories = false | ||
| 9 | private(set) var isLoadingTrackers = false | ||
| 10 | var repositoriesError: String? | ||
| 11 | var trackersError: String? | ||
| 12 | |||
| 13 | private let client: SRHTClient | ||
| 14 | let ownerUsername: String | ||
| 15 | |||
| 16 | init(ownerUsername: String, client: SRHTClient) { | ||
| 17 | self.ownerUsername = ownerUsername | ||
| 18 | self.client = client | ||
| 19 | } | ||
| 20 | |||
| 21 | func loadRepositories() async { | ||
| 22 | isLoadingRepositories = true | ||
| 23 | repositoriesError = nil | ||
| 24 | defer { isLoadingRepositories = false } | ||
| 25 | |||
| 26 | do { | ||
| 27 | let result = try await client.execute( | ||
| 28 | service: .git, | ||
| 29 | query: Self.repositoriesQuery, | ||
| 30 | variables: ["owner": ownerUsername], | ||
| 31 | responseType: UserRepositoriesResponse.self | ||
| 32 | ) | ||
| 33 | repositories = result.user.repositories.results.map { $0.repositorySummary(service: .git) } | ||
| 34 | } catch { | ||
| 35 | repositoriesError = error.userFacingMessage | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | func loadTrackers() async { | ||
| 40 | isLoadingTrackers = true | ||
| 41 | trackersError = nil | ||
| 42 | defer { isLoadingTrackers = false } | ||
| 43 | |||
| 44 | do { | ||
| 45 | let result = try await client.execute( | ||
| 46 | service: .todo, | ||
| 47 | query: Self.trackersQuery, | ||
| 48 | variables: ["owner": ownerUsername], | ||
| 49 | responseType: UserTrackersResponse.self | ||
| 50 | ) | ||
| 51 | trackers = result.user.trackers.results | ||
| 52 | } catch { | ||
| 53 | trackersError = error.userFacingMessage | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | private static let repositoriesQuery = """ | ||
| 58 | query userRepositories($owner: String!) { | ||
| 59 | user(username: $owner) { | ||
| 60 | repositories { | ||
| 61 | results { | ||
| 62 | id | ||
| 63 | rid | ||
| 64 | name | ||
| 65 | description | ||
| 66 | visibility | ||
| 67 | updated | ||
| 68 | owner { canonicalName } | ||
| 69 | HEAD { name target } | ||
| 70 | } | ||
| 71 | cursor | ||
| 72 | } | ||
| 73 | } | ||
| 74 | } | ||
| 75 | """ | ||
| 76 | |||
| 77 | private static let trackersQuery = """ | ||
| 78 | query userTrackers($owner: String!) { | ||
| 79 | user(username: $owner) { | ||
| 80 | trackers { | ||
| 81 | results { | ||
| 82 | id | ||
| 83 | rid | ||
| 84 | name | ||
| 85 | description | ||
| 86 | visibility | ||
| 87 | updated | ||
| 88 | owner { canonicalName } | ||
| 89 | } | ||
| 90 | cursor | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
| 94 | """ | ||
| 95 | |||
| 96 | private struct UserRepositoriesResponse: Decodable, Sendable { | ||
| 97 | let user: UserRepositoriesContainer | ||
| 98 | } | ||
| 99 | |||
| 100 | private struct UserRepositoriesContainer: Decodable, Sendable { | ||
| 101 | let repositories: RepositoriesPage | ||
| 102 | } | ||
| 103 | |||
| 104 | private struct RepositoriesPage: Decodable, Sendable { | ||
| 105 | let results: [RepositoryPayload] | ||
| 106 | let cursor: String? | ||
| 107 | } | ||
| 108 | |||
| 109 | private struct RepositoryPayload: Decodable, Sendable { | ||
| 110 | let id: Int | ||
| 111 | let rid: String | ||
| 112 | let name: String | ||
| 113 | let description: String? | ||
| 114 | let visibility: Visibility | ||
| 115 | let updated: Date | ||
| 116 | let owner: Entity | ||
| 117 | let head: Reference? | ||
| 118 | |||
| 119 | enum CodingKeys: String, CodingKey { | ||
| 120 | case id, rid, name, description, visibility, updated, owner | ||
| 121 | case head = "HEAD" | ||
| 122 | } | ||
| 123 | |||
| 124 | func repositorySummary(service: SRHTService) -> RepositorySummary { | ||
| 125 | RepositorySummary( | ||
| 126 | id: id, | ||
| 127 | rid: rid, | ||
| 128 | service: service, | ||
| 129 | name: name, | ||
| 130 | description: description, | ||
| 131 | visibility: visibility, | ||
| 132 | updated: updated, | ||
| 133 | owner: owner, | ||
| 134 | head: head | ||
| 135 | ) | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | private struct UserTrackersResponse: Decodable, Sendable { | ||
| 140 | let user: UserTrackersContainer | ||
| 141 | } | ||
| 142 | |||
| 143 | private struct UserTrackersContainer: Decodable, Sendable { | ||
| 144 | let trackers: TrackersPage | ||
| 145 | } | ||
| 146 | |||
| 147 | private struct TrackersPage: Decodable, Sendable { | ||
| 148 | let results: [TrackerSummary] | ||
| 149 | let cursor: String? | ||
| 150 | } | ||
| 151 | } | ||
Hutch/Views/Lookup/UserRepositoriesView.swift added +28
| @@ -0,0 +1,28 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct UserRepositoriesView: View { | ||
| 4 | let viewModel: UserProfileViewModel | ||
| 5 | |||
| 6 | var body: some View { | ||
| 7 | List { | ||
| 8 | ForEach(viewModel.repositories) { repo in | ||
| 9 | NavigationLink { | ||
| 10 | RepositoryDetailView(repository: repo) | ||
| 11 | } label: { | ||
| 12 | RepositoryRowView(repository: repo, buildStatus: .none) | ||
| 13 | } | ||
| 14 | } | ||
| 15 | } | ||
| 16 | .listStyle(.plain) | ||
| 17 | .navigationTitle("Repositories") | ||
| 18 | .navigationBarTitleDisplayMode(.inline) | ||
| 19 | .overlay { | ||
| 20 | if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty { | ||
| 21 | SRHTLoadingStateView(message: "Loading repositories…") | ||
| 22 | } | ||
| 23 | } | ||
| 24 | .refreshable { | ||
| 25 | await viewModel.loadRepositories() | ||
| 26 | } | ||
| 27 | } | ||
| 28 | } | ||
Hutch/Views/Lookup/UserTrackersView.swift added +28
| @@ -0,0 +1,28 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct UserTrackersView: View { | ||
| 4 | let viewModel: UserProfileViewModel | ||
| 5 | |||
| 6 | var body: some View { | ||
| 7 | List { | ||
| 8 | ForEach(viewModel.trackers) { tracker in | ||
| 9 | NavigationLink { | ||
| 10 | TicketListView(tracker: tracker) | ||
| 11 | } label: { | ||
| 12 | UserProfileTrackerRowView(tracker: tracker) | ||
| 13 | } | ||
| 14 | } | ||
| 15 | } | ||
| 16 | .listStyle(.plain) | ||
| 17 | .navigationTitle("Trackers") | ||
| 18 | .navigationBarTitleDisplayMode(.inline) | ||
| 19 | .overlay { | ||
| 20 | if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty { | ||
| 21 | SRHTLoadingStateView(message: "Loading trackers…") | ||
| 22 | } | ||
| 23 | } | ||
| 24 | .refreshable { | ||
| 25 | await viewModel.loadTrackers() | ||
| 26 | } | ||
| 27 | } | ||
| 28 | } | ||
Hutch/Views/Tickets/TicketListView.swift +9 −10
| @@ -190,7 +190,15 @@ struct TicketListView: View { | |||
| 190 | 190 | ||
| 191 | // Tickets | 191 | // Tickets |
| 192 | ForEach(viewModel.filteredTickets) { ticket in | 192 | ForEach(viewModel.filteredTickets) { ticket in |
| 193 | NavigationLink(value: ticket) { | 193 | NavigationLink { |
| 194 | TicketDetailView( | ||
| 195 | ownerUsername: String(tracker.owner.canonicalName.dropFirst()), | ||
| 196 | trackerName: tracker.name, | ||
| 197 | trackerId: tracker.id, | ||
| 198 | trackerRid: tracker.rid, | ||
| 199 | ticketId: ticket.id | ||
| 200 | ) | ||
| 201 | } label: { | ||
| 194 | TicketRowView(ticket: ticket) | 202 | TicketRowView(ticket: ticket) |
| 195 | } | 203 | } |
| 196 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | 204 | .swipeActions(edge: .leading, allowsFullSwipe: true) { |
| @@ -250,15 +258,6 @@ struct TicketListView: View { | |||
| 250 | .refreshable { | 258 | .refreshable { |
| 251 | await viewModel.loadTickets() | 259 | await viewModel.loadTickets() |
| 252 | } | 260 | } |
| 253 | .navigationDestination(for: TicketSummary.self) { ticket in | ||
| 254 | TicketDetailView( | ||
| 255 | ownerUsername: String(tracker.owner.canonicalName.dropFirst()), | ||
| 256 | trackerName: tracker.name, | ||
| 257 | trackerId: tracker.id, | ||
| 258 | trackerRid: tracker.rid, | ||
| 259 | ticketId: ticket.id | ||
| 260 | ) | ||
| 261 | } | ||
| 262 | } | 261 | } |
| 263 | 262 | ||
| 264 | private var trackerActionsMenu: some View { | 263 | private var trackerActionsMenu: some View { |
codex-user-resource-browsing.md added +249
| @@ -0,0 +1,249 @@ | |||
| 1 | # feat: User resource browsing from profile | ||
| 2 | |||
| 3 | ## Goal | ||
| 4 | |||
| 5 | Extend `UserProfileView` so that after looking up a user, their public | ||
| 6 | repositories and trackers are shown as browsable sections below the existing | ||
| 7 | profile metadata. This mirrors the sr.ht `~username` page. | ||
| 8 | |||
| 9 | --- | ||
| 10 | |||
| 11 | ## Context | ||
| 12 | |||
| 13 | **Entry point:** `Hutch/Views/Lookup/LookupView.swift` | ||
| 14 | Looking up a user opens `UserProfileView` in a sheet. Currently the view only | ||
| 15 | shows static metadata fields from the `User` model. | ||
| 16 | |||
| 17 | **Owner identifier:** `user.canonicalName` (e.g. `~username`). Strip the leading | ||
| 18 | `~` when passing to GraphQL `username` parameters — see the existing pattern in | ||
| 19 | `AppState.resolveRepository(owner:name:)`. | ||
| 20 | |||
| 21 | **Existing row views to reuse:** | ||
| 22 | - `RepositoryRowView` in `Hutch/Views/Repositories/RepositoryRowView.swift` | ||
| 23 | - Tracker row style from `TrackerListView` private `TrackerRowView` | ||
| 24 | |||
| 25 | **API pattern for user-scoped queries** (from `AppState.swift`): | ||
| 26 | ```graphql | ||
| 27 | query repoLookup($owner: String!, $name: String!) { | ||
| 28 | user(username: $owner) { | ||
| 29 | repository(name: $name) { ... } | ||
| 30 | } | ||
| 31 | } | ||
| 32 | ``` | ||
| 33 | The same `user(username:)` root field supports `repositories` and `trackers` | ||
| 34 | paginated collections on git.sr.ht and todo.sr.ht respectively. | ||
| 35 | |||
| 36 | --- | ||
| 37 | |||
| 38 | ## New File: `Hutch/Views/Lookup/UserProfileViewModel.swift` | ||
| 39 | |||
| 40 | Create an `@Observable @MainActor` view model following the same pattern as | ||
| 41 | `RepositoryListViewModel` and `TrackerListViewModel`. | ||
| 42 | |||
| 43 | ```swift | ||
| 44 | @Observable | ||
| 45 | @MainActor | ||
| 46 | final class UserProfileViewModel { | ||
| 47 | private(set) var repositories: [RepositorySummary] = [] | ||
| 48 | private(set) var trackers: [TrackerSummary] = [] | ||
| 49 | private(set) var isLoadingRepositories = false | ||
| 50 | private(set) var isLoadingTrackers = false | ||
| 51 | var repositoriesError: String? | ||
| 52 | var trackersError: String? | ||
| 53 | |||
| 54 | private let client: SRHTClient | ||
| 55 | let ownerUsername: String // without leading ~ | ||
| 56 | |||
| 57 | init(ownerUsername: String, client: SRHTClient) { ... } | ||
| 58 | |||
| 59 | func loadRepositories() async { ... } | ||
| 60 | func loadTrackers() async { ... } | ||
| 61 | } | ||
| 62 | ``` | ||
| 63 | |||
| 64 | **GraphQL queries:** | ||
| 65 | |||
| 66 | Repositories (execute against `.git` service): | ||
| 67 | ```graphql | ||
| 68 | query userRepositories($owner: String!) { | ||
| 69 | user(username: $owner) { | ||
| 70 | repositories { | ||
| 71 | results { | ||
| 72 | id rid name description visibility updated | ||
| 73 | owner { canonicalName } | ||
| 74 | HEAD { name target } | ||
| 75 | } | ||
| 76 | cursor | ||
| 77 | } | ||
| 78 | } | ||
| 79 | } | ||
| 80 | ``` | ||
| 81 | |||
| 82 | Trackers (execute against `.todo` service): | ||
| 83 | ```graphql | ||
| 84 | query userTrackers($owner: String!) { | ||
| 85 | user(username: $owner) { | ||
| 86 | trackers { | ||
| 87 | results { | ||
| 88 | id rid name description visibility updated | ||
| 89 | owner { canonicalName } | ||
| 90 | } | ||
| 91 | cursor | ||
| 92 | } | ||
| 93 | } | ||
| 94 | } | ||
| 95 | ``` | ||
| 96 | |||
| 97 | Decode using private response structs identical to those in | ||
| 98 | `RepositoryListViewModel` and `TrackerListViewModel`. Map results to | ||
| 99 | `RepositorySummary` and `TrackerSummary` exactly as those view models do. | ||
| 100 | |||
| 101 | --- | ||
| 102 | |||
| 103 | ## Modified File: `Hutch/Views/Lookup/UserProfileView.swift` | ||
| 104 | |||
| 105 | ### View model instantiation | ||
| 106 | |||
| 107 | Add `@State private var profileViewModel: UserProfileViewModel?` and initialise | ||
| 108 | it in `.task` using `user.canonicalName` with the leading `~` stripped: | ||
| 109 | |||
| 110 | ```swift | ||
| 111 | .task { | ||
| 112 | let owner = user.canonicalName.hasPrefix("~") | ||
| 113 | ? String(user.canonicalName.dropFirst()) | ||
| 114 | : user.canonicalName | ||
| 115 | let vm = UserProfileViewModel(ownerUsername: owner, client: appState.client) | ||
| 116 | profileViewModel = vm | ||
| 117 | async let repos: () = vm.loadRepositories() | ||
| 118 | async let trackers: () = vm.loadTrackers() | ||
| 119 | _ = await (repos, trackers) | ||
| 120 | } | ||
| 121 | ``` | ||
| 122 | |||
| 123 | Restore `@Environment(AppState.self) private var appState` (it was removed in a | ||
| 124 | recent commit but is needed for `client` access). | ||
| 125 | |||
| 126 | ### Repositories section | ||
| 127 | |||
| 128 | Add after the existing metadata sections: | ||
| 129 | |||
| 130 | ```swift | ||
| 131 | Section { | ||
| 132 | if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty { | ||
| 133 | ProgressView() | ||
| 134 | } else if viewModel.repositories.isEmpty { | ||
| 135 | Text("No public repositories.") | ||
| 136 | .foregroundStyle(.secondary) | ||
| 137 | } else { | ||
| 138 | ForEach(viewModel.repositories.prefix(4)) { repo in | ||
| 139 | NavigationLink { | ||
| 140 | RepositoryDetailView(repository: repo) | ||
| 141 | } label: { | ||
| 142 | RepositoryRowView(repository: repo, buildStatus: .none) | ||
| 143 | } | ||
| 144 | } | ||
| 145 | if viewModel.repositories.count > 4 { | ||
| 146 | NavigationLink("See All") { | ||
| 147 | UserRepositoriesView(viewModel: viewModel) | ||
| 148 | } | ||
| 149 | } | ||
| 150 | } | ||
| 151 | } header: { | ||
| 152 | Text("Repositories") | ||
| 153 | } | ||
| 154 | ``` | ||
| 155 | |||
| 156 | ### Trackers section | ||
| 157 | |||
| 158 | Immediately after the Repositories section: | ||
| 159 | |||
| 160 | ```swift | ||
| 161 | Section { | ||
| 162 | if viewModel.isLoadingTrackers && viewModel.trackers.isEmpty { | ||
| 163 | ProgressView() | ||
| 164 | } else if viewModel.trackers.isEmpty { | ||
| 165 | Text("No public trackers.") | ||
| 166 | .foregroundStyle(.secondary) | ||
| 167 | } else { | ||
| 168 | ForEach(viewModel.trackers.prefix(4)) { tracker in | ||
| 169 | NavigationLink { | ||
| 170 | TicketListView(tracker: tracker) | ||
| 171 | } label: { | ||
| 172 | TrackerRowView(tracker: tracker) | ||
| 173 | } | ||
| 174 | } | ||
| 175 | if viewModel.trackers.count > 4 { | ||
| 176 | NavigationLink("See All") { | ||
| 177 | UserTrackersView(viewModel: viewModel) | ||
| 178 | } | ||
| 179 | } | ||
| 180 | } | ||
| 181 | } header: { | ||
| 182 | Text("Trackers") | ||
| 183 | } | ||
| 184 | ``` | ||
| 185 | |||
| 186 | `TrackerRowView` — use the same VStack layout as the private `TrackerRowView` | ||
| 187 | in `TrackerListView.swift`. Define it as a private struct in | ||
| 188 | `UserProfileView.swift` rather than duplicating from `TrackerListView`. | ||
| 189 | |||
| 190 | --- | ||
| 191 | |||
| 192 | ## New File: `Hutch/Views/Lookup/UserRepositoriesView.swift` | ||
| 193 | |||
| 194 | A simple full-list view for "See All" repositories: | ||
| 195 | |||
| 196 | ```swift | ||
| 197 | struct UserRepositoriesView: View { | ||
| 198 | let viewModel: UserProfileViewModel | ||
| 199 | |||
| 200 | var body: some View { | ||
| 201 | List { | ||
| 202 | ForEach(viewModel.repositories) { repo in | ||
| 203 | NavigationLink { | ||
| 204 | RepositoryDetailView(repository: repo) | ||
| 205 | } label: { | ||
| 206 | RepositoryRowView(repository: repo, buildStatus: .none) | ||
| 207 | } | ||
| 208 | } | ||
| 209 | } | ||
| 210 | .listStyle(.plain) | ||
| 211 | .navigationTitle("Repositories") | ||
| 212 | .navigationBarTitleDisplayMode(.inline) | ||
| 213 | .overlay { | ||
| 214 | if viewModel.isLoadingRepositories && viewModel.repositories.isEmpty { | ||
| 215 | SRHTLoadingStateView(message: "Loading repositories…") | ||
| 216 | } | ||
| 217 | } | ||
| 218 | .refreshable { | ||
| 219 | await viewModel.loadRepositories() | ||
| 220 | } | ||
| 221 | } | ||
| 222 | } | ||
| 223 | ``` | ||
| 224 | |||
| 225 | ## New File: `Hutch/Views/Lookup/UserTrackersView.swift` | ||
| 226 | |||
| 227 | Same pattern as `UserRepositoriesView` but for trackers, navigating to | ||
| 228 | `TicketListView(tracker:)`. | ||
| 229 | |||
| 230 | --- | ||
| 231 | |||
| 232 | ## Navigation context | ||
| 233 | |||
| 234 | `UserProfileView` is always presented inside a `NavigationStack` via the sheet | ||
| 235 | in `LookupView`. `NavigationLink` destinations push correctly within that stack. | ||
| 236 | No changes to `LookupView` or `MoreRoute` are needed. | ||
| 237 | |||
| 238 | --- | ||
| 239 | |||
| 240 | ## Verification | ||
| 241 | |||
| 242 | 1. Look up a user with public repositories and trackers. Confirm both sections | ||
| 243 | appear below the profile metadata. | ||
| 244 | 2. Tap a repository row — confirm `RepositoryDetailView` pushes correctly. | ||
| 245 | 3. Tap a tracker row — confirm `TicketListView` pushes correctly. | ||
| 246 | 4. For users with more than 4 items, confirm "See All" pushes the full list. | ||
| 247 | 5. Look up a user with no public resources — confirm the empty-state text | ||
| 248 | renders in each section rather than crashing. | ||
| 249 | 6. Build with no warnings. | ||