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:
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user