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
This commit is contained in:
315
Lendair/Models/Challenge.swift
Normal file
315
Lendair/Models/Challenge.swift
Normal file
@@ -0,0 +1,315 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user