krz/hutch

an ios client for sourcehut

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

v2.9.0: codex-user-resource-browsing.md · raw

  1# feat: User resource browsing from profile
  2
  3## Goal
  4
  5Extend `UserProfileView` so that after looking up a user, their public
  6repositories and trackers are shown as browsable sections below the existing
  7profile metadata. This mirrors the sr.ht `~username` page.
  8
  9---
 10
 11## Context
 12
 13**Entry point:** `Hutch/Views/Lookup/LookupView.swift`
 14Looking up a user opens `UserProfileView` in a sheet. Currently the view only
 15shows 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
 27query repoLookup($owner: String!, $name: String!) {
 28    user(username: $owner) {
 29        repository(name: $name) { ... }
 30    }
 31}
 32```
 33The same `user(username:)` root field supports `repositories` and `trackers`
 34paginated collections on git.sr.ht and todo.sr.ht respectively.
 35
 36---
 37
 38## New File: `Hutch/Views/Lookup/UserProfileViewModel.swift`
 39
 40Create an `@Observable @MainActor` view model following the same pattern as
 41`RepositoryListViewModel` and `TrackerListViewModel`.
 42
 43```swift
 44@Observable
 45@MainActor
 46final 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
 66Repositories (execute against `.git` service):
 67```graphql
 68query 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
 82Trackers (execute against `.todo` service):
 83```graphql
 84query 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
 97Decode 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
107Add `@State private var profileViewModel: UserProfileViewModel?` and initialise
108it 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
123Restore `@Environment(AppState.self) private var appState` (it was removed in a
124recent commit but is needed for `client` access).
125
126### Repositories section
127
128Add after the existing metadata sections:
129
130```swift
131Section {
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
158Immediately after the Repositories section:
159
160```swift
161Section {
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`
187in `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
194A simple full-list view for "See All" repositories:
195
196```swift
197struct 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
227Same 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
235in `LookupView`. `NavigationLink` destinations push correctly within that stack.
236No changes to `LookupView` or `MoreRoute` are needed.
237
238---
239
240## Verification
241
2421. Look up a user with public repositories and trackers. Confirm both sections
243   appear below the profile metadata.
2442. Tap a repository row — confirm `RepositoryDetailView` pushes correctly.
2453. Tap a tracker row — confirm `TicketListView` pushes correctly.
2464. For users with more than 4 items, confirm "See All" pushes the full list.
2475. Look up a user with no public resources — confirm the empty-state text
248   renders in each section rather than crashing.
2496. Build with no warnings.