Files
FrenoCorp/Lendair/Models/Challenge.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

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
}