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