Add Phase 2 community features: clubs and challenges (FRE-4664)

Implement full MVVM stack for two new community features:

Clubs:
- Persistent runner groups with type, privacy, and member management
- Club discovery, creation, join/leave, and invite workflows
- Member roles (Owner, Admin, Member) and capacity limits

Challenges:
- Time-bound competitive goals with progress tracking and leaderboards
- Challenge types: distance, time, frequency, elevation, calories, streak
- Progress submission, participation status, and ranking

Files:
- Models: Club.swift, Challenge.swift
- Services: ClubService.swift, ChallengeService.swift
- ViewModels: ClubViewModel.swift, ChallengeViewModel.swift
- Views: ClubsView.swift, ClubDetailView.swift, ChallengesView.swift, ChallengeDetailView.swift
- Tests: ClubServiceTests.swift, ChallengeServiceTests.swift
- Updated README.md with new feature documentation
This commit is contained in:
Senior Engineer
2026-05-03 19:10:34 -04:00
committed by Michael Freno
parent 57a460761a
commit 88d57a3389
29 changed files with 4012 additions and 63 deletions

View File

@@ -0,0 +1,272 @@
import SwiftUI
struct ChallengeDetailView: View {
let challenge: Challenge
@StateObject private var viewModel = ChallengeViewModel()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
challengeHeader
challengeInfoSection
challengeDescription
progressSection
participationSection
leaderboardSection
}
.padding(.horizontal)
.padding(.bottom, 32)
}
.navigationTitle(challenge.title)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
Task {
await viewModel.selectChallenge(id: challenge.id)
await viewModel.fetchLeaderboard(challengeId: challenge.id)
}
}
}
private var challengeHeader: some View {
VStack(spacing: 12) {
HStack {
Image(systemName: challenge.challengeType.icon)
.font(.system(size: 40))
.foregroundColor(challenge.challengeType.color)
VStack(alignment: .leading, spacing: 4) {
Text(challenge.challengeType.displayName)
.font(.title2)
.fontWeight(.bold)
Text("\(challenge.targetValue) \(challenge.targetUnit) goal")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
Divider()
HStack(spacing: 20) {
infoItem(label: "Status", value: challenge.status.rawValue.capitalized)
infoItem(label: "Participants", value: "\(challenge.participantCount)")
infoItem(label: "Days Left", value: "\(challenge.daysRemaining)")
}
}
.padding()
.background(Color.secondary.opacity(0.1))
.cornerRadius(12)
}
private func infoItem(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.subheadline)
.fontWeight(.medium)
}
}
private var challengeInfoSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Challenge Details")
.font(.headline)
VStack(alignment: .leading, spacing: 6) {
detailRow(label: "Type", value: challenge.challengeType.displayName)
detailRow(label: "Metric", value: challenge.targetMetric.displayName)
detailRow(label: "Target", value: "\(challenge.targetValue) \(challenge.targetUnit)")
detailRow(label: "Start", value: formatDate(challenge.startDate))
detailRow(label: "End", value: formatDate(challenge.endDate))
detailRow(label: "Creator", value: challenge.createdByName)
}
}
}
private func detailRow(label: String, value: String) -> some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundColor(.secondary)
.frame(width: 100, alignment: .leading)
Text(value)
.font(.subheadline)
.fontWeight(.medium)
}
}
private var challengeDescription: some View {
VStack(alignment: .leading, spacing: 4) {
Text("About This Challenge")
.font(.headline)
Text(challenge.description)
.font(.subheadline)
.foregroundColor(.secondary)
if let rules = challenge.rules {
Divider()
.padding(.vertical, 4)
Text("Rules")
.font(.headline)
Text(rules)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
private var progressSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Your Progress")
.font(.headline)
if challenge.participationStatus == .participating {
VStack(spacing: 8) {
HStack {
Text("\(challenge.progressPercentage, specifier: "%.0f")%")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(challenge.challengeType.color)
Spacer()
Text("\(challenge.userProgress ?? 0) / \(challenge.targetValue) \(challenge.targetUnit)")
.font(.subheadline)
.foregroundColor(.secondary)
}
ProgressView(value: challenge.progressPercentage / 100)
.tint(challenge.challengeType.color)
.frame(height: 8)
}
} else {
Text(challenge.participationStatus == .invited
? "You've been invited — join to start tracking progress."
: "Join this challenge to track your progress.")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
private var participationSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Participation")
.font(.headline)
HStack(spacing: 12) {
switch challenge.participationStatus {
case .participating:
Button {
Task {
await viewModel.leaveChallenge(id: challenge.id)
}
} label: {
Label("Leave Challenge", systemImage: "flag.on.flag.fill")
.foregroundColor(.red)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.red.opacity(0.15))
.cornerRadius(8)
case .invited:
Button {
Task {
await viewModel.joinChallenge(id: challenge.id)
}
} label: {
Label("Accept & Join", systemImage: "checkmark.circle.fill")
.foregroundColor(.green)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.green.opacity(0.15))
.cornerRadius(8)
case .notParticipating:
Button {
Task {
await viewModel.joinChallenge(id: challenge.id)
}
} label: {
Label("Join Challenge", systemImage: "flag.fill")
.foregroundColor(.blue)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.blue.opacity(0.15))
.cornerRadius(8)
}
}
}
}
}
private var leaderboardSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Leaderboard")
.font(.headline)
if viewModel.leaderboard.isEmpty {
Text("No participants yet.")
.font(.subheadline)
.foregroundColor(.secondary)
.padding(.vertical, 8)
} else {
ForEach(Array(viewModel.leaderboard.prefix(10), id: \.id)) { entry in
HStack(spacing: 12) {
Text("\(entry.position)")
.font(.caption)
.fontWeight(.bold)
.frame(width: 24)
Circle()
.fill(Color.secondary.opacity(0.2))
.frame(width: 28, height: 28)
Text(entry.participantName)
.font(.subheadline)
Spacer()
Text("\(entry.progressPercentage, specifier: "%.0f")%")
.font(.caption)
.fontWeight(.medium)
.foregroundColor(entry.position <= 3 ? .orange : .secondary)
}
}
}
}
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: date)
}
}
#Preview {
NavigationView {
ChallengeDetailView(challenge: sampleChallenge)
}
}
private var sampleChallenge: Challenge {
Challenge(
id: "1",
title: "Monthly 100km Challenge",
description: "Run 100km this month. Track your distance and compete with friends!",
challengeType: .distance,
status: .active,
startDate: Date().addingTimeInterval(-7 * 24 * 3600),
endDate: Date().addingTimeInterval(23 * 24 * 3600),
targetMetric: .distance,
targetValue: 100,
targetUnit: "km",
participantCount: 47,
rules: "All runs count. GPS-tracked activities only.",
imageUrl: nil,
createdBy: "user1",
createdByName: "Sarah Chen",
clubId: nil,
participationStatus: .participating,
userProgress: 42.5,
createdAt: Date().addingTimeInterval(-7 * 24 * 3600)
)
}

