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