Use when building new SwiftUI screens for HustleXP iOS, adding navigation routes, creating ViewModels, or wiring backend tRPC procedures to new UI components
{FeatureName}ViewModel.swift — @Observable @MainActor final class, uses TRPCClient.shared{FeatureName}Screen.swift — struct, holds @State private var viewModel = {FeatureName}ViewModel()Router.swift*Stack.swift .navigationDestination switch| Screen type | File path |
|---|---|
| Hustler-facing | hustleXP final1/Screens/Hustler/{FeatureName}Screen.swift |
| Hustler ViewModel | hustleXP final1/ViewModels/{FeatureName}ViewModel.swift |
| Poster-facing | hustleXP final1/Screens/Poster/{FeatureName}Screen.swift |
| Poster ViewModel | hustleXP final1/ViewModels/{FeatureName}ViewModel.swift |
| Shared (both roles) | hustleXP final1/Screens/Shared/{FeatureName}Screen.swift |
| Shared ViewModel | hustleXP final1/ViewModels/{FeatureName}ViewModel.swift |
ViewModels are NOT co-located with Screens. They live in the separate ViewModels/ directory at the project root alongside Screens/.
All paths are relative to: /Users/sebastiandysart/HustleXP/HUSTLEXPFINAL1/hustleXP final1/
The actual method signature (from Services/TRPCClient.swift) is:
TRPCClient.shared.call(
router: String, // e.g. "jury"
procedure: String, // e.g. "submitVote"
type: ProcedureType, // .query (GET) or .mutation (POST) — defaults to .mutation
input: Encodable
) async throws -> Decodable
NOT procedure: "jury.submitVote" — router and procedure are separate parameters.
Queries (read): type: .query
Mutations (write): type: .mutation (default, can be omitted)
ViewModels use @Observable @MainActor (not ObservableObject/@Published). All state vars are plain var.
import Foundation
@Observable @MainActor
final class JuryVotingViewModel {
var isLoading = false
var errorMessage: String?
var voteTally: VoteTally?
var hasVoted = false
func loadTally(disputeId: String) async {
isLoading = true
defer { isLoading = false }
do {
struct Input: Encodable { let disputeId: String }
voteTally = try await TRPCClient.shared.call(
router: "jury",
procedure: "getVoteTally",
type: .query,
input: Input(disputeId: disputeId)
)
} catch let error as APIError {
errorMessage = error.userFacingMessage
} catch {
errorMessage = error.localizedDescription
}
}
func submitVote(disputeId: String, vote: JuryVote, confidence: Double = 1.0) async {
isLoading = true
defer { isLoading = false }
do {
struct Input: Encodable {
let disputeId: String
let vote: String
let confidence: Double
}
let _: VoteResult = try await TRPCClient.shared.call(
router: "jury",
procedure: "submitVote",
input: Input(disputeId: disputeId, vote: vote.rawValue, confidence: confidence)
)
hasVoted = true
} catch let error as APIError {
errorMessage = error.userFacingMessage
} catch {
errorMessage = error.localizedDescription
}
}
}
Key rules:
APIError first (has .userFacingMessage) is recommended for new screens, but note that many existing ViewModels use plain catch { error.localizedDescription } without a typed APIError clause — both patterns compile and workInput structs as Encodable inside the function — keeps the ViewModel self-containedJuryService.swift), import/reuse them instead of redefiningisLoading = true + defer { isLoading = false } on every async funcimport SwiftUI
struct JuryVotingScreen: View {
let disputeId: String
@State private var viewModel = JuryVotingViewModel()
var body: some View {
ZStack {
Color.brandBlack.ignoresSafeArea()
if viewModel.isLoading {
ProgressView()
.tint(Color.brandPurple)
} else if let tally = viewModel.voteTally {
ScrollView {
VStack(spacing: 24) {
// Main content
VoteTallyView(tally: tally)
if !viewModel.hasVoted {
VoteButtonsView { vote in
Task {
await viewModel.submitVote(disputeId: disputeId, vote: vote)
}
}
}
}
.padding(20)
}
} else {
EmptyState(icon: "scale.3d", title: "Loading...", message: "")
}
if let error = viewModel.errorMessage {
VStack {
Spacer()
Text(error)
.foregroundStyle(Color.errorRed)
.padding()
.background(Color.surfaceElevated)
.cornerRadius(12)
.padding()
}
}
}
.navigationTitle("Jury Vote")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(Color.brandBlack, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.task { await viewModel.loadTally(disputeId: disputeId) }
}
}
#Preview {
NavigationStack {
JuryVotingScreen(disputeId: "dispute-preview-123")
}
}
Router.swiftFile: hustleXP final1/Navigation/Router.swift
Hustler screens → enum HustlerRoute: Hashable
Poster screens → enum PosterRoute: Hashable
Shared screens → enum SharedRoute: Hashable
Settings screens→ enum SettingsRoute: Hashable
Example — adding jury voting to SharedRoute:
enum SharedRoute: Hashable {
// existing cases...
case juryVoting(disputeId: String)
}
To trigger navigation from any screen:
@Environment(Router.self) private var router
// ...
router.navigateToHustler(.taskDetail(taskId: id)) // for HustlerRoute
// There is no navigateToShared — SharedRoute cases are appended to the role stack
// e.g. router.hustlerPath.append(SharedRoute.juryVoting(disputeId: id))
// or embed in HustlerRoute/PosterRoute as needed
| Route enum | Stack file |
|---|---|
| HustlerRoute | Navigation/HustlerStack.swift |
| PosterRoute | Navigation/PosterStack.swift |
| SettingsRoute | Navigation/SettingsStack.swift |
Add a case inside .navigationDestination(for: HustlerRoute.self):
case .juryVoting(let disputeId):
JuryVotingScreen(disputeId: disputeId)
WARNING — SharedRoute registration:
SharedRouteis defined inRouter.swiftbut currently has ZERO.navigationDestinationregistrations anywhere in the codebase. If you add a newSharedRoutecase, you must manually add.navigationDestination(for: SharedRoute.self)to bothHustlerStack.swiftandPosterStack.swift. Without this, navigation will silently do nothing — no crash, no error, the push simply never fires.
Several backend-connected services and their models already exist. Prefer them over redefining types:
| Feature | Existing service file | Key types |
|---|---|---|
| Jury voting | Services/JuryService.swift | JuryVote, VoteTally, VoteResult |
| Daily challenges | Services/DailyChallengeService.swift | DailyChallengeService.DailyChallenge |
| Featured listing | Services/FeaturedListingService.swift | FeatureOption |
| Messaging | Services/MessagingService.swift | HXMessage, HXConversation |
| Tasks | Services/TaskService.swift | HXTask |
| Auth | Services/AuthService.swift | AuthService.shared |
When a service already exists, you can call it directly from the ViewModel OR call TRPCClient.shared directly. Both patterns are valid; prefer the service when it already handles the tRPC plumbing for that domain.
Screens/Shared/JuryVotingScreen.swiftViewModels/JuryVotingViewModel.swiftrouter: "jury", procedure: "getVoteTally", type: .query → VoteTallyrouter: "jury", procedure: "submitVote", type: .mutation → VoteResultdisputeId: StringJuryVote, VoteTally, VoteResult from Services/JuryService.swiftcase juryVoting(disputeId: String) to SharedRoute (embed in HustlerRoute + PosterRoute if deep-linking needed), register in both HustlerStack.swift and PosterStack.swiftDisputeScreen.swift (existing) — add a "Vote" button that pushes this routeScreens/Hustler/DailyChallengesScreen.swiftViewModels/DailyChallengesViewModel.swiftrouter: "challenges", procedure: "getTodaysChallenges", type: .query → [DailyChallengeService.DailyChallenge]struct EmptyInput: Encodable {} as inputDailyChallengeService and its DailyChallenge struct already parse the responsecase dailyChallenges to HustlerRoute, register in HustlerStack.swiftScreens/Poster/FeaturedListingScreen.swiftViewModels/FeaturedListingViewModel.swiftrouter: "featured", procedure: "promoteTask" → { clientSecret: String, listingId: String? } (returns Stripe clientSecret for payment)router: "featured", procedure: "confirmPromotion" → after Stripe payment succeedstaskId: String, featureType: String (featureType one of: "promoted", "highlighted", "urgent_boost")FeaturedListingService.FeatureOption for the option list; FeaturedListingService.options static arraycase featuredListing(taskId: String) to PosterRoute, register in PosterStack.swiftNever hardcode backend URLs. TRPCClient reads from AppConfig.backendBaseURL automatically:
// AppConfig.swift uses #if DEBUG for environment switching
// All TRPCClient calls inherit the correct URL — no extra setup needed
| Token | Usage |
|---|---|
Color.brandBlack | Screen background (always on ZStack root) |
Color.brandPurple | Primary accent, CTAs |
Color.surfaceElevated | Cards and elevated surfaces |
Color.textPrimary | Primary text |
Color.textSecondary | Captions, labels |
Color.errorRed | Error messages |
Color.successGreen | Success states |
Color.moneyGreen | Monetary values |
Components: HXButton, HXText, HXIcon, HXAvatar, HXInput (from Components/Atoms/), EmptyState, LoadingState (from Components/Molecules/).
xcodebuild -scheme "hustleXP final1" \
-destination "platform=iOS Simulator,name=iPhone 15" \
build 2>&1 | grep -E "error:|Build succeeded"
A clean build produces ** BUILD SUCCEEDED ** with zero error: lines.
procedure: "jury.submitVote". Always pass router: and procedure: as separate params.*Stack.swift .navigationDestination, NOT inline in Router.swift.HustlerRoute, Poster screens → PosterRoute, both-role screens → add to both or use SharedRoute embedded in both stacks.@Observable @MainActor final class, not class ... : ObservableObject. State vars are plain var, not @Published var.TRPCClient.shared which reads AppConfig.backendBaseURL automatically.Services/ for existing models before creating new ones. JuryVote, VoteTally, DailyChallenge etc. already exist.