Files
FrenoCorp/Lendair/Views/ClubDetailView.swift
Senior Engineer 88d57a3389 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
2026-05-03 19:10:34 -04:00

277 lines
9.2 KiB
Swift

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)
)
}