View File

@@ -0,0 +1,262 @@
import SwiftUI
struct ChallengesView: View {
@StateObject private var viewModel = ChallengeViewModel()
@State private var showingCreateSheet = false
@State private var selectedTab: ChallengeTab = .active
enum ChallengeTab: String, CaseIterable {
case active, upcoming, completed
}
var body: some View {
NavigationView {
Group {
if viewModel.isLoading && viewModel.challenges.isEmpty {
loadingView
} else if currentChallenges.isEmpty {
emptyStateView
} else {
challengeListView
}
}
.navigationTitle("Challenges")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingCreateSheet = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingCreateSheet) {
CreateChallengeSheet()
}
}
.onAppear {
Task {
await viewModel.fetchChallenges()
}
}
}
private var currentChallenges: [Challenge] {
switch selectedTab {
case .active: return viewModel.activeChallenges
case .upcoming: return viewModel.upcomingChallenges
case .completed: return viewModel.completedChallenges
}
}
private var challengeListView: some View {
List {
Picker("Challenges", selection: $selectedTab) {
ForEach(ChallengeTab.allCases, id: \.self) { tab in
Text(tab.rawValue.capitalized).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.top, 8)
Section(currentSectionTitle) {
ForEach(currentChallenges) { challenge in
NavigationLink(destination: ChallengeDetailView(challenge: challenge)) {
ChallengeRowView(challenge: challenge)
}
}
}
}
.listStyle(.insetGrouped)
.refreshable {
await viewModel.fetchChallenges()
}
}
private var currentSectionTitle: String {
switch selectedTab {
case .active: return "Active Challenges"
case .upcoming: return "Upcoming"
case .completed: return "Completed"
}
}
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
Text("Loading Challenges...")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.vertical, 60)
}
private var emptyStateView: some View {
VStack(spacing: 16) {
Image(systemName: "flag.fill")
.font(.system(size: 64))
.foregroundColor(.secondary)
Text("No \(selectedTab.rawValue) Challenges")
.font(.title2)
.fontWeight(.semibold)
Text(selectedTab == .active
? "Join or create a challenge to compete with others."
: selectedTab == .upcoming
? "New challenges will appear here."
: "Completed challenges are tracked here.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.vertical, 60)
}
}
struct CreateChallengeSheet: View {
@Environment(\.dismiss) var dismiss
@State private var title = ""
@State private var description = ""
@State private var challengeType: ChallengeType = .distance
@State private var targetMetric: ChallengeMetric = .distance
@State private var targetValue = ""
@State private var rules = ""
var body: some View {
NavigationView {
Form {
Section("Challenge Details") {
TextField("Challenge Title", text: $title)
TextField("Description", text: $description)
}
Section("Type") {
Picker("Challenge Type", selection: $challengeType) {
ForEach(ChallengeType.allCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
}
Section("Target") {
Picker("Metric", selection: $targetMetric) {
ForEach(ChallengeMetric.allCases, id: \.self) { metric in
Text(metric.displayName).tag(metric)
}
}
HStack {
TextField("Target Value", text: $targetValue)
.keyboardType(.decimalPad)
Text(targetMetric.unit)
.foregroundColor(.secondary)
}
}
Section("Optional") {
TextField("Rules", text: $rules)
}
}
.navigationTitle("Create Challenge")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Create") {
let endDate = Date().addingTimeInterval(30 * 24 * 3600)
let request = CreateChallengeRequest(
title: title,
description: description,
challengeType: challengeType,
startDate: Date(),
endDate: endDate,
targetMetric: targetMetric,
targetValue: Double(targetValue) ?? 0,
rules: rules.isEmpty ? nil : rules,
clubId: nil
)
dismiss()
}
.disabled(title.isEmpty || targetValue.isEmpty)
}
}
}
}
}
struct ChallengeRowView: View {
let challenge: Challenge
var body: some View {
VStack(spacing: 8) {
HStack(spacing: 12) {
Image(systemName: challenge.challengeType.icon)
.font(.system(size: 24))
.foregroundColor(challenge.challengeType.color)
.frame(width: 44, height: 44)
.background(challenge.challengeType.color.opacity(0.15))
.cornerRadius(10)
VStack(alignment: .leading, spacing: 4) {
Text(challenge.title)
.font(.headline)
Text("\(challenge.challengeType.displayName) \u2022 \(challenge.targetValue) \(challenge.targetUnit)")
.font(.subheadline)
.foregroundColor(.secondary)
HStack(spacing: 8) {
Text("\(challenge.participantCount) participants")
.font(.caption)
.foregroundColor(.secondary)
if challenge.daysRemaining > 0 {
Text("\(challenge.daysRemaining) days left")
.font(.caption2)
.foregroundColor(.orange)
}
}
}
Spacer()
switch challenge.participationStatus {
case .participating:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
case .invited:
Image(systemName: "mail.fill")
.foregroundColor(.blue)
case .notParticipating:
Image(systemName: "circle")
.foregroundColor(.secondary)
}
}
if challenge.participationStatus == .participating {
progressView
}
}
.padding(.vertical, 4)
}
private var progressView: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("\(challenge.progressPercentage, specifier: "%.0f")%")
.font(.caption2)
.fontWeight(.medium)
Spacer()
Text("\(challenge.userProgress ?? 0)/\(challenge.targetValue) \(challenge.targetUnit)")
.font(.caption2)
.foregroundColor(.secondary)
}
ProgressView(value: challenge.progressPercentage / 100)
.tint(challenge.challengeType.color)
}
.padding(.horizontal, 4)
}
}
#Preview {
ChallengesView()
}

View File

@@ -0,0 +1,276 @@
import SwiftUI
struct ClubDetailView: View {
let club: Club
@StateObject private var viewModel = ClubViewModel()
@State private var inviteEmail = ""
@State private var showingInviteAlert = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
clubHeader
clubInfoSection
clubDescription
membershipSection
rulesSection
membersSection
}
.padding(.horizontal)
.padding(.bottom, 32)
}
.navigationTitle(club.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if club.membershipStatus == .active {
Menu {
Button("Edit Club") {}
Button("Leave Club", role: .destructive) {
Task {
await viewModel.leaveClub(id: club.id)
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
.onAppear {
Task {
await viewModel.selectClub(id: club.id)
}
}
}
private var clubHeader: some View {
VStack(spacing: 12) {
HStack {
Image(systemName: club.clubType.icon)
.font(.system(size: 40))
.foregroundColor(club.clubType.color)
VStack(alignment: .leading, spacing: 4) {
Text(club.clubType.displayName)
.font(.title2)
.fontWeight(.bold)
Text(club.location)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
Divider()
HStack(spacing: 20) {
infoItem(label: "Members", value: "\(club.memberCount)")
infoItem(label: "Privacy", value: club.privacy.displayName)
infoItem(label: "Owner", value: club.ownerName)
}
}
.padding()
.background(Color.secondary.opacity(0.1))
.cornerRadius(12)
}
private func infoItem(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.subheadline)
.fontWeight(.medium)
}
}
private var clubInfoSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Club Details")
.font(.headline)
VStack(alignment: .leading, spacing: 6) {
detailRow(label: "Type", value: club.clubType.displayName)
detailRow(label: "Privacy", value: club.privacy.displayName)
detailRow(label: "Location", value: club.location)
if let max = club.maxMembers {
detailRow(label: "Capacity", value: "\(club.memberCount)/\(max)")
}
detailRow(label: "Joined", value: formatDate(club.createdAt))
}
}
}
private func detailRow(label: String, value: String) -> some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundColor(.secondary)
.frame(width: 100, alignment: .leading)
Text(value)
.font(.subheadline)
.fontWeight(.medium)
}
}
private var clubDescription: some View {
VStack(alignment: .leading, spacing: 4) {
Text("About This Club")
.font(.headline)
Text(club.description)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
private var membershipSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Membership")
.font(.headline)
HStack(spacing: 12) {
switch club.membershipStatus {
case .active:
Button {
Task {
await viewModel.leaveClub(id: club.id)
}
} label: {
Label("Leave Club", systemImage: "door.left.hand.open")
.foregroundColor(.red)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.red.opacity(0.15))
.cornerRadius(8)
case .pending:
Label("Joining...", systemImage: "clock.fill")
.foregroundColor(.orange)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.15))
.cornerRadius(8)
case .invited:
Button {
Task {
await viewModel.joinClub(id: club.id)
}
} label: {
Label("Accept Invite", systemImage: "checkmark.circle.fill")
.foregroundColor(.green)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.green.opacity(0.15))
.cornerRadius(8)
case .left:
Button {
Task {
await viewModel.joinClub(id: club.id)
}
} label: {
Label("Rejoin Club", systemImage: "arrow.turn.down.right")
.foregroundColor(.blue)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.blue.opacity(0.15))
.cornerRadius(8)
}
if club.membershipStatus == .active {
Button {
showingInviteAlert = true
} label: {
Label("Invite", systemImage: "person.crop.circle.badge.plus")
.foregroundColor(.blue)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.blue.opacity(0.15))
.cornerRadius(8)
}
}
}
}
}
private var rulesSection: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Club Rules")
.font(.headline)
if let rules = club.rules {
Text(rules)
.font(.subheadline)
.foregroundColor(.secondary)
} else {
Text("No rules specified.")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
private var membersSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Members (\(club.memberCount))")
.font(.headline)
ForEach(viewModel.members) { member in
HStack(spacing: 12) {
Circle()
.fill(Color.secondary.opacity(0.2))
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(member.name)
.font(.subheadline)
Text(member.role.displayName)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text(member.membershipStatus.rawValue.capitalized)
.font(.caption)
.foregroundColor(member.membershipStatus == .active ? .green : .secondary)
}
}
}
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: date)
}
}
#Preview {
NavigationView {
ClubDetailView(club: sampleClub)
}
}
private var sampleClub: Club {
Club(
id: "1",
name: "Central Park Runners",
description: "A friendly running club that meets every weekend in Central Park. All levels welcome!",
clubType: .running,
privacy: .publicPrivacy,
location: "Central Park, NYC",
latitude: 40.7851,
longitude: -73.9683,
memberCount: 142,
maxMembers: 200,
imageUrl: nil,
rules: "Be respectful, stay hydrated, and have fun!",
ownerId: "user1",
ownerName: "Alex Johnson",
membershipStatus: .active,
createdAt: Date().addingTimeInterval(-30 * 24 * 3600)
)
}

View File

@@ -0,0 +1,232 @@
import SwiftUI
struct ClubsView: View {
@StateObject private var viewModel = ClubViewModel()
@State private var showingCreateSheet = false
@State private var selectedTab: ClubTab = .discover
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()
}
}
.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
@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
)
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()
}