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>
243 lines
8.0 KiB
Swift
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()
|
|
}
|