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
263 lines
9.1 KiB
Swift
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()
|
|
}
|