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

263 lines
9.1 KiB
Swift

import SwiftUI
struct ChallengesView: View {
@StateObject private var viewModel = ChallengeViewModel()
@State private var showingCreateSheet = false
@State private var selectedTab: ChallengeTab = .active
enum ChallengeTab: String, CaseIterable {
case active, upcoming, completed
}
var body: some View {
NavigationView {
Group {
if viewModel.isLoading && viewModel.challenges.isEmpty {
loadingView
} else if currentChallenges.isEmpty {
emptyStateView
} else {
challengeListView
}
}
.navigationTitle("Challenges")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingCreateSheet = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingCreateSheet) {
CreateChallengeSheet()
}
}
.onAppear {
Task {
await viewModel.fetchChallenges()
}
}
}
private var currentChallenges: [Challenge] {
switch selectedTab {
case .active: return viewModel.activeChallenges
case .upcoming: return viewModel.upcomingChallenges
case .completed: return viewModel.completedChallenges
}
}
private var challengeListView: some View {
List {
Picker("Challenges", selection: $selectedTab) {
ForEach(ChallengeTab.allCases, id: \.self) { tab in
Text(tab.rawValue.capitalized).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.top, 8)
Section(currentSectionTitle) {
ForEach(currentChallenges) { challenge in
NavigationLink(destination: ChallengeDetailView(challenge: challenge)) {
ChallengeRowView(challenge: challenge)
}
}
}
}
.listStyle(.insetGrouped)
.refreshable {
await viewModel.fetchChallenges()
}
}
private var currentSectionTitle: String {
switch selectedTab {
case .active: return "Active Challenges"
case .upcoming: return "Upcoming"
case .completed: return "Completed"
}
}
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
Text("Loading Challenges...")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.vertical, 60)
}
private var emptyStateView: some View {
VStack(spacing: 16) {
Image(systemName: "flag.fill")
.font(.system(size: 64))
.foregroundColor(.secondary)
Text("No \(selectedTab.rawValue) Challenges")
.font(.title2)
.fontWeight(.semibold)
Text(selectedTab == .active
? "Join or create a challenge to compete with others."
: selectedTab == .upcoming
? "New challenges will appear here."
: "Completed challenges are tracked here.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.vertical, 60)
}
}
struct CreateChallengeSheet: View {
@Environment(\.dismiss) var dismiss
@State private var title = ""
@State private var description = ""
@State private var challengeType: ChallengeType = .distance
@State private var targetMetric: ChallengeMetric = .distance
@State private var targetValue = ""
@State private var rules = ""
var body: some View {
NavigationView {
Form {
Section("Challenge Details") {
TextField("Challenge Title", text: $title)
TextField("Description", text: $description)
}
Section("Type") {
Picker("Challenge Type", selection: $challengeType) {
ForEach(ChallengeType.allCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
}
Section("Target") {
Picker("Metric", selection: $targetMetric) {
ForEach(ChallengeMetric.allCases, id: \.self) { metric in
Text(metric.displayName).tag(metric)
}
}
HStack {
TextField("Target Value", text: $targetValue)
.keyboardType(.decimalPad)
Text(targetMetric.unit)
.foregroundColor(.secondary)
}
}
Section("Optional") {
TextField("Rules", text: $rules)
}
}
.navigationTitle("Create Challenge")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Create") {
let endDate = Date().addingTimeInterval(30 * 24 * 3600)
let request = CreateChallengeRequest(
title: title,
description: description,
challengeType: challengeType,
startDate: Date(),
endDate: endDate,
targetMetric: targetMetric,
targetValue: Double(targetValue) ?? 0,
rules: rules.isEmpty ? nil : rules,
clubId: nil
)
dismiss()
}
.disabled(title.isEmpty || targetValue.isEmpty)
}
}
}
}
}
struct ChallengeRowView: View {
let challenge: Challenge
var body: some View {
VStack(spacing: 8) {
HStack(spacing: 12) {
Image(systemName: challenge.challengeType.icon)
.font(.system(size: 24))
.foregroundColor(challenge.challengeType.color)
.frame(width: 44, height: 44)
.background(challenge.challengeType.color.opacity(0.15))
.cornerRadius(10)
VStack(alignment: .leading, spacing: 4) {
Text(challenge.title)
.font(.headline)
Text("\(challenge.challengeType.displayName) \u2022 \(challenge.targetValue) \(challenge.targetUnit)")
.font(.subheadline)
.foregroundColor(.secondary)
HStack(spacing: 8) {
Text("\(challenge.participantCount) participants")
.font(.caption)
.foregroundColor(.secondary)
if challenge.daysRemaining > 0 {
Text("\(challenge.daysRemaining) days left")
.font(.caption2)
.foregroundColor(.orange)
}
}
}
Spacer()
switch challenge.participationStatus {
case .participating:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
case .invited:
Image(systemName: "mail.fill")
.foregroundColor(.blue)
case .notParticipating:
Image(systemName: "circle")
.foregroundColor(.secondary)
}
}
if challenge.participationStatus == .participating {
progressView
}
}
.padding(.vertical, 4)
}
private var progressView: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("\(challenge.progressPercentage, specifier: "%.0f")%")
.font(.caption2)
.fontWeight(.medium)
Spacer()
Text("\(challenge.userProgress ?? 0)/\(challenge.targetValue) \(challenge.targetUnit)")
.font(.caption2)
.foregroundColor(.secondary)
}
ProgressView(value: challenge.progressPercentage / 100)
.tint(challenge.challengeType.color)
}
.padding(.horizontal, 4)
}
}
#Preview {
ChallengesView()
}