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
277 lines
9.2 KiB
Swift
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)
|
|
)
|
|
}
|