Files
FrenoCorp/Lendair/Views/ClubsView.swift
Michael Freno bc7bf124f5 Fix P0-P3 code review issues for clubs and challenges (FRE-4664)
P0: Fix variable shadowing in ClubService.createClub/updateClub and
    ChallengeService.createChallenge/updateChallenge — renamed local
    'var request' to 'var urlRequest' so JSONEncoder encodes the
    typed parameter, not the URLRequest.

P1: Wire CreateClubSheet and CreateChallengeSheet to parent ViewModel —
    sheets now receive viewModel and call createClub/createChallenge
    before dismissing.

P2: Extract HTTPMethod enum to shared Utils/HTTPMethod.swift (was
    defined in NotificationService). Remove dead 'body' parameter from
    buildRequest in all three services. Add error alert UI to
    ClubsView and ChallengesView.

P3: Replace forced URL unwrap with static let defaultBaseURL in all
    three services. Fix MockChallengeService.updateChallenge to track
    updateCalled instead of always throwing notFound.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-10 06:42:00 -04:00

243 lines
8.0 KiB
Swift

import SwiftUI
struct ClubsView: View {
@StateObject private var viewModel = ClubViewModel()
@State private var showingCreateSheet = false
@State private var selectedTab: ClubTab = .discover
@State private var lastError: ClubError?
enum ClubTab: String, CaseIterable {
case discover, myClubs
}
var body: some View {
NavigationView {
Group {
if viewModel.isLoading && viewModel.clubs.isEmpty {
loadingView
} else if currentClubs.isEmpty {
emptyStateView
} else {
clubListView
}
}
.navigationTitle("Clubs")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingCreateSheet = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingCreateSheet) {
CreateClubSheet(viewModel: viewModel)
}
.alert("Error", isPresented: .init(get: { viewModel.error != nil }, set: { if !$0 { lastError = nil } })) {
Button("OK") { lastError = viewModel.error }
} message: {
Text(viewModel.error?.errorDescription ?? "")
}
}
.onAppear {
Task {
await viewModel.fetchClubs()
}
}
}
private var currentClubs: [Club] {
switch selectedTab {
case .discover: return viewModel.publicClubs
case .myClubs: return viewModel.userClubs
}
}
private var clubListView: some View {
List {
Picker("Clubs", selection: $selectedTab) {
ForEach(ClubTab.allCases, id: \.self) { tab in
Text(tab.rawValue.capitalized).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.top, 8)
Section(currentSectionTitle) {
ForEach(currentClubs) { club in
NavigationLink(destination: ClubDetailView(club: club)) {
ClubRowView(club: club)
}
}
}
}
.listStyle(.insetGrouped)
.refreshable {
await viewModel.fetchClubs()
}
}
private var currentSectionTitle: String {
switch selectedTab {
case .discover: return "Discover Clubs"
case .myClubs: return "My Clubs"
}
}
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
Text("Loading Clubs...")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.vertical, 60)
}
private var emptyStateView: some View {
VStack(spacing: 16) {
Image(systemName: "person.3.fill")
.font(.system(size: 64))
.foregroundColor(.secondary)
Text("No \(selectedTab.rawValue) Clubs")
.font(.title2)
.fontWeight(.semibold)
Text(selectedTab == .discover
? "Find running and fitness clubs in your area."
: "Join or create a club to get started.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.vertical, 60)
}
}
struct CreateClubSheet: View {
@Environment(\.dismiss) var dismiss
let viewModel: ClubViewModel
@State private var name = ""
@State private var description = ""
@State private var clubType: ClubType = .running
@State private var privacy: ClubPrivacy = .publicPrivacy
@State private var location = ""
@State private var maxMembers = ""
@State private var rules = ""
var body: some View {
NavigationView {
Form {
Section("Club Details") {
TextField("Club Name", text: $name)
TextField("Description", text: $description)
TextField("Location", text: $location)
}
Section("Type & Privacy") {
Picker("Club Type", selection: $clubType) {
ForEach(ClubType.allCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
Picker("Privacy", selection: $privacy) {
ForEach(ClubPrivacy.allCases, id: \.self) { priv in
Text(priv.displayName).tag(priv)
}
}
}
Section("Optional") {
TextField("Max Members", text: $maxMembers)
.keyboardType(.numberPad)
TextField("Rules", text: $rules)
}
}
.navigationTitle("Create Club")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Create") {
let request = CreateClubRequest(
name: name,
description: description,
clubType: clubType,
privacy: privacy,
location: location,
latitude: nil,
longitude: nil,
maxMembers: Int(maxMembers),
rules: rules.isEmpty ? nil : rules
)
Task {
_ = await viewModel.createClub(request: request)
dismiss()
}
}
.disabled(name.isEmpty || location.isEmpty)
}
}
}
}
}
struct ClubRowView: View {
let club: Club
var body: some View {
HStack(spacing: 12) {
Image(systemName: club.clubType.icon)
.font(.system(size: 24))
.foregroundColor(club.clubType.color)
.frame(width: 44, height: 44)
.background(club.clubType.color.opacity(0.15))
.cornerRadius(10)
VStack(alignment: .leading, spacing: 4) {
Text(club.name)
.font(.headline)
Text("\(club.location) \u2022 \(club.privacy.displayName)")
.font(.subheadline)
.foregroundColor(.secondary)
HStack(spacing: 8) {
Text("\(club.memberCount) members")
.font(.caption)
.foregroundColor(.secondary)
if let spots = club.availableSpots, spots > 0 {
Text("\(spots) spots left")
.font(.caption2)
.foregroundColor(.green)
}
}
}
Spacer()
switch club.membershipStatus {
case .active:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
case .pending:
Image(systemName: "clock.fill")
.foregroundColor(.orange)
case .invited:
Image(systemName: "mail.fill")
.foregroundColor(.blue)
case .left:
Image(systemName: "circle")
.foregroundColor(.secondary)
}
}
.padding(.vertical, 4)
}
}
#Preview {
ClubsView()
}