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
316 lines
7.7 KiB
Swift
316 lines
7.7 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
// MARK: - Challenge
|
|
|
|
struct Challenge: Identifiable, Equatable, Codable {
|
|
let id: String
|
|
let title: String
|
|
let description: String
|
|
let challengeType: ChallengeType
|
|
var status: ChallengeStatus
|
|
let startDate: Date
|
|
let endDate: Date
|
|
let targetMetric: ChallengeMetric
|
|
let targetValue: Double
|
|
let targetUnit: String
|
|
var participantCount: Int
|
|
let rules: String?
|
|
let imageUrl: String?
|
|
let createdBy: String
|
|
let createdByName: String
|
|
let clubId: String?
|
|
var participationStatus: ParticipationStatus
|
|
var userProgress: Double?
|
|
let createdAt: Date
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, title, description, challengeType, status, startDate, endDate, targetMetric, targetValue, targetUnit, participantCount, rules, imageUrl, createdBy, createdByName, clubId, participationStatus, userProgress, createdAt
|
|
}
|
|
|
|
init(
|
|
id: String,
|
|
title: String,
|
|
description: String,
|
|
challengeType: ChallengeType,
|
|
status: ChallengeStatus,
|
|
startDate: Date,
|
|
endDate: Date,
|
|
targetMetric: ChallengeMetric,
|
|
targetValue: Double,
|
|
targetUnit: String,
|
|
participantCount: Int,
|
|
rules: String?,
|
|
imageUrl: String?,
|
|
createdBy: String,
|
|
createdByName: String,
|
|
clubId: String?,
|
|
participationStatus: ParticipationStatus,
|
|
userProgress: Double?,
|
|
createdAt: Date
|
|
) {
|
|
self.id = id
|
|
self.title = title
|
|
self.description = description
|
|
self.challengeType = challengeType
|
|
self.status = status
|
|
self.startDate = startDate
|
|
self.endDate = endDate
|
|
self.targetMetric = targetMetric
|
|
self.targetValue = targetValue
|
|
self.targetUnit = targetUnit
|
|
self.participantCount = participantCount
|
|
self.rules = rules
|
|
self.imageUrl = imageUrl
|
|
self.createdBy = createdBy
|
|
self.createdByName = createdByName
|
|
self.clubId = clubId
|
|
self.participationStatus = participationStatus
|
|
self.userProgress = userProgress
|
|
self.createdAt = createdAt
|
|
}
|
|
|
|
static func == (lhs: Challenge, rhs: Challenge) -> Bool {
|
|
lhs.id == rhs.id && lhs.participationStatus == rhs.participationStatus
|
|
}
|
|
|
|
var progressPercentage: Double {
|
|
guard let progress = userProgress else { return 0 }
|
|
return min((progress / targetValue) * 100, 100)
|
|
}
|
|
|
|
var daysRemaining: Int {
|
|
let calendar = Calendar.current
|
|
let components = calendar.dateComponents([.day], from: Date(), to: endDate)
|
|
return components.day ?? 0
|
|
}
|
|
|
|
var isUpcoming: Bool {
|
|
startDate > Date()
|
|
}
|
|
|
|
var isActive: Bool {
|
|
Date() >= startDate && Date() <= endDate
|
|
}
|
|
|
|
var isCompleted: Bool {
|
|
endDate < Date()
|
|
}
|
|
}
|
|
|
|
// MARK: - Challenge Type
|
|
|
|
enum ChallengeType: String, CaseIterable, Codable {
|
|
case distance
|
|
case time
|
|
case frequency
|
|
case elevation
|
|
case calories
|
|
case streak
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .distance: return "Distance"
|
|
case .time: return "Time"
|
|
case .frequency: return "Frequency"
|
|
case .elevation: return "Elevation"
|
|
case .calories: return "Calories"
|
|
case .streak: return "Streak"
|
|
}
|
|
}
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .distance: return "arrow.right.arrow.left"
|
|
case .time: return "stopwatch.fill"
|
|
case .frequency: return "repeat"
|
|
case .elevation: return "mountain.2.fill"
|
|
case .calories: return "flame.fill"
|
|
case .streak: return "calendar.badge.clock"
|
|
}
|
|
}
|
|
|
|
var color: Color {
|
|
switch self {
|
|
case .distance: return .blue
|
|
case .time: return .orange
|
|
case .frequency: return .green
|
|
case .elevation: return .brown
|
|
case .calories: return .red
|
|
case .streak: return .purple
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Challenge Status
|
|
|
|
enum ChallengeStatus: String, CaseIterable, Codable {
|
|
case upcoming
|
|
case active
|
|
case completed
|
|
case cancelled
|
|
}
|
|
|
|
// MARK: - Challenge Metric
|
|
|
|
enum ChallengeMetric: String, CaseIterable, Codable {
|
|
case distance
|
|
case time
|
|
case frequency
|
|
case elevation
|
|
case calories
|
|
|
|
var unit: String {
|
|
switch self {
|
|
case .distance: return "km"
|
|
case .time: return "min"
|
|
case .frequency: return "sessions"
|
|
case .elevation: return "m"
|
|
case .calories: return "kcal"
|
|
}
|
|
}
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .distance: return "Distance"
|
|
case .time: return "Time"
|
|
case .frequency: return "Sessions"
|
|
case .elevation: return "Elevation"
|
|
case .calories: return "Calories"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Participation Status
|
|
|
|
enum ParticipationStatus: String, CaseIterable, Codable {
|
|
case participating
|
|
case notParticipating
|
|
case invited
|
|
}
|
|
|
|
// MARK: - Challenge Participant
|
|
|
|
struct ChallengeParticipant: Identifiable, Codable {
|
|
let id: String
|
|
let name: String
|
|
let avatarUrl: String?
|
|
let progress: Double
|
|
let rank: Int
|
|
let joinedAt: Date
|
|
}
|
|
|
|
// MARK: - Leaderboard Entry
|
|
|
|
struct LeaderboardEntry: Identifiable, Codable {
|
|
let id: String
|
|
let position: Int
|
|
let participantId: String
|
|
let participantName: String
|
|
let participantAvatarUrl: String?
|
|
let progress: Double
|
|
let progressPercentage: Double
|
|
}
|
|
|
|
// MARK: - Progress Submission
|
|
|
|
struct ProgressSubmission: Encodable {
|
|
let metric: ChallengeMetric
|
|
let value: Double
|
|
let activityDate: Date
|
|
}
|
|
|
|
// MARK: - Create Challenge Request
|
|
|
|
struct CreateChallengeRequest: Encodable {
|
|
let title: String
|
|
let description: String
|
|
let challengeType: ChallengeType
|
|
let startDate: Date
|
|
let endDate: Date
|
|
let targetMetric: ChallengeMetric
|
|
let targetValue: Double
|
|
let rules: String?
|
|
let clubId: String?
|
|
}
|
|
|
|
// MARK: - Update Challenge Request
|
|
|
|
struct UpdateChallengeRequest: Encodable {
|
|
var title: String?
|
|
var description: String?
|
|
var challengeType: ChallengeType?
|
|
var startDate: Date?
|
|
var endDate: Date?
|
|
var targetMetric: ChallengeMetric?
|
|
var targetValue: Double?
|
|
var rules: String?
|
|
var status: ChallengeStatus?
|
|
}
|
|
|
|
// MARK: - Challenge Filter
|
|
|
|
struct ChallengeFilter: Encodable {
|
|
var challengeType: ChallengeType?
|
|
var status: ChallengeStatus?
|
|
var participationStatus: ParticipationStatus?
|
|
var clubId: String?
|
|
var limit: Int
|
|
var offset: Int
|
|
|
|
init(
|
|
challengeType: ChallengeType? = nil,
|
|
status: ChallengeStatus? = nil,
|
|
participationStatus: ParticipationStatus? = nil,
|
|
clubId: String? = nil,
|
|
limit: Int = 20,
|
|
offset: Int = 0
|
|
) {
|
|
self.challengeType = challengeType
|
|
self.status = status
|
|
self.participationStatus = participationStatus
|
|
self.clubId = clubId
|
|
self.limit = limit
|
|
self.offset = offset
|
|
}
|
|
}
|
|
|
|
// MARK: - API Response Types
|
|
|
|
struct ChallengeListResponse: Decodable {
|
|
let challenges: [Challenge]
|
|
let hasMore: Bool
|
|
}
|
|
|
|
struct ChallengeDetailResponse: Decodable {
|
|
let challenge: Challenge
|
|
let participants: [ChallengeParticipant]
|
|
}
|
|
|
|
struct CreateChallengeResponse: Decodable {
|
|
let challenge: Challenge
|
|
}
|
|
|
|
struct UpdateChallengeResponse: Decodable {
|
|
let challenge: Challenge
|
|
}
|
|
|
|
struct LeaderboardResponse: Decodable {
|
|
let entries: [LeaderboardEntry]
|
|
let userPosition: Int?
|
|
let totalParticipants: Int
|
|
}
|
|
|
|
struct ParticipationResponse: Decodable {
|
|
let success: Bool
|
|
let challengeId: String
|
|
let status: ParticipationStatus
|
|
}
|
|
|
|
struct ProgressResponse: Decodable {
|
|
let success: Bool
|
|
let challengeId: String
|
|
let newProgress: Double
|
|
let progressPercentage: Double
|
|
}
|