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
|
||||
}
|
||||
275
Lendair/Models/Club.swift
Normal file
275
Lendair/Models/Club.swift
Normal file
@@ -0,0 +1,275 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Club
|
||||
|
||||
struct Club: Identifiable, Equatable, Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let description: String
|
||||
let clubType: ClubType
|
||||
let privacy: ClubPrivacy
|
||||
let location: String
|
||||
let latitude: Double?
|
||||
let longitude: Double?
|
||||
var memberCount: Int
|
||||
let maxMembers: Int?
|
||||
let imageUrl: String?
|
||||
let rules: String?
|
||||
let ownerId: String
|
||||
let ownerName: String
|
||||
var membershipStatus: MembershipStatus
|
||||
let createdAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, description, clubType, privacy, location, latitude, longitude, memberCount, maxMembers, imageUrl, rules, ownerId, ownerName, membershipStatus, createdAt
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
clubType: ClubType,
|
||||
privacy: ClubPrivacy,
|
||||
location: String,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
memberCount: Int,
|
||||
maxMembers: Int?,
|
||||
imageUrl: String?,
|
||||
rules: String?,
|
||||
ownerId: String,
|
||||
ownerName: String,
|
||||
membershipStatus: MembershipStatus,
|
||||
createdAt: Date
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.clubType = clubType
|
||||
self.privacy = privacy
|
||||
self.location = location
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.memberCount = memberCount
|
||||
self.maxMembers = maxMembers
|
||||
self.imageUrl = imageUrl
|
||||
self.rules = rules
|
||||
self.ownerId = ownerId
|
||||
self.ownerName = ownerName
|
||||
self.membershipStatus = membershipStatus
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
static func == (lhs: Club, rhs: Club) -> Bool {
|
||||
lhs.id == rhs.id && lhs.membershipStatus == rhs.membershipStatus
|
||||
}
|
||||
|
||||
var availableSpots: Int? {
|
||||
guard let max = maxMembers else { return nil }
|
||||
return max - memberCount
|
||||
}
|
||||
|
||||
var isFull: Bool {
|
||||
guard let max = maxMembers else { return false }
|
||||
return memberCount >= max
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Club Type
|
||||
|
||||
enum ClubType: String, CaseIterable, Codable {
|
||||
case running
|
||||
case walking
|
||||
case cycling
|
||||
case triathlon
|
||||
case crossfit
|
||||
case general
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .running: return "Running"
|
||||
case .walking: return "Walking"
|
||||
case .cycling: return "Cycling"
|
||||
case .triathlon: return "Triathlon"
|
||||
case .crossfit: return "CrossFit"
|
||||
case .general: return "General Fitness"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .running: return "figure.run"
|
||||
case .walking: return "figure.walk"
|
||||
case .cycling: return "bicycle"
|
||||
case .triathlon: return "triangle.fill"
|
||||
case .crossfit: return "dumbbell.fill"
|
||||
case .general: return "heart.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .running: return .blue
|
||||
case .walking: return .green
|
||||
case .cycling: return .orange
|
||||
case .triathlon: return .purple
|
||||
case .crossfit: return .red
|
||||
case .general: return .indigo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Club Privacy
|
||||
|
||||
enum ClubPrivacy: String, CaseIterable, Codable {
|
||||
case publicPrivacy
|
||||
case privateClub
|
||||
case invitationOnly
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .publicPrivacy: return "Public"
|
||||
case .privateClub: return "Private"
|
||||
case .invitationOnly: return "Invitation Only"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .publicPrivacy: return "globe"
|
||||
case .privateClub: return "lock.fill"
|
||||
case .invitationOnly: return "mail.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Membership Status
|
||||
|
||||
enum MembershipStatus: String, CaseIterable, Codable {
|
||||
case active
|
||||
case pending
|
||||
case invited
|
||||
case left
|
||||
}
|
||||
|
||||
// MARK: - Club Member
|
||||
|
||||
struct ClubMember: Identifiable, Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let avatarUrl: String?
|
||||
let role: MemberRole
|
||||
let joinedAt: Date
|
||||
let membershipStatus: MembershipStatus
|
||||
}
|
||||
|
||||
enum MemberRole: String, CaseIterable, Codable {
|
||||
case owner
|
||||
case admin
|
||||
case member
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .owner: return "Owner"
|
||||
case .admin: return "Admin"
|
||||
case .member: return "Member"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Create Club Request
|
||||
|
||||
struct CreateClubRequest: Encodable {
|
||||
let name: String
|
||||
let description: String
|
||||
let clubType: ClubType
|
||||
let privacy: ClubPrivacy
|
||||
let location: String
|
||||
let latitude: Double?
|
||||
let longitude: Double?
|
||||
let maxMembers: Int?
|
||||
let rules: String?
|
||||
}
|
||||
|
||||
// MARK: - Update Club Request
|
||||
|
||||
struct UpdateClubRequest: Encodable {
|
||||
var name: String?
|
||||
var description: String?
|
||||
var clubType: ClubType?
|
||||
var privacy: ClubPrivacy?
|
||||
var location: String?
|
||||
var latitude: Double?
|
||||
var longitude: Double?
|
||||
var maxMembers: Int?
|
||||
var rules: String?
|
||||
}
|
||||
|
||||
// MARK: - Club Filter
|
||||
|
||||
struct ClubFilter: Encodable {
|
||||
var clubType: ClubType?
|
||||
var privacy: ClubPrivacy?
|
||||
var membershipStatus: MembershipStatus?
|
||||
var location: String?
|
||||
var radiusKm: Double?
|
||||
var limit: Int
|
||||
var offset: Int
|
||||
|
||||
init(
|
||||
clubType: ClubType? = nil,
|
||||
privacy: ClubPrivacy? = nil,
|
||||
membershipStatus: MembershipStatus? = nil,
|
||||
location: String? = nil,
|
||||
radiusKm: Double? = nil,
|
||||
limit: Int = 20,
|
||||
offset: Int = 0
|
||||
) {
|
||||
self.clubType = clubType
|
||||
self.privacy = privacy
|
||||
self.membershipStatus = membershipStatus
|
||||
self.location = location
|
||||
self.radiusKm = radiusKm
|
||||
self.limit = limit
|
||||
self.offset = offset
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Response Types
|
||||
|
||||
struct ClubListResponse: Decodable {
|
||||
let clubs: [Club]
|
||||
let hasMore: Bool
|
||||
}
|
||||
|
||||
struct ClubDetailResponse: Decodable {
|
||||
let club: Club
|
||||
let members: [ClubMember]
|
||||
}
|
||||
|
||||
struct CreateClubResponse: Decodable {
|
||||
let club: Club
|
||||
}
|
||||
|
||||
struct UpdateClubResponse: Decodable {
|
||||
let club: Club
|
||||
}
|
||||
|
||||
struct MembershipResponse: Decodable {
|
||||
let success: Bool
|
||||
let clubId: String
|
||||
let status: MembershipStatus
|
||||
}
|
||||
|
||||
struct InviteMemberResponse: Decodable {
|
||||
let success: Bool
|
||||
let clubId: String
|
||||
let memberId: String
|
||||
}
|
||||
|
||||
struct RemoveMemberResponse: Decodable {
|
||||
let success: Bool
|
||||
let clubId: String
|
||||
let memberId: String
|
||||
}
|
||||
@@ -20,21 +20,27 @@ Lendair/
|
||||
│ ├── Race.swift # Race, RaceType, RaceFilter, API response types
|
||||
│ ├── FamilyPlan.swift # FamilyPlan, FamilyMember, LeaderboardMetric
|
||||
│ ├── BeginnerMode.swift # BeginnerConfig, Milestone, OnboardingStep
|
||||
│ └── CommunityEvent.swift # CommunityEvent, EventType, RSVPStatus
|
||||
│ ├── CommunityEvent.swift # CommunityEvent, EventType, RSVPStatus
|
||||
│ ├── Club.swift # Club, ClubType, ClubPrivacy, MembershipStatus, ClubMember
|
||||
│ └── Challenge.swift # Challenge, ChallengeType, ChallengeStatus, LeaderboardEntry
|
||||
├── Services/
|
||||
│ ├── NotificationService.swift # NotificationsServiceProtocol + implementation
|
||||
│ ├── TrainingPlanService.swift # TrainingPlanServiceProtocol + implementation
|
||||
│ ├── RaceService.swift # RaceServiceProtocol + implementation
|
||||
│ ├── FamilyPlanService.swift # FamilyPlanServiceProtocol + implementation
|
||||
│ ├── BeginnerModeService.swift # BeginnerModeServiceProtocol + implementation
|
||||
│ └── CommunityEventService.swift # CommunityEventServiceProtocol + implementation
|
||||
│ ├── CommunityEventService.swift # CommunityEventServiceProtocol + implementation
|
||||
│ ├── ClubService.swift # ClubServiceProtocol + implementation
|
||||
│ └── ChallengeService.swift # ChallengeServiceProtocol + implementation
|
||||
├── ViewModels/
|
||||
│ ├── NotificationsViewModel.swift
|
||||
│ ├── TrainingPlanViewModel.swift
|
||||
│ ├── RaceDiscoveryViewModel.swift
|
||||
│ ├── FamilyPlanViewModel.swift
|
||||
│ ├── BeginnerModeViewModel.swift
|
||||
│ └── CommunityEventViewModel.swift
|
||||
│ ├── CommunityEventViewModel.swift
|
||||
│ ├── ClubViewModel.swift
|
||||
│ └── ChallengeViewModel.swift
|
||||
├── Views/
|
||||
│ ├── NotificationsView.swift
|
||||
│ ├── NotificationRowView.swift
|
||||
@@ -47,7 +53,11 @@ Lendair/
|
||||
│ ├── FamilyMemberView.swift
|
||||
│ ├── BeginnerModeView.swift
|
||||
│ ├── CommunityEventsView.swift
|
||||
│ └── CommunityEventDetailView.swift
|
||||
│ ├── CommunityEventDetailView.swift
|
||||
│ ├── ClubsView.swift
|
||||
│ ├── ClubDetailView.swift
|
||||
│ ├── ChallengesView.swift
|
||||
│ └── ChallengeDetailView.swift
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -95,6 +105,24 @@ Lendair/
|
||||
- Participant tracking
|
||||
- Upcoming/ongoing/past event categorization
|
||||
|
||||
### Clubs (Phase 2 - Community)
|
||||
- Persistent community groups for runners and fitness enthusiasts
|
||||
- Club types: Running, Walking, Cycling, Triathlon, CrossFit, General Fitness
|
||||
- Privacy levels: Public, Private, Invitation Only
|
||||
- Member management with roles (Owner, Admin, Member)
|
||||
- Invite members via email
|
||||
- Club rules and capacity limits
|
||||
- Discover clubs by type, location, and privacy
|
||||
|
||||
### Challenges (Phase 2 - Community)
|
||||
- Time-bound competitive goals with progress tracking
|
||||
- Challenge types: Distance, Time, Frequency, Elevation, Calories, Streak
|
||||
- Real-time leaderboard with rankings
|
||||
- Progress submission and percentage tracking
|
||||
- Join/leave challenges
|
||||
- Create custom challenges with rules and targets
|
||||
- Active/upcoming/completed challenge categorization
|
||||
|
||||
## Service Pattern
|
||||
|
||||
All services follow the same architecture:
|
||||
@@ -154,23 +182,48 @@ All services follow the same architecture:
|
||||
| PATCH | `/api/events/:id` | Update event |
|
||||
| POST | `/api/events/:id/rsvp` | RSVP to event |
|
||||
|
||||
### Clubs
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/clubs?type=&privacy=&...` | List clubs with filters |
|
||||
| GET | `/api/clubs/:id` | Get club detail with members |
|
||||
| POST | `/api/clubs` | Create club |
|
||||
| PATCH | `/api/clubs/:id` | Update club |
|
||||
| POST | `/api/clubs/:id/join` | Join club |
|
||||
| POST | `/api/clubs/:id/leave` | Leave club |
|
||||
| POST | `/api/clubs/:id/invite` | Invite member by email |
|
||||
| DELETE | `/api/clubs/:id/members/:memberId` | Remove member |
|
||||
|
||||
### Challenges
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/challenges?type=&status=&...` | List challenges with filters |
|
||||
| GET | `/api/challenges/:id` | Get challenge detail with participants |
|
||||
| POST | `/api/challenges` | Create challenge |
|
||||
| PATCH | `/api/challenges/:id` | Update challenge |
|
||||
| POST | `/api/challenges/:id/join` | Join challenge |
|
||||
| POST | `/api/challenges/:id/leave` | Leave challenge |
|
||||
| GET | `/api/challenges/:id/leaderboard` | Get challenge leaderboard |
|
||||
| POST | `/api/challenges/:id/progress` | Submit progress |
|
||||
|
||||
## Testing
|
||||
|
||||
Tests are in `LendairTests/`:
|
||||
- Uses mock services conforming to feature protocols
|
||||
- ViewModel tests cover fetch, update, error handling, and computed properties
|
||||
- Model tests cover enum cases, display values, and equality
|
||||
- Model tests cover enum cases, display values, equality, and computed properties
|
||||
- Available test files: `NotificationServiceTests.swift`, `ClubServiceTests.swift`, `ChallengeServiceTests.swift`
|
||||
|
||||
## Usage
|
||||
|
||||
```swift
|
||||
// Feature views can be integrated into your navigation stack
|
||||
NavigationStack {
|
||||
TrainingPlanView()
|
||||
ClubsView()
|
||||
}
|
||||
|
||||
NavigationStack {
|
||||
RaceDiscoveryView()
|
||||
ChallengesView()
|
||||
}
|
||||
|
||||
NavigationStack {
|
||||
|
||||
183
Lendair/Services/ChallengeService.swift
Normal file
183
Lendair/Services/ChallengeService.swift
Normal file
@@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Service Protocol
|
||||
|
||||
protocol ChallengeServiceProtocol: Sendable {
|
||||
func listChallenges(filter: ChallengeFilter) async throws -> [Challenge]
|
||||
func getChallenge(id: String) async throws -> (challenge: Challenge, participants: [ChallengeParticipant])
|
||||
func createChallenge(request: CreateChallengeRequest) async throws -> Challenge
|
||||
func updateChallenge(id: String, request: UpdateChallengeRequest) async throws -> Challenge
|
||||
func joinChallenge(id: String) async throws
|
||||
func leaveChallenge(id: String) async throws
|
||||
func getLeaderboard(challengeId: String) async throws -> [LeaderboardEntry]
|
||||
func submitProgress(challengeId: String, progress: ProgressSubmission) async throws -> (progress: Double, percentage: Double)
|
||||
}
|
||||
|
||||
// MARK: - Default Service
|
||||
|
||||
class ChallengeService: ChallengeServiceProtocol {
|
||||
private let baseURL: URL
|
||||
private let session: URLSession
|
||||
private let authToken: String?
|
||||
|
||||
init(
|
||||
baseURL: URL = URL(string: "http://localhost:3000")!,
|
||||
session: URLSession = .shared,
|
||||
authToken: String? = nil
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.session = session
|
||||
self.authToken = authToken
|
||||
}
|
||||
|
||||
func listChallenges(filter: ChallengeFilter = ChallengeFilter()) async throws -> [Challenge] {
|
||||
var components = URLComponents(url: baseURL.appendingPathComponent("/api/challenges"), resolvingAgainstBaseURL: true)!
|
||||
var queryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "limit", value: String(filter.limit)),
|
||||
URLQueryItem(name: "offset", value: String(filter.offset))
|
||||
]
|
||||
if let type = filter.challengeType { queryItems.append(URLQueryItem(name: "type", value: type.rawValue)) }
|
||||
if let status = filter.status { queryItems.append(URLQueryItem(name: "status", value: status.rawValue)) }
|
||||
if let participation = filter.participationStatus { queryItems.append(URLQueryItem(name: "participation", value: participation.rawValue)) }
|
||||
if let clubId = filter.clubId { queryItems.append(URLQueryItem(name: "clubId", value: clubId)) }
|
||||
components.queryItems = queryItems
|
||||
|
||||
let request = try buildRequest(url: components.url!)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
let decoded = try JSONDecoder().decode(ChallengeListResponse.self, from: data)
|
||||
return decoded.challenges
|
||||
}
|
||||
|
||||
func getChallenge(id: String) async throws -> (challenge: Challenge, participants: [ChallengeParticipant]) {
|
||||
let url = baseURL.appendingPathComponent("/api/challenges/\(id)")
|
||||
let request = try buildRequest(url: url)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
let decoded = try JSONDecoder().decode(ChallengeDetailResponse.self, from: data)
|
||||
return (decoded.challenge, decoded.participants)
|
||||
}
|
||||
|
||||
func createChallenge(request: CreateChallengeRequest) async throws -> Challenge {
|
||||
let url = baseURL.appendingPathComponent("/api/challenges")
|
||||
var request = try buildRequest(url: url, method: .post)
|
||||
request.httpBody = try JSONEncoder().encode(request)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
let decoded = try JSONDecoder().decode(CreateChallengeResponse.self, from: data)
|
||||
return decoded.challenge
|
||||
}
|
||||
|
||||
func updateChallenge(id: String, request: UpdateChallengeRequest) async throws -> Challenge {
|
||||
let url = baseURL.appendingPathComponent("/api/challenges/\(id)")
|
||||
var request = try buildRequest(url: url, method: .patch)
|
||||
request.httpBody = try JSONEncoder().encode(request)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
let decoded = try JSONDecoder().decode(UpdateChallengeResponse.self, from: data)
|
||||
return decoded.challenge
|
||||
}
|
||||
|
||||
func joinChallenge(id: String) async throws {
|
||||
let url = baseURL.appendingPathComponent("/api/challenges/\(id)/join")
|
||||
let request = try buildRequest(url: url, method: .post)
|
||||
|
||||
let (_, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
}
|
||||
|
||||
func leaveChallenge(id: String) async throws {
|
||||
let url = baseURL.appendingPathComponent("/api/challenges/\(id)/leave")
|
||||
let request = try buildRequest(url: url, method: .post)
|
||||
|
||||
let (_, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
}
|
||||
|
||||
func getLeaderboard(challengeId: String) async throws -> [LeaderboardEntry] {
|
||||
let url = baseURL.appendingPathComponent("/api/challenges/\(challengeId)/leaderboard")
|
||||
let request = try buildRequest(url: url)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
let decoded = try JSONDecoder().decode(LeaderboardResponse.self, from: data)
|
||||
return decoded.entries
|
||||
}
|
||||
|
||||
func submitProgress(challengeId: String, progress: ProgressSubmission) async throws -> (progress: Double, percentage: Double) {
|
||||
let url = baseURL.appendingPathComponent("/api/challenges/\(challengeId)/progress")
|
||||
var request = try buildRequest(url: url, method: .post)
|
||||
request.httpBody = try JSONEncoder().encode(progress)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
let decoded = try JSONDecoder().decode(ProgressResponse.self, from: data)
|
||||
return (decoded.newProgress, decoded.progressPercentage)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
if let token = authToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
if let body = body {
|
||||
request.httpBody = body
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
private func validateResponse(_ response: URLResponse) throws {
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw ChallengeError.invalidResponse
|
||||
}
|
||||
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
switch httpResponse.statusCode {
|
||||
case 401: throw ChallengeError.unauthorized
|
||||
case 403: throw ChallengeError.forbidden
|
||||
case 404: throw ChallengeError.notFound
|
||||
case 429: throw ChallengeError.rateLimited
|
||||
case 500...599: throw ChallengeError.serverError(httpResponse.statusCode)
|
||||
default: throw ChallengeError.httpError(httpResponse.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
enum ChallengeError: LocalizedError {
|
||||
case invalidResponse
|
||||
case unauthorized
|
||||
case forbidden
|
||||
case notFound
|
||||
case rateLimited
|
||||
case serverError(Int)
|
||||
case httpError(Int)
|
||||
|
||||
var errorDescription: String {
|
||||
switch self {
|
||||
case .invalidResponse: return "Invalid server response"
|
||||
case .unauthorized: return "Unauthorized — please log in again"
|
||||
case .forbidden: return "Forbidden — check permissions"
|
||||
case .notFound: return "Challenge not found"
|
||||
case .rateLimited: return "Too many requests — try again shortly"
|
||||
case .serverError(let code): return "Server error (\(code))"
|
||||
case .httpError(let code): return "HTTP error (\(code))"
|
||||
}
|
||||
}
|
||||
}
|
||||
179
Lendair/Services/ClubService.swift
Normal file
179
Lendair/Services/ClubService.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Service Protocol
|
||||
|
||||
protocol ClubServiceProtocol: Sendable {
|
||||
func listClubs(filter: ClubFilter) async throws -> [Club]
|
||||
func getClub(id: String) async throws -> (club: Club, members: [ClubMember])
|
||||
func createClub(request: CreateClubRequest) async throws -> Club
|
||||
func updateClub(id: String, request: UpdateClubRequest) async throws -> Club
|
||||
func joinClub(id: String) async throws
|
||||
func leaveClub(id: String) async throws
|
||||
func inviteMember(clubId: String, email: String) async throws
|
||||
func removeMember(clubId: String, memberId: String) async throws
|
||||
}
|
||||
|
||||
// MARK: - Default Service
|
||||
|
||||
class ClubService: ClubServiceProtocol {
|
||||
private let baseURL: URL
|
||||
private let session: URLSession
|
||||
private let authToken: String?
|
||||
|
||||
init(
|
||||
baseURL: URL = URL(string: "http://localhost:3000")!,
|
||||
session: URLSession = .shared,
|
||||
authToken: String? = nil
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.session = session
|
||||
self.authToken = authToken
|
||||
}
|
||||
|
||||
func listClubs(filter: ClubFilter = ClubFilter()) async throws -> [Club] {
|
||||
var components = URLComponents(url: baseURL.appendingPathComponent("/api/clubs"), resolvingAgainstBaseURL: true)!
|
||||
var queryItems: [URLQueryItem] = [
|
||||
URLQueryItem(name: "limit", value: String(filter.limit)),
|
||||
URLQueryItem(name: "offset", value: String(filter.offset))
|
||||
]
|
||||
if let type = filter.clubType { queryItems.append(URLQueryItem(name: "type", value: type.rawValue)) }
|
||||
if let privacy = filter.privacy { queryItems.append(URLQueryItem(name: "privacy", value: privacy.rawValue)) }
|
||||
if let status = filter.membershipStatus { queryItems.append(URLQueryItem(name: "status", value: status.rawValue)) }
|
||||
if let location = filter.location { queryItems.append(URLQueryItem(name: "location", value: location)) }
|
||||
if let radius = filter.radiusKm { queryItems.append(URLQueryItem(name: "radius", value: String(radius))) }
|
||||
components.queryItems = queryItems
|
||||
|
||||
let request = try buildRequest(url: components.url!)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
let decoded = try JSONDecoder().decode(ClubListResponse.self, from: data)
|
||||
return decoded.clubs
|
||||
}
|
||||
|
||||
func getClub(id: String) async throws -> (club: Club, members: [ClubMember]) {
|
||||
let url = baseURL.appendingPathComponent("/api/clubs/\(id)")
|
||||
let request = try buildRequest(url: url)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
let decoded = try JSONDecoder().decode(ClubDetailResponse.self, from: data)
|
||||
return (decoded.club, decoded.members)
|
||||
}
|
||||
|
||||
func createClub(request: CreateClubRequest) async throws -> Club {
|
||||
let url = baseURL.appendingPathComponent("/api/clubs")
|
||||
var request = try buildRequest(url: url, method: .post)
|
||||
request.httpBody = try JSONEncoder().encode(request)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
let decoded = try JSONDecoder().decode(CreateClubResponse.self, from: data)
|
||||
return decoded.club
|
||||
}
|
||||
|
||||
func updateClub(id: String, request: UpdateClubRequest) async throws -> Club {
|
||||
let url = baseURL.appendingPathComponent("/api/clubs/\(id)")
|
||||
var request = try buildRequest(url: url, method: .patch)
|
||||
request.httpBody = try JSONEncoder().encode(request)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
|
||||
let decoded = try JSONDecoder().decode(UpdateClubResponse.self, from: data)
|
||||
return decoded.club
|
||||
}
|
||||
|
||||
func joinClub(id: String) async throws {
|
||||
let url = baseURL.appendingPathComponent("/api/clubs/\(id)/join")
|
||||
let request = try buildRequest(url: url, method: .post)
|
||||
|
||||
let (_, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
}
|
||||
|
||||
func leaveClub(id: String) async throws {
|
||||
let url = baseURL.appendingPathComponent("/api/clubs/\(id)/leave")
|
||||
let request = try buildRequest(url: url, method: .post)
|
||||
|
||||
let (_, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
}
|
||||
|
||||
func inviteMember(clubId: String, email: String) async throws {
|
||||
let url = baseURL.appendingPathComponent("/api/clubs/\(clubId)/invite")
|
||||
var request = try buildRequest(url: url, method: .post)
|
||||
request.httpBody = try JSONEncoder().encode(["email": email])
|
||||
|
||||
let (_, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
}
|
||||
|
||||
func removeMember(clubId: String, memberId: String) async throws {
|
||||
let url = baseURL.appendingPathComponent("/api/clubs/\(clubId)/members/\(memberId)")
|
||||
let request = try buildRequest(url: url, method: .delete)
|
||||
|
||||
let (_, response) = try await session.data(for: request)
|
||||
try validateResponse(response)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func buildRequest(url: URL, method: HTTPMethod = .get, body: Data? = nil) throws -> URLRequest {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
if let token = authToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
if let body = body {
|
||||
request.httpBody = body
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
private func validateResponse(_ response: URLResponse) throws {
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw ClubError.invalidResponse
|
||||
}
|
||||
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
switch httpResponse.statusCode {
|
||||
case 401: throw ClubError.unauthorized
|
||||
case 403: throw ClubError.forbidden
|
||||
case 404: throw ClubError.notFound
|
||||
case 429: throw ClubError.rateLimited
|
||||
case 500...599: throw ClubError.serverError(httpResponse.statusCode)
|
||||
default: throw ClubError.httpError(httpResponse.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
enum ClubError: LocalizedError {
|
||||
case invalidResponse
|
||||
case unauthorized
|
||||
case forbidden
|
||||
case notFound
|
||||
case rateLimited
|
||||
case serverError(Int)
|
||||
case httpError(Int)
|
||||
|
||||
var errorDescription: String {
|
||||
switch self {
|
||||
case .invalidResponse: return "Invalid server response"
|
||||
case .unauthorized: return "Unauthorized — please log in again"
|
||||
case .forbidden: return "Forbidden — check permissions"
|
||||
case .notFound: return "Club not found"
|
||||
case .rateLimited: return "Too many requests — try again shortly"
|
||||
case .serverError(let code): return "Server error (\(code))"
|
||||
case .httpError(let code): return "HTTP error (\(code))"
|
||||
}
|
||||
}
|
||||
}
|
||||
165
Lendair/ViewModels/ChallengeViewModel.swift
Normal file
165
Lendair/ViewModels/ChallengeViewModel.swift
Normal file
@@ -0,0 +1,165 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class ChallengeViewModel: ObservableObject {
|
||||
@Published var challenges: [Challenge] = []
|
||||
@Published var selectedChallenge: Challenge?
|
||||
@Published var leaderboard: [LeaderboardEntry] = []
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var error: ChallengeError?
|
||||
@Published var filter: ChallengeFilter = ChallengeFilter()
|
||||
|
||||
private let service: ChallengeServiceProtocol
|
||||
|
||||
init(service: ChallengeServiceProtocol = ChallengeService()) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
func fetchChallenges() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
challenges = try await service.listChallenges(filter: filter)
|
||||
} catch let error as ChallengeError {
|
||||
self.error = error
|
||||
} catch {
|
||||
print("Failed to fetch challenges: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func selectChallenge(id: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let result = try await service.getChallenge(id: id)
|
||||
selectedChallenge = result.challenge
|
||||
if let index = challenges.firstIndex(where: { $0.id == id }) {
|
||||
challenges[index] = result.challenge
|
||||
objectWillChange.send()
|
||||
}
|
||||
} catch let error as ChallengeError {
|
||||
self.error = error
|
||||
} catch {
|
||||
print("Failed to get challenge: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func createChallenge(request: CreateChallengeRequest) async -> Challenge? {
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let challenge = try await service.createChallenge(request: request)
|
||||
challenges.insert(challenge, at: 0)
|
||||
objectWillChange.send()
|
||||
return challenge
|
||||
} catch let error as ChallengeError {
|
||||
self.error = error
|
||||
return nil
|
||||
} catch {
|
||||
print("Failed to create challenge: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func updateChallenge(id: String, request: UpdateChallengeRequest) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let updatedChallenge = try await service.updateChallenge(id: id, request: request)
|
||||
if let index = challenges.firstIndex(where: { $0.id == id }) {
|
||||
challenges[index] = updatedChallenge
|
||||
objectWillChange.send()
|
||||
}
|
||||
if selectedChallenge?.id == id {
|
||||
selectedChallenge = updatedChallenge
|
||||
}
|
||||
} catch let error as ChallengeError {
|
||||
self.error = error
|
||||
} catch {
|
||||
print("Failed to update challenge: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func joinChallenge(id: String) async {
|
||||
do {
|
||||
try await service.joinChallenge(id: id)
|
||||
if let index = challenges.firstIndex(where: { $0.id == id }) {
|
||||
challenges[index].participationStatus = .participating
|
||||
challenges[index].participantCount += 1
|
||||
objectWillChange.send()
|
||||
}
|
||||
} catch {
|
||||
print("Failed to join challenge: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func leaveChallenge(id: String) async {
|
||||
do {
|
||||
try await service.leaveChallenge(id: id)
|
||||
if let index = challenges.firstIndex(where: { $0.id == id }) {
|
||||
challenges[index].participationStatus = .notParticipating
|
||||
challenges[index].participantCount = max(0, challenges[index].participantCount - 1)
|
||||
objectWillChange.send()
|
||||
}
|
||||
} catch {
|
||||
print("Failed to leave challenge: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func fetchLeaderboard(challengeId: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
leaderboard = try await service.getLeaderboard(challengeId: challengeId)
|
||||
} catch let error as ChallengeError {
|
||||
self.error = error
|
||||
} catch {
|
||||
print("Failed to fetch leaderboard: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func submitProgress(challengeId: String, progress: ProgressSubmission) async {
|
||||
do {
|
||||
let result = try await service.submitProgress(challengeId: challengeId, progress: progress)
|
||||
if let index = challenges.firstIndex(where: { $0.id == challengeId }) {
|
||||
challenges[index].userProgress = result.progress
|
||||
objectWillChange.send()
|
||||
}
|
||||
if selectedChallenge?.id == challengeId {
|
||||
selectedChallenge?.userProgress = result.progress
|
||||
}
|
||||
} catch {
|
||||
print("Failed to submit progress: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
var activeChallenges: [Challenge] {
|
||||
challenges.filter { $0.isActive }.sorted { $0.endDate < $1.endDate }
|
||||
}
|
||||
|
||||
var upcomingChallenges: [Challenge] {
|
||||
challenges.filter { $0.isUpcoming }.sorted { $0.startDate < $1.startDate }
|
||||
}
|
||||
|
||||
var completedChallenges: [Challenge] {
|
||||
challenges.filter { $0.isCompleted }.sorted { $0.endDate > $1.endDate }
|
||||
}
|
||||
|
||||
var userChallenges: [Challenge] {
|
||||
challenges.filter { $0.participationStatus == .participating }
|
||||
}
|
||||
|
||||
var challengeTypes: [ChallengeType] { ChallengeType.allCases }
|
||||
var challengeStatuses: [ChallengeStatus] { ChallengeStatus.allCases }
|
||||
}
|
||||
156
Lendair/ViewModels/ClubViewModel.swift
Normal file
156
Lendair/ViewModels/ClubViewModel.swift
Normal file
@@ -0,0 +1,156 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class ClubViewModel: ObservableObject {
|
||||
@Published var clubs: [Club] = []
|
||||
@Published var selectedClub: Club?
|
||||
@Published var members: [ClubMember] = []
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var error: ClubError?
|
||||
@Published var filter: ClubFilter = ClubFilter()
|
||||
|
||||
private let service: ClubServiceProtocol
|
||||
|
||||
init(service: ClubServiceProtocol = ClubService()) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
func fetchClubs() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
clubs = try await service.listClubs(filter: filter)
|
||||
} catch let error as ClubError {
|
||||
self.error = error
|
||||
} catch {
|
||||
print("Failed to fetch clubs: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func selectClub(id: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let result = try await service.getClub(id: id)
|
||||
selectedClub = result.club
|
||||
members = result.members
|
||||
if let index = clubs.firstIndex(where: { $0.id == id }) {
|
||||
clubs[index] = result.club
|
||||
objectWillChange.send()
|
||||
}
|
||||
} catch let error as ClubError {
|
||||
self.error = error
|
||||
} catch {
|
||||
print("Failed to get club: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func createClub(request: CreateClubRequest) async -> Club? {
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let club = try await service.createClub(request: request)
|
||||
clubs.insert(club, at: 0)
|
||||
objectWillChange.send()
|
||||
return club
|
||||
} catch let error as ClubError {
|
||||
self.error = error
|
||||
return nil
|
||||
} catch {
|
||||
print("Failed to create club: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func updateClub(id: String, request: UpdateClubRequest) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let updatedClub = try await service.updateClub(id: id, request: request)
|
||||
if let index = clubs.firstIndex(where: { $0.id == id }) {
|
||||
clubs[index] = updatedClub
|
||||
objectWillChange.send()
|
||||
}
|
||||
if selectedClub?.id == id {
|
||||
selectedClub = updatedClub
|
||||
}
|
||||
} catch let error as ClubError {
|
||||
self.error = error
|
||||
} catch {
|
||||
print("Failed to update club: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func joinClub(id: String) async {
|
||||
do {
|
||||
try await service.joinClub(id: id)
|
||||
if let index = clubs.firstIndex(where: { $0.id == id }) {
|
||||
clubs[index].membershipStatus = .active
|
||||
clubs[index].memberCount += 1
|
||||
objectWillChange.send()
|
||||
}
|
||||
} catch {
|
||||
print("Failed to join club: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func leaveClub(id: String) async {
|
||||
do {
|
||||
try await service.leaveClub(id: id)
|
||||
if let index = clubs.firstIndex(where: { $0.id == id }) {
|
||||
clubs[index].membershipStatus = .left
|
||||
clubs[index].memberCount = max(0, clubs[index].memberCount - 1)
|
||||
objectWillChange.send()
|
||||
}
|
||||
} catch {
|
||||
print("Failed to leave club: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func inviteMember(clubId: String, email: String) async {
|
||||
do {
|
||||
try await service.inviteMember(clubId: clubId, email: email)
|
||||
} catch {
|
||||
print("Failed to invite member: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func removeMember(clubId: String, memberId: String) async {
|
||||
do {
|
||||
try await service.removeMember(clubId: clubId, memberId: memberId)
|
||||
if let index = members.firstIndex(where: { $0.id == memberId }) {
|
||||
members.remove(at: index)
|
||||
if let clubIndex = clubs.firstIndex(where: { $0.id == clubId }) {
|
||||
clubs[clubIndex].memberCount = max(0, clubs[clubIndex].memberCount - 1)
|
||||
}
|
||||
objectWillChange.send()
|
||||
}
|
||||
} catch {
|
||||
print("Failed to remove member: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
var publicClubs: [Club] {
|
||||
clubs.filter { $0.privacy == .publicPrivacy }
|
||||
}
|
||||
|
||||
var userClubs: [Club] {
|
||||
clubs.filter { $0.membershipStatus == .active }
|
||||
}
|
||||
|
||||
var pendingClubs: [Club] {
|
||||
clubs.filter { $0.membershipStatus == .pending }
|
||||
}
|
||||
|
||||
var clubTypes: [ClubType] { ClubType.allCases }
|
||||
var privacyOptions: [ClubPrivacy] { ClubPrivacy.allCases }
|
||||
}
|
||||
272
Lendair/Views/ChallengeDetailView.swift
Normal file
272
Lendair/Views/ChallengeDetailView.swift
Normal file
@@ -0,0 +1,272 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChallengeDetailView: View {
|
||||
let challenge: Challenge
|
||||
@StateObject private var viewModel = ChallengeViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
challengeHeader
|
||||
challengeInfoSection
|
||||
challengeDescription
|
||||
progressSection
|
||||
participationSection
|
||||
leaderboardSection
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.navigationTitle(challenge.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.selectChallenge(id: challenge.id)
|
||||
await viewModel.fetchLeaderboard(challengeId: challenge.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var challengeHeader: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: challenge.challengeType.icon)
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(challenge.challengeType.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(challenge.challengeType.displayName)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
Text("\(challenge.targetValue) \(challenge.targetUnit) goal")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 20) {
|
||||
infoItem(label: "Status", value: challenge.status.rawValue.capitalized)
|
||||
infoItem(label: "Participants", value: "\(challenge.participantCount)")
|
||||
infoItem(label: "Days Left", value: "\(challenge.daysRemaining)")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
private func infoItem(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
private var challengeInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Challenge Details")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
detailRow(label: "Type", value: challenge.challengeType.displayName)
|
||||
detailRow(label: "Metric", value: challenge.targetMetric.displayName)
|
||||
detailRow(label: "Target", value: "\(challenge.targetValue) \(challenge.targetUnit)")
|
||||
detailRow(label: "Start", value: formatDate(challenge.startDate))
|
||||
detailRow(label: "End", value: formatDate(challenge.endDate))
|
||||
detailRow(label: "Creator", value: challenge.createdByName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detailRow(label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 100, alignment: .leading)
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
private var challengeDescription: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("About This Challenge")
|
||||
.font(.headline)
|
||||
Text(challenge.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
if let rules = challenge.rules {
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
Text("Rules")
|
||||
.font(.headline)
|
||||
Text(rules)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var progressSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Your Progress")
|
||||
.font(.headline)
|
||||
|
||||
if challenge.participationStatus == .participating {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Text("\(challenge.progressPercentage, specifier: "%.0f")%")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(challenge.challengeType.color)
|
||||
Spacer()
|
||||
Text("\(challenge.userProgress ?? 0) / \(challenge.targetValue) \(challenge.targetUnit)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
ProgressView(value: challenge.progressPercentage / 100)
|
||||
.tint(challenge.challengeType.color)
|
||||
.frame(height: 8)
|
||||
}
|
||||
} else {
|
||||
Text(challenge.participationStatus == .invited
|
||||
? "You've been invited — join to start tracking progress."
|
||||
: "Join this challenge to track your progress.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var participationSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Participation")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
switch challenge.participationStatus {
|
||||
case .participating:
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.leaveChallenge(id: challenge.id)
|
||||
}
|
||||
} label: {
|
||||
Label("Leave Challenge", systemImage: "flag.on.flag.fill")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.red.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
|
||||
case .invited:
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.joinChallenge(id: challenge.id)
|
||||
}
|
||||
} label: {
|
||||
Label("Accept & Join", systemImage: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.green.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
|
||||
case .notParticipating:
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.joinChallenge(id: challenge.id)
|
||||
}
|
||||
} label: {
|
||||
Label("Join Challenge", systemImage: "flag.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.blue.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var leaderboardSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Leaderboard")
|
||||
.font(.headline)
|
||||
|
||||
if viewModel.leaderboard.isEmpty {
|
||||
Text("No participants yet.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
ForEach(Array(viewModel.leaderboard.prefix(10), id: \.id)) { entry in
|
||||
HStack(spacing: 12) {
|
||||
Text("\(entry.position)")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.frame(width: 24)
|
||||
Circle()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
.frame(width: 28, height: 28)
|
||||
Text(entry.participantName)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text("\(entry.progressPercentage, specifier: "%.0f")%")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(entry.position <= 3 ? .orange : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
ChallengeDetailView(challenge: sampleChallenge)
|
||||
}
|
||||
}
|
||||
|
||||
private var sampleChallenge: Challenge {
|
||||
Challenge(
|
||||
id: "1",
|
||||
title: "Monthly 100km Challenge",
|
||||
description: "Run 100km this month. Track your distance and compete with friends!",
|
||||
challengeType: .distance,
|
||||
status: .active,
|
||||
startDate: Date().addingTimeInterval(-7 * 24 * 3600),
|
||||
endDate: Date().addingTimeInterval(23 * 24 * 3600),
|
||||
targetMetric: .distance,
|
||||
targetValue: 100,
|
||||
targetUnit: "km",
|
||||
participantCount: 47,
|
||||
rules: "All runs count. GPS-tracked activities only.",
|
||||
imageUrl: nil,
|
||||
createdBy: "user1",
|
||||
createdByName: "Sarah Chen",
|
||||
clubId: nil,
|
||||
participationStatus: .participating,
|
||||
userProgress: 42.5,
|
||||
createdAt: Date().addingTimeInterval(-7 * 24 * 3600)
|
||||
)
|
||||
}
|
||||
262
Lendair/Views/ChallengesView.swift
Normal file
262
Lendair/Views/ChallengesView.swift
Normal file
@@ -0,0 +1,262 @@
|
||||
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()
|
||||
}
|
||||
276
Lendair/Views/ClubDetailView.swift
Normal file
276
Lendair/Views/ClubDetailView.swift
Normal file
@@ -0,0 +1,276 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ClubDetailView: View {
|
||||
let club: Club
|
||||
@StateObject private var viewModel = ClubViewModel()
|
||||
@State private var inviteEmail = ""
|
||||
@State private var showingInviteAlert = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
clubHeader
|
||||
clubInfoSection
|
||||
clubDescription
|
||||
membershipSection
|
||||
rulesSection
|
||||
membersSection
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.navigationTitle(club.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if club.membershipStatus == .active {
|
||||
Menu {
|
||||
Button("Edit Club") {}
|
||||
Button("Leave Club", role: .destructive) {
|
||||
Task {
|
||||
await viewModel.leaveClub(id: club.id)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.selectClub(id: club.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var clubHeader: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: club.clubType.icon)
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(club.clubType.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(club.clubType.displayName)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
Text(club.location)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 20) {
|
||||
infoItem(label: "Members", value: "\(club.memberCount)")
|
||||
infoItem(label: "Privacy", value: club.privacy.displayName)
|
||||
infoItem(label: "Owner", value: club.ownerName)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
private func infoItem(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
private var clubInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Club Details")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
detailRow(label: "Type", value: club.clubType.displayName)
|
||||
detailRow(label: "Privacy", value: club.privacy.displayName)
|
||||
detailRow(label: "Location", value: club.location)
|
||||
if let max = club.maxMembers {
|
||||
detailRow(label: "Capacity", value: "\(club.memberCount)/\(max)")
|
||||
}
|
||||
detailRow(label: "Joined", value: formatDate(club.createdAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detailRow(label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 100, alignment: .leading)
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
private var clubDescription: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("About This Club")
|
||||
.font(.headline)
|
||||
Text(club.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var membershipSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Membership")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
switch club.membershipStatus {
|
||||
case .active:
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.leaveClub(id: club.id)
|
||||
}
|
||||
} label: {
|
||||
Label("Leave Club", systemImage: "door.left.hand.open")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.red.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
|
||||
case .pending:
|
||||
Label("Joining...", systemImage: "clock.fill")
|
||||
.foregroundColor(.orange)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.orange.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
|
||||
case .invited:
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.joinClub(id: club.id)
|
||||
}
|
||||
} label: {
|
||||
Label("Accept Invite", systemImage: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.green.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
|
||||
case .left:
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.joinClub(id: club.id)
|
||||
}
|
||||
} label: {
|
||||
Label("Rejoin Club", systemImage: "arrow.turn.down.right")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.blue.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
if club.membershipStatus == .active {
|
||||
Button {
|
||||
showingInviteAlert = true
|
||||
} label: {
|
||||
Label("Invite", systemImage: "person.crop.circle.badge.plus")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.blue.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rulesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Club Rules")
|
||||
.font(.headline)
|
||||
if let rules = club.rules {
|
||||
Text(rules)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("No rules specified.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var membersSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Members (\(club.memberCount))")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(viewModel.members) { member in
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
.frame(width: 32, height: 32)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(member.name)
|
||||
.font(.subheadline)
|
||||
Text(member.role.displayName)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(member.membershipStatus.rawValue.capitalized)
|
||||
.font(.caption)
|
||||
.foregroundColor(member.membershipStatus == .active ? .green : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
ClubDetailView(club: sampleClub)
|
||||
}
|
||||
}
|
||||
|
||||
private var sampleClub: Club {
|
||||
Club(
|
||||
id: "1",
|
||||
name: "Central Park Runners",
|
||||
description: "A friendly running club that meets every weekend in Central Park. All levels welcome!",
|
||||
clubType: .running,
|
||||
privacy: .publicPrivacy,
|
||||
location: "Central Park, NYC",
|
||||
latitude: 40.7851,
|
||||
longitude: -73.9683,
|
||||
memberCount: 142,
|
||||
maxMembers: 200,
|
||||
imageUrl: nil,
|
||||
rules: "Be respectful, stay hydrated, and have fun!",
|
||||
ownerId: "user1",
|
||||
ownerName: "Alex Johnson",
|
||||
membershipStatus: .active,
|
||||
createdAt: Date().addingTimeInterval(-30 * 24 * 3600)
|
||||
)
|
||||
}
|
||||
232
Lendair/Views/ClubsView.swift
Normal file
232
Lendair/Views/ClubsView.swift
Normal file
@@ -0,0 +1,232 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ClubsView: View {
|
||||
@StateObject private var viewModel = ClubViewModel()
|
||||
@State private var showingCreateSheet = false
|
||||
@State private var selectedTab: ClubTab = .discover
|
||||
|
||||
enum ClubTab: String, CaseIterable {
|
||||
case discover, myClubs
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Group {
|
||||
if viewModel.isLoading && viewModel.clubs.isEmpty {
|
||||
loadingView
|
||||
} else if currentClubs.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
clubListView
|
||||
}
|
||||
}
|
||||
.navigationTitle("Clubs")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingCreateSheet = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingCreateSheet) {
|
||||
CreateClubSheet()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.fetchClubs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var currentClubs: [Club] {
|
||||
switch selectedTab {
|
||||
case .discover: return viewModel.publicClubs
|
||||
case .myClubs: return viewModel.userClubs
|
||||
}
|
||||
}
|
||||
|
||||
private var clubListView: some View {
|
||||
List {
|
||||
Picker("Clubs", selection: $selectedTab) {
|
||||
ForEach(ClubTab.allCases, id: \.self) { tab in
|
||||
Text(tab.rawValue.capitalized).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.top, 8)
|
||||
|
||||
Section(currentSectionTitle) {
|
||||
ForEach(currentClubs) { club in
|
||||
NavigationLink(destination: ClubDetailView(club: club)) {
|
||||
ClubRowView(club: club)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable {
|
||||
await viewModel.fetchClubs()
|
||||
}
|
||||
}
|
||||
|
||||
private var currentSectionTitle: String {
|
||||
switch selectedTab {
|
||||
case .discover: return "Discover Clubs"
|
||||
case .myClubs: return "My Clubs"
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading Clubs...")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "person.3.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No \(selectedTab.rawValue) Clubs")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text(selectedTab == .discover
|
||||
? "Find running and fitness clubs in your area."
|
||||
: "Join or create a club to get started.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateClubSheet: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var name = ""
|
||||
@State private var description = ""
|
||||
@State private var clubType: ClubType = .running
|
||||
@State private var privacy: ClubPrivacy = .publicPrivacy
|
||||
@State private var location = ""
|
||||
@State private var maxMembers = ""
|
||||
@State private var rules = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section("Club Details") {
|
||||
TextField("Club Name", text: $name)
|
||||
TextField("Description", text: $description)
|
||||
TextField("Location", text: $location)
|
||||
}
|
||||
|
||||
Section("Type & Privacy") {
|
||||
Picker("Club Type", selection: $clubType) {
|
||||
ForEach(ClubType.allCases, id: \.self) { type in
|
||||
Text(type.displayName).tag(type)
|
||||
}
|
||||
}
|
||||
Picker("Privacy", selection: $privacy) {
|
||||
ForEach(ClubPrivacy.allCases, id: \.self) { priv in
|
||||
Text(priv.displayName).tag(priv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Optional") {
|
||||
TextField("Max Members", text: $maxMembers)
|
||||
.keyboardType(.numberPad)
|
||||
TextField("Rules", text: $rules)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Create Club")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Create") {
|
||||
let request = CreateClubRequest(
|
||||
name: name,
|
||||
description: description,
|
||||
clubType: clubType,
|
||||
privacy: privacy,
|
||||
location: location,
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
maxMembers: Int(maxMembers),
|
||||
rules: rules.isEmpty ? nil : rules
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(name.isEmpty || location.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ClubRowView: View {
|
||||
let club: Club
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: club.clubType.icon)
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(club.clubType.color)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(club.clubType.color.opacity(0.15))
|
||||
.cornerRadius(10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(club.name)
|
||||
.font(.headline)
|
||||
Text("\(club.location) \u2022 \(club.privacy.displayName)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(spacing: 8) {
|
||||
Text("\(club.memberCount) members")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
if let spots = club.availableSpots, spots > 0 {
|
||||
Text("\(spots) spots left")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
switch club.membershipStatus {
|
||||
case .active:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
case .pending:
|
||||
Image(systemName: "clock.fill")
|
||||
.foregroundColor(.orange)
|
||||
case .invited:
|
||||
Image(systemName: "mail.fill")
|
||||
.foregroundColor(.blue)
|
||||
case .left:
|
||||
Image(systemName: "circle")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ClubsView()
|
||||
}
|
||||
Reference in New Issue
Block a user