Commit e2749ef0c0
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -539,7 +539,7 @@ | ||
| 539 | 539 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 540 | 540 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 541 | 541 | CODE_SIGN_STYLE = Automatic; |
| 542 | CURRENT_PROJECT_VERSION = 21; | |
| 542 | CURRENT_PROJECT_VERSION = 22; | |
| 543 | 543 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 544 | 544 | ENABLE_PREVIEWS = YES; |
| 545 | 545 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -556,7 +556,7 @@ | ||
| 556 | 556 | "$(inherited)", |
| 557 | 557 | "@executable_path/Frameworks", |
| 558 | 558 | ); |
| 559 | MARKETING_VERSION = 2.8.2; | |
| 559 | MARKETING_VERSION = 2.9.0; | |
| 560 | 560 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 561 | 561 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 562 | 562 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -576,7 +576,7 @@ | ||
| 576 | 576 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 577 | 577 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 578 | 578 | CODE_SIGN_STYLE = Automatic; |
| 579 | CURRENT_PROJECT_VERSION = 21; | |
| 579 | CURRENT_PROJECT_VERSION = 22; | |
| 580 | 580 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 581 | 581 | ENABLE_PREVIEWS = YES; |
| 582 | 582 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -593,7 +593,7 @@ | ||
| 593 | 593 | "$(inherited)", |
| 594 | 594 | "@executable_path/Frameworks", |
| 595 | 595 | ); |
| 596 | MARKETING_VERSION = 2.8.2; | |
| 596 | MARKETING_VERSION = 2.9.0; | |
| 597 | 597 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 598 | 598 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 599 | 599 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -656,7 +656,7 @@ | ||
| 656 | 656 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 657 | 657 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 658 | 658 | CODE_SIGN_STYLE = Automatic; |
| 659 | CURRENT_PROJECT_VERSION = 21; | |
| 659 | CURRENT_PROJECT_VERSION = 22; | |
| 660 | 660 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 661 | 661 | GENERATE_INFOPLIST_FILE = NO; |
| 662 | 662 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -666,7 +666,7 @@ | ||
| 666 | 666 | "@executable_path/Frameworks", |
| 667 | 667 | "@executable_path/../../Frameworks", |
| 668 | 668 | ); |
| 669 | MARKETING_VERSION = 2.8.2; | |
| 669 | MARKETING_VERSION = 2.8.3; | |
| 670 | 670 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 671 | 671 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 672 | 672 | SKIP_INSTALL = YES; |
| @@ -685,7 +685,7 @@ | ||
| 685 | 685 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 686 | 686 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 687 | 687 | CODE_SIGN_STYLE = Automatic; |
| 688 | CURRENT_PROJECT_VERSION = 21; | |
| 688 | CURRENT_PROJECT_VERSION = 22; | |
| 689 | 689 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 690 | 690 | GENERATE_INFOPLIST_FILE = NO; |
| 691 | 691 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -695,7 +695,7 @@ | ||
| 695 | 695 | "@executable_path/Frameworks", |
| 696 | 696 | "@executable_path/../../Frameworks", |
| 697 | 697 | ); |
| 698 | MARKETING_VERSION = 2.8.2; | |
| 698 | MARKETING_VERSION = 2.8.3; | |
| 699 | 699 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 700 | 700 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 701 | 701 | SKIP_INSTALL = YES; |
Hutch/Views/Lookup/UserProfileView.swift +98
| @@ -1,7 +1,10 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | 3 | struct UserProfileView: View { |
| 4 | @Environment(AppState.self) private var appState | |
| 5 | ||
| 4 | 6 | let user: User |
| 7 | @State private var profileViewModel: UserProfileViewModel? | |
| 5 | 8 | |
| 6 | 9 | private static let iso8601Formatter: ISO8601DateFormatter = { |
| 7 | 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 | 143 | .listStyle(.insetGrouped) |
| 91 | 144 | .navigationTitle(user.canonicalName) |
| 92 | 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 | 158 | private func formattedTimestamp(_ value: String) -> String { |
| @@ -100,3 +163,38 @@ struct UserProfileView: View { | ||
| 100 | 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 | 191 | // Tickets |
| 192 | 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 | 202 | TicketRowView(ticket: ticket) |
| 195 | 203 | } |
| 196 | 204 | .swipeActions(edge: .leading, allowsFullSwipe: true) { |
| @@ -250,15 +258,6 @@ struct TicketListView: View { | ||
| 250 | 258 | .refreshable { |
| 251 | 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 | 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. | |