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:
276
Lendair/Views/ClubDetailView.swift
Normal file
276
Lendair/Views/ClubDetailView.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user