Files
FrenoCorp/Lendair/Views/ChallengeDetailView.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

272 lines
9.7 KiB
Swift

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