Clean up FrenoCorp repo: move project code to correct repositories (FRE-4529)

- Removed literal $AGENT_HOME/ directory artifact
- Moved Lendair iOS code to ~/code/lendair/iOS/Lendair/
- Moved marketing/ to ~/code/scripter/
- Moved ShieldAI workflow doc to ~/code/ShieldAI/
- Moved CI/CD workflows and load-test scripts to ~/code/lendair/
- Moved web configs (vercel.json, .env.example, index.html) to ~/code/lendair/web/
- Removed root-level project configs (package.json, tsconfig.json, vite.config.ts, etc.)
- Removed shared/exports/ and scripts/
- Updated all 8 agent AGENTS.md files with Repository Rules section
- Clarified: FrenoCorp is for agent notes/memories/plans only, not project code

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-10 12:12:06 -04:00
parent d74f65b9d5
commit 97d246e98e
91 changed files with 124 additions and 10622 deletions

View File

@@ -1,11 +0,0 @@
import SwiftUI
import Lendair
@main
struct LendairApp: SwiftUI.App {
var body: some Scene {
WindowGroup {
MainTabView()
}
}
}

View File

@@ -1,7 +0,0 @@
import XCTest
class LendairTests: XCTestCase {
func testPackageLoads() throws {
XCTAssertTrue(true)
}
}

View File

@@ -1,16 +0,0 @@
import Foundation
struct AppSettings {
static let appVersion = "1.0.0"
static let buildNumber = "1"
static let termsOfServiceURL = URL(string: "https://lendair.app/terms/2026-03-22")
static let privacyPolicyURL = URL(string: "https://lendair.app/privacy/2026-03-25")
}
enum AccountAction {
case logout
case deleteAccount
case viewTermsOfService
case viewPrivacyPolicy
}

View File

@@ -1,201 +0,0 @@
import Foundation
import SwiftUI
// MARK: - Beginner Configuration
struct BeginnerConfig: Codable {
var isEnabled: Bool
var currentLevel: BeginnerLevel
var completedOnboardingSteps: [OnboardingStep]
var milestones: [Milestone]
var shownTips: Set<String>
var preferredMetric: MetricType
enum CodingKeys: String, CodingKey {
case isEnabled, currentLevel, completedOnboardingSteps, milestones, shownTips, preferredMetric
}
}
// MARK: - Beginner Level
enum BeginnerLevel: String, CaseIterable, Codable {
case justStarted
case gettingComfortable
case buildingConsistency
case progressing
var displayName: String {
switch self {
case .justStarted: return "Just Started"
case .gettingComfortable: return "Getting Comfortable"
case .buildingConsistency: return "Building Consistency"
case .progressing: return "Progressing"
}
}
var requiredWorkouts: Int {
switch self {
case .justStarted: return 0
case .gettingComfortable: return 5
case .buildingConsistency: return 15
case .progressing: return 30
}
}
var icon: String {
switch self {
case .justStarted: return "sparkles"
case .gettingComfortable: return "leaf.fill"
case .buildingConsistency: return "chart.bar.fill"
case .progressing: return "bolt.fill"
}
}
}
// MARK: - Onboarding Step
enum OnboardingStep: String, CaseIterable, Codable {
case profileSetup
case goalSelection
case firstActivity
case inviteFriends
case enableNotifications
case choosePlan
var displayName: String {
switch self {
case .profileSetup: return "Profile Setup"
case .goalSelection: return "Set Goals"
case .firstActivity: return "First Activity"
case .inviteFriends: return "Invite Friends"
case .enableNotifications: return "Enable Notifications"
case .choosePlan: return "Choose a Plan"
}
}
var description: String {
switch self {
case .profileSetup: return "Complete your profile with your name and photo"
case .goalSelection: return "Tell us what you want to achieve"
case .firstActivity: return "Record your first workout"
case .inviteFriends: return "Invite friends to join Nessa"
case .enableNotifications: return "Get reminders and updates"
case .choosePlan: return "Pick a training plan to follow"
}
}
}
// MARK: - Milestone
struct Milestone: Identifiable, Codable {
let id: String
let title: String
let description: String
let icon: String
let requirement: MilestoneRequirement
var isCompleted: Bool
var completedAt: Date?
enum CodingKeys: String, CodingKey {
case id, title, description, icon, requirement, isCompleted, completedAt
}
init(
id: String,
title: String,
description: String,
icon: String,
requirement: MilestoneRequirement,
isCompleted: Bool,
completedAt: Date?
) {
self.id = id
self.title = title
self.description = description
self.icon = icon
self.requirement = requirement
self.isCompleted = isCompleted
self.completedAt = completedAt
}
}
// MARK: - Milestone Requirement
struct MilestoneRequirement: Codable {
let type: RequirementType
let targetValue: Double
}
enum RequirementType: String, CaseIterable, Codable {
case totalDistanceKm
case totalWorkouts
case consecutiveDays
case weeklyConsistency
case firstWorkout
var displayName: String {
switch self {
case .totalDistanceKm: return "Total Distance"
case .totalWorkouts: return "Total Workouts"
case .consecutiveDays: return "Streak"
case .weeklyConsistency: return "Weekly Consistency"
case .firstWorkout: return "First Workout"
}
}
}
// MARK: - Tip
struct BeginnerTip: Identifiable, Codable {
let id: String
let context: TipContext
let title: String
let message: String
var isShown: Bool
}
enum TipContext: String, CaseIterable, Codable {
case beforeWorkout
case afterWorkout
case dailyReminder
case progressUpdate
case restDay
}
// MARK: - Metric Type
enum MetricType: String, CaseIterable, Codable {
case distance
case duration
case pace
var displayName: String {
switch self {
case .distance: return "Distance"
case .duration: return "Duration"
case .pace: return "Pace"
}
}
}
// MARK: - API Response Types
struct BeginnerConfigResponse: Decodable {
let config: BeginnerConfig
}
struct UpdateBeginnerConfigRequest: Encodable {
var isEnabled: Bool?
var completedOnboardingSteps: [OnboardingStep]?
var preferredMetric: MetricType?
}
struct UpdateBeginnerConfigResponse: Decodable {
let success: Bool
let config: BeginnerConfig
}
struct MilestoneProgressResponse: Decodable {
let milestones: [Milestone]
let currentLevel: BeginnerLevel
}

View File

@@ -1,315 +0,0 @@
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
}

View File

@@ -1,275 +0,0 @@
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
}

View File

@@ -1,243 +0,0 @@
import Foundation
import SwiftUI
// MARK: - Community Event
struct CommunityEvent: Identifiable, Equatable, Codable {
let id: String
let title: String
let description: String
let eventType: EventType
let location: String
let latitude: Double
let longitude: Double
let startDate: Date
let endDate: Date
let distanceKm: Double?
let organizerId: String
let organizerName: String
let maxParticipants: Int?
let participantCount: Int
let imageUrl: String?
let difficulty: Difficulty?
var rsvpStatus: RSVPStatus
let createdAt: Date
enum CodingKeys: String, CodingKey {
case id, title, description, eventType, location, latitude, longitude, startDate, endDate, distanceKm, organizerId, organizerName, maxParticipants, participantCount, imageUrl, difficulty, rsvpStatus, createdAt
}
init(
id: String,
title: String,
description: String,
eventType: EventType,
location: String,
latitude: Double,
longitude: Double,
startDate: Date,
endDate: Date,
distanceKm: Double?,
organizerId: String,
organizerName: String,
maxParticipants: Int?,
participantCount: Int,
imageUrl: String?,
difficulty: Difficulty?,
rsvpStatus: RSVPStatus,
createdAt: Date
) {
self.id = id
self.title = title
self.description = description
self.eventType = eventType
self.location = location
self.latitude = latitude
self.longitude = longitude
self.startDate = startDate
self.endDate = endDate
self.distanceKm = distanceKm
self.organizerId = organizerId
self.organizerName = organizerName
self.maxParticipants = maxParticipants
self.participantCount = participantCount
self.imageUrl = imageUrl
self.difficulty = difficulty
self.rsvpStatus = rsvpStatus
self.createdAt = createdAt
}
static func == (lhs: CommunityEvent, rhs: CommunityEvent) -> Bool {
lhs.id == rhs.id && lhs.rsvpStatus == rhs.rsvpStatus
}
var isUpcoming: Bool {
startDate > Date()
}
var isOngoing: Bool {
Date() >= startDate && Date() <= endDate
}
var isPast: Bool {
endDate < Date()
}
var availableSpots: Int? {
guard let max = maxParticipants else { return nil }
return max - participantCount
}
}
// MARK: - Event Type
enum EventType: String, CaseIterable, Codable {
case groupRun
case race
case workshop
case socialGather
case charityEvent
case trainingCamp
var displayName: String {
switch self {
case .groupRun: return "Group Run"
case .race: return "Race"
case .workshop: return "Workshop"
case .socialGather: return "Social"
case .charityEvent: return "Charity"
case .trainingCamp: return "Training Camp"
}
}
var icon: String {
switch self {
case .groupRun: return "person.3.fill"
case .race: return "flag.fill"
case .workshop: return "lightbulb.fill"
case .socialGather: return "cup.and.saucer.fill"
case .charityEvent: return "heart.fill"
case .trainingCamp: return "figure.run"
}
}
var color: Color {
switch self {
case .groupRun: return .blue
case .race: return .orange
case .workshop: return .purple
case .socialGather: return .green
case .charityEvent: return .red
case .trainingCamp: return .indigo
}
}
}
// MARK: - RSVP Status
enum RSVPStatus: String, CaseIterable, Codable {
case going
case maybe
case notGoing
case pending
}
// MARK: - Event Participant
struct EventParticipant: Identifiable, Codable {
let id: String
let name: String
let avatarUrl: String?
let rsvpStatus: RSVPStatus
let joinedAt: Date
}
// MARK: - Create Event Request
struct CreateEventRequest: Encodable {
let title: String
let description: String
let eventType: EventType
let location: String
let latitude: Double
let longitude: Double
let startDate: Date
let endDate: Date
let distanceKm: Double?
let maxParticipants: Int?
let difficulty: Difficulty?
}
// MARK: - Update Event Request
struct UpdateEventRequest: Encodable {
var title: String?
var description: String?
var eventType: EventType?
var location: String?
var latitude: Double?
var longitude: Double?
var startDate: Date?
var endDate: Date?
var distanceKm: Double?
var maxParticipants: Int?
}
// MARK: - Event Filter
struct EventFilter: Encodable {
var eventType: EventType?
var startDate: Date?
var endDate: Date?
var location: String?
var radiusKm: Double?
var rsvpStatus: RSVPStatus?
var limit: Int
var offset: Int
init(
eventType: EventType? = nil,
startDate: Date? = nil,
endDate: Date? = nil,
location: String? = nil,
radiusKm: Double? = nil,
rsvpStatus: RSVPStatus? = nil,
limit: Int = 20,
offset: Int = 0
) {
self.eventType = eventType
self.startDate = startDate
self.endDate = endDate
self.location = location
self.radiusKm = radiusKm
self.rsvpStatus = rsvpStatus
self.limit = limit
self.offset = offset
}
}
// MARK: - API Response Types
struct EventListResponse: Decodable {
let events: [CommunityEvent]
let hasMore: Bool
}
struct EventDetailResponse: Decodable {
let event: CommunityEvent
let participants: [EventParticipant]
}
struct CreateEventResponse: Decodable {
let event: CommunityEvent
}
struct UpdateEventResponse: Decodable {
let event: CommunityEvent
}
struct RSVPResponse: Decodable {
let success: Bool
let eventId: String
let status: RSVPStatus
}

View File

@@ -1,217 +0,0 @@
import Foundation
import SwiftUI
// MARK: - Family Plan
struct FamilyPlan: Identifiable, Equatable, Codable {
let id: String
let ownerId: String
let ownerName: String
let members: [FamilyMember]
let maxMembers: Int
let subscriptionStatus: SubscriptionStatus
let renewalDate: Date?
let createdAt: Date
enum CodingKeys: String, CodingKey {
case id, ownerId, ownerName, members, maxMembers, subscriptionStatus, renewalDate, createdAt
}
init(
id: String,
ownerId: String,
ownerName: String,
members: [FamilyMember],
maxMembers: Int,
subscriptionStatus: SubscriptionStatus,
renewalDate: Date?,
createdAt: Date
) {
self.id = id
self.ownerId = ownerId
self.ownerName = ownerName
self.members = members
self.maxMembers = maxMembers
self.subscriptionStatus = subscriptionStatus
self.renewalDate = renewalDate
self.createdAt = createdAt
}
static func == (lhs: FamilyPlan, rhs: FamilyPlan) -> Bool {
lhs.id == rhs.id
}
var availableSlots: Int {
maxMembers - members.count
}
var isActive: Bool {
subscriptionStatus == .active
}
}
// MARK: - Family Member
struct FamilyMember: Identifiable, Equatable, Codable {
let id: String
let name: String
let email: String
let role: MemberRole
let joinedAt: Date
let avatarUrl: String?
var isPrimary: Bool
var totalDistanceKm: Double
var totalWorkouts: Int
var weeklyDistanceKm: Double
var weeklyWorkouts: Int
enum CodingKeys: String, CodingKey {
case id, name, email, role, joinedAt, avatarUrl, isPrimary, totalDistanceKm, totalWorkouts, weeklyDistanceKm, weeklyWorkouts
}
init(
id: String,
name: String,
email: String,
role: MemberRole,
joinedAt: Date,
avatarUrl: String?,
isPrimary: Bool,
totalDistanceKm: Double,
totalWorkouts: Int,
weeklyDistanceKm: Double,
weeklyWorkouts: Int
) {
self.id = id
self.name = name
self.email = email
self.role = role
self.joinedAt = joinedAt
self.avatarUrl = avatarUrl
self.isPrimary = isPrimary
self.totalDistanceKm = totalDistanceKm
self.totalWorkouts = totalWorkouts
self.weeklyDistanceKm = weeklyDistanceKm
self.weeklyWorkouts = weeklyWorkouts
}
static func == (lhs: FamilyMember, rhs: FamilyMember) -> Bool {
lhs.id == rhs.id
}
}
// MARK: - Member Role
enum MemberRole: String, CaseIterable, Codable {
case owner
case member
case pending
var displayName: String {
switch self {
case .owner: return "Owner"
case .member: return "Member"
case .pending: return "Pending"
}
}
var icon: String {
switch self {
case .owner: return "star.fill"
case .member: return "person.fill"
case .pending: return "person.crop.circle.badge.exclamationmark"
}
}
}
// MARK: - Subscription Status
enum SubscriptionStatus: String, CaseIterable, Codable {
case active
case expired
case cancelled
case pending
var displayName: String {
switch self {
case .active: return "Active"
case .expired: return "Expired"
case .cancelled: return "Cancelled"
case .pending: return "Pending"
}
}
var color: Color {
switch self {
case .active: return .green
case .expired: return .red
case .cancelled: return .orange
case .pending: return .yellow
}
}
}
// MARK: - Family Leaderboard Entry
struct FamilyLeaderboardEntry: Identifiable, Codable {
let id: String
let memberId: String
let memberName: String
let avatarUrl: String?
let metric: LeaderboardMetric
let value: Double
let rank: Int
}
// MARK: - Leaderboard Metric
enum LeaderboardMetric: String, CaseIterable, Codable {
case distance
case workouts
case streak
var displayName: String {
switch self {
case .distance: return "Distance"
case .workouts: return "Workouts"
case .streak: return "Streak"
}
}
var unit: String {
switch self {
case .distance: return "km"
case .workouts: return ""
case .streak: return "days"
}
}
}
// MARK: - Invite Member Request
struct InviteMemberRequest: Encodable {
let email: String
let name: String
}
// MARK: - API Response Types
struct FamilyPlanDetailResponse: Decodable {
let plan: FamilyPlan
}
struct InviteMemberResponse: Decodable {
let success: Bool
let invitationId: String
let memberEmail: String
}
struct RemoveMemberResponse: Decodable {
let success: Bool
let memberId: String
}
struct FamilyLeaderboardResponse: Decodable {
let entries: [FamilyLeaderboardEntry]
let metric: LeaderboardMetric
}

View File

@@ -1,96 +0,0 @@
import Foundation
import SwiftUI
// MARK: - Notification Item
struct NotificationItem: Identifiable, Equatable, Codable {
let id: String
let type: NotificationType
let title: String
let message: String
let createdAt: Date
var isRead: Bool
enum CodingKeys: String, CodingKey {
case id, type, title, message, createdAt, isRead
}
init(id: String, type: NotificationType, title: String, message: String, createdAt: Date, isRead: Bool) {
self.id = id
self.type = type
self.title = title
self.message = message
self.createdAt = createdAt
self.isRead = isRead
}
static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool {
lhs.id == rhs.id && lhs.isRead == rhs.isRead
}
}
// MARK: - Notification Type
enum NotificationType: String, CaseIterable, Codable {
case loanApproved = "LOAN_APPROVED"
case loanRejected = "LOAN_REJECTED"
case paymentReceived = "PAYMENT_RECEIVED"
case paymentDue = "PAYMENT_DUE"
case newLender = "NEW_LENDER"
case systemUpdate = "SYSTEM_UPDATE"
var icon: String {
switch self {
case .loanApproved: return "checkmark.circle.fill"
case .loanRejected: return "xmark.circle.fill"
case .paymentReceived: return "arrow.down.circle.fill"
case .paymentDue: return "exclamationmark.circle.fill"
case .newLender: return "person.circle.fill"
case .systemUpdate: return "info.circle.fill"
}
}
var color: Color {
switch self {
case .loanApproved: return .green
case .loanRejected: return .red
case .paymentReceived: return .green
case .paymentDue: return .orange
case .newLender: return .blue
case .systemUpdate: return .gray
}
}
}
// MARK: - List Parameters
struct NotificationListParams: Encodable {
var limit: Int
var offset: Int
init(limit: Int = 20, offset: Int = 0) {
self.limit = limit
self.offset = offset
}
}
// MARK: - API Response Types
struct NotificationListResponse: Decodable {
let notifications: [NotificationItem]
let hasMore: Bool
}
struct NotificationMarkAsReadResponse: Decodable {
let success: Bool
let notificationId: String
}
struct NotificationMarkAllReadResponse: Decodable {
let success: Bool
let markedCount: Int
}
struct NotificationUnreadCountResponse: Decodable {
let count: Int
}

View File

@@ -1,183 +0,0 @@
import Foundation
import SwiftUI
// MARK: - Race
struct Race: Identifiable, Equatable, Codable {
let id: String
let name: String
let description: String
let location: String
let latitude: Double
let longitude: Double
let raceDate: Date
let distanceKm: Double
let raceType: RaceType
let organizerName: String
let registrationUrl: String?
let imageUrl: String?
let participantCount: Int?
let isRegistered: Bool
let isSaved: Bool
let elevationGain: Double
let terrainType: TerrainType
enum CodingKeys: String, CodingKey {
case id, name, description, location, latitude, longitude, raceDate, distanceKm, raceType, organizerName, registrationUrl, imageUrl, participantCount, isRegistered, isSaved, elevationGain, terrainType
}
init(
id: String,
name: String,
description: String,
location: String,
latitude: Double,
longitude: Double,
raceDate: Date,
distanceKm: Double,
raceType: RaceType,
organizerName: String,
registrationUrl: String?,
imageUrl: String?,
participantCount: Int?,
isRegistered: Bool,
isSaved: Bool,
elevationGain: Double,
terrainType: TerrainType
) {
self.id = id
self.name = name
self.description = description
self.location = location
self.latitude = latitude
self.longitude = longitude
self.raceDate = raceDate
self.distanceKm = distanceKm
self.raceType = raceType
self.organizerName = organizerName
self.registrationUrl = registrationUrl
self.imageUrl = imageUrl
self.participantCount = participantCount
self.isRegistered = isRegistered
self.isSaved = isSaved
self.elevationGain = elevationGain
self.terrainType = terrainType
}
static func == (lhs: Race, rhs: Race) -> Bool {
lhs.id == rhs.id && lhs.isRegistered == rhs.isRegistered && lhs.isSaved == rhs.isSaved
}
var daysUntilRace: Int {
let calendar = Calendar.current
return calendar.dateComponents([.day], from: Date(), to: raceDate).day ?? 0
}
var isUpcoming: Bool {
raceDate > Date()
}
}
// MARK: - Race Type
enum RaceType: String, CaseIterable, Codable {
case road
case trail
case track
case virtual
var displayName: String {
switch self {
case .road: return "Road"
case .trail: return "Trail"
case .track: return "Track"
case .virtual: return "Virtual"
}
}
var icon: String {
switch self {
case .road: return "car.fill"
case .trail: return "mountain.2.fill"
case .track: return "circle.fill"
case .virtual: return "globe"
}
}
}
// MARK: - Terrain Type
enum TerrainType: String, CaseIterable, Codable {
case flat
case rolling
case hilly
case mountainous
var displayName: String {
switch self {
case .flat: return "Flat"
case .rolling: return "Rolling"
case .hilly: return "Hilly"
case .mountainous: return "Mountainous"
}
}
}
// MARK: - Race Filter
struct RaceFilter: Encodable {
var distanceKm: Double?
var raceType: RaceType?
var terrainType: TerrainType?
var startDate: Date?
var endDate: Date?
var location: String?
var radiusKm: Double?
var limit: Int
var offset: Int
init(
distanceKm: Double? = nil,
raceType: RaceType? = nil,
terrainType: TerrainType? = nil,
startDate: Date? = nil,
endDate: Date? = nil,
location: String? = nil,
radiusKm: Double? = nil,
limit: Int = 20,
offset: Int = 0
) {
self.distanceKm = distanceKm
self.raceType = raceType
self.terrainType = terrainType
self.startDate = startDate
self.endDate = endDate
self.location = location
self.radiusKm = radiusKm
self.limit = limit
self.offset = offset
}
}
// MARK: - API Response Types
struct RaceListResponse: Decodable {
let races: [Race]
let hasMore: Bool
}
struct RaceDetailResponse: Decodable {
let race: Race
}
struct SaveRaceResponse: Decodable {
let success: Bool
let raceId: String
let isSaved: Bool
}
struct RegisterRaceResponse: Decodable {
let success: Bool
let raceId: String
let registrationUrl: String?
}

View File

@@ -1,312 +0,0 @@
import Foundation
import SwiftUI
// MARK: - Training Plan
struct TrainingPlan: Identifiable, Equatable, Codable {
let id: String
let title: String
let description: String
let planType: PlanType
let durationWeeks: Int
let difficulty: Difficulty
let startDate: Date
let endDate: Date
let weeklyWorkouts: [WeeklyWorkout]
var progress: PlanProgress
var isFollowing: Bool
let createdAt: Date
enum CodingKeys: String, CodingKey {
case id, title, description, planType, durationWeeks, difficulty, startDate, endDate, weeklyWorkouts, progress, isFollowing, createdAt
}
init(
id: String,
title: String,
description: String,
planType: PlanType,
durationWeeks: Int,
difficulty: Difficulty,
startDate: Date,
endDate: Date,
weeklyWorkouts: [WeeklyWorkout],
progress: PlanProgress,
isFollowing: Bool,
createdAt: Date
) {
self.id = id
self.title = title
self.description = description
self.planType = planType
self.durationWeeks = durationWeeks
self.difficulty = difficulty
self.startDate = startDate
self.endDate = endDate
self.weeklyWorkouts = weeklyWorkouts
self.progress = progress
self.isFollowing = isFollowing
self.createdAt = createdAt
}
static func == (lhs: TrainingPlan, rhs: TrainingPlan) -> Bool {
lhs.id == rhs.id
}
}
// MARK: - Plan Type
enum PlanType: String, CaseIterable, Codable {
case fiveK = "5K"
case tenK = "10K"
case halfMarathon = "HALF_MARATHON"
case fullMarathon = "FULL_MARATHON"
case custom = "CUSTOM"
var displayName: String {
switch self {
case .fiveK: return "5K"
case .tenK: return "10K"
case .halfMarathon: return "Half Marathon"
case .fullMarathon: return "Full Marathon"
case .custom: return "Custom"
}
}
var distanceKm: Double {
switch self {
case .fiveK: return 5.0
case .tenK: return 10.0
case .halfMarathon: return 21.1
case .fullMarathon: return 42.2
case .custom: return 0.0
}
}
var icon: String {
switch self {
case .fiveK: return "figure.run"
case .tenK: return "figure.run"
case .halfMarathon: return "flag.fill"
case .fullMarathon: return "flag.fill"
case .custom: return "wrench.and.screwdriver"
}
}
}
// MARK: - Difficulty
enum Difficulty: String, CaseIterable, Codable {
case beginner
case intermediate
case advanced
case elite
var displayName: String {
switch self {
case .beginner: return "Beginner"
case .intermediate: return "Intermediate"
case .advanced: return "Advanced"
case .elite: return "Elite"
}
}
var color: Color {
switch self {
case .beginner: return .green
case .intermediate: return .blue
case .advanced: return .orange
case .elite: return .red
}
}
}
// MARK: - Weekly Workout
struct WeeklyWorkout: Identifiable, Codable {
let id: String
let weekNumber: Int
let dailySessions: [DailySession]
var completedSessions: Int {
dailySessions.filter { $0.status == .completed }.count
}
var totalSessions: Int {
dailySessions.count
}
var progressPercentage: Double {
totalSessions == 0 ? 0 : Double(completedSessions) / Double(totalSessions) * 100
}
}
// MARK: - Daily Session
struct DailySession: Identifiable, Codable {
let id: String
let dayOfWeek: DayOfWeek
let workoutType: WorkoutType
let title: String
let description: String
let targetDistanceKm: Double?
let targetDurationMinutes: Int?
let targetPaceMinPerKm: Double?
let intensity: Intensity
var status: SessionStatus
var completedDistanceKm: Double?
var completedDurationMinutes: Int?
enum CodingKeys: String, CodingKey {
case id, dayOfWeek, workoutType, title, description, targetDistanceKm, targetDurationMinutes, targetPaceMinPerKm, intensity, status, completedDistanceKm, completedDurationMinutes
}
}
// MARK: - Day of Week
enum DayOfWeek: String, CaseIterable, Codable {
case monday, tuesday, wednesday, thursday, friday, saturday, sunday
var displayName: String {
switch self {
case .monday: return "Mon"
case .tuesday: return "Tue"
case .wednesday: return "Wed"
case .thursday: return "Thu"
case .friday: return "Fri"
case .saturday: return "Sat"
case .sunday: return "Sun"
}
}
}
// MARK: - Workout Type
enum WorkoutType: String, CaseIterable, Codable {
case easyRun
case tempoRun
case intervalTraining
case longRun
case speedWork
case recoveryRun
case crossTraining
case rest
var displayName: String {
switch self {
case .easyRun: return "Easy Run"
case .tempoRun: return "Tempo Run"
case .intervalTraining: return "Intervals"
case .longRun: return "Long Run"
case .speedWork: return "Speed Work"
case .recoveryRun: return "Recovery Run"
case .crossTraining: return "Cross Train"
case .rest: return "Rest"
}
}
var icon: String {
switch self {
case .easyRun: return "figure.walk"
case .tempoRun: return "figure.run"
case .intervalTraining: return "bolt.fill"
case .longRun: return "figure.run"
case .speedWork: return "speedometer"
case .recoveryRun: return "leaf.fill"
case .crossTraining: return "dumbbell.fill"
case .rest: return "moon.fill"
}
}
var color: Color {
switch self {
case .easyRun: return .green
case .tempoRun: return .blue
case .intervalTraining: return .purple
case .longRun: return .orange
case .speedWork: return .red
case .recoveryRun: return .mint
case .crossTraining: return .gray
case .rest: return .secondary
}
}
}
// MARK: - Intensity
enum Intensity: String, CaseIterable, Codable {
case veryEasy
case easy
case moderate
case hard
case veryHard
var displayName: String {
switch self {
case .veryEasy: return "Very Easy"
case .easy: return "Easy"
case .moderate: return "Moderate"
case .hard: return "Hard"
case .veryHard: return "Very Hard"
}
}
}
// MARK: - Session Status
enum SessionStatus: String, CaseIterable, Codable {
case pending
case inProgress
case completed
case skipped
}
// MARK: - Plan Progress
struct PlanProgress: Codable {
let completedWeeks: Int
let totalWeeks: Int
let completedSessions: Int
let totalSessions: Int
let currentWeekNumber: Int
var percentage: Double {
totalWeeks == 0 ? 0 : Double(completedWeeks) / Double(totalWeeks) * 100
}
var sessionPercentage: Double {
totalSessions == 0 ? 0 : Double(completedSessions) / Double(totalSessions) * 100
}
}
// MARK: - AI Plan Generation Request
struct GeneratePlanRequest: Encodable {
let planType: PlanType
let difficulty: Difficulty
let startDate: Date
let currentWeeklyMileageKm: Double?
let goalTimeMinutes: Int?
let availableDays: [DayOfWeek]
}
// MARK: - API Response Types
struct TrainingPlanListResponse: Decodable {
let plans: [TrainingPlan]
let hasMore: Bool
}
struct TrainingPlanDetailResponse: Decodable {
let plan: TrainingPlan
}
struct GeneratePlanResponse: Decodable {
let plan: TrainingPlan
}
struct UpdateSessionStatusResponse: Decodable {
let success: Bool
let sessionId: String
let status: SessionStatus
}

View File

@@ -1,46 +0,0 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "Lendair",
platforms: [
.iOS(.v16),
.macOS(.v13)
],
products: [
.library(
name: "Lendair",
targets: ["Lendair"]
),
.executable(
name: "LendairApp",
targets: ["LendairApp"]
)
],
targets: [
.target(
name: "Lendair",
path: ".",
sources: [
"Models",
"Services",
"Utils",
"ViewModels",
"Views"
],
linkerSettings: [
.linkedFramework("UIKit", .when(platforms: [.iOS]))
]
),
.executableTarget(
name: "LendairApp",
dependencies: ["Lendair"],
path: "App"
),
.testTarget(
name: "LendairTests",
dependencies: ["Lendair"],
path: "LendairTests"
)
]
)

View File

@@ -1,264 +0,0 @@
# Lendair iOS App
## Overview
SwiftUI iOS app with modular feature architecture following MVVM pattern.
## Architecture
### MVVM Pattern
- **View**: `Views/` - SwiftUI views for all feature screens
- **ViewModel**: `ViewModels/` - State management and business logic
- **Service**: `Services/` - Data layer with API communication
- **Model**: `Models/` - Data structures and type definitions
### File Structure
```
Lendair/
├── Models/
│ ├── Notification.swift # NotificationItem, NotificationType, API response types
│ ├── TrainingPlan.swift # TrainingPlan, PlanType, WorkoutSession, PlanProgress
│ ├── Race.swift # Race, RaceType, RaceFilter, API response types
│ ├── FamilyPlan.swift # FamilyPlan, FamilyMember, LeaderboardMetric
│ ├── BeginnerMode.swift # BeginnerConfig, Milestone, OnboardingStep
│ ├── 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
│ ├── ClubService.swift # ClubServiceProtocol + implementation
│ └── ChallengeService.swift # ChallengeServiceProtocol + implementation
├── ViewModels/
│ ├── NotificationsViewModel.swift
│ ├── TrainingPlanViewModel.swift
│ ├── RaceDiscoveryViewModel.swift
│ ├── FamilyPlanViewModel.swift
│ ├── BeginnerModeViewModel.swift
│ ├── CommunityEventViewModel.swift
│ ├── ClubViewModel.swift
│ └── ChallengeViewModel.swift
├── Views/
│ ├── NotificationsView.swift
│ ├── NotificationRowView.swift
│ ├── TrainingPlanView.swift
│ ├── TrainingPlanDetailView.swift
│ ├── WorkoutSessionView.swift
│ ├── RaceDiscoveryView.swift
│ ├── RaceDetailView.swift
│ ├── FamilyPlanView.swift
│ ├── FamilyMemberView.swift
│ ├── BeginnerModeView.swift
│ ├── CommunityEventsView.swift
│ ├── CommunityEventDetailView.swift
│ ├── ClubsView.swift
│ ├── ClubDetailView.swift
│ ├── ChallengesView.swift
│ ├── ChallengeDetailView.swift
│ ├── MainTabView.swift
│ └── SettingsView.swift
├── Models/
│ ├── Notification.swift
│ ├── TrainingPlan.swift
│ ├── Race.swift
│ ├── FamilyPlan.swift
│ ├── BeginnerMode.swift
│ ├── CommunityEvent.swift
│ ├── Club.swift
│ ├── Challenge.swift
│ └── AppSettings.swift
└── README.md
```
## Features
### Notifications
- Notification list with pull-to-refresh
- Mark-as-read (individual and bulk)
- Type-specific icons and color coding
- Empty state handling
### AI Training Plans (Phase 3 - Premium)
- Personalized training plan generation (5K, 10K, Half/Full Marathon, Custom)
- Difficulty levels: Beginner, Intermediate, Advanced, Elite
- Weekly/daily workout scheduling with progressive overload
- Plan progress tracking with session completion
- Workout session execution with metrics display
- Plan following/unfollowing
### Race Discovery (Phase 3 - Premium)
- Browse upcoming races by location, distance, type, terrain
- Race detail pages with registration links
- Save/bookmark races
- Filter by race type (Road, Trail, Track, Virtual)
- Calendar integration ready
### Family Plans (Phase 3 - Premium)
- Multi-member household management (up to 6 members)
- Invite members via email
- Individual progress tracking per member
- Family leaderboard (distance, workouts, streak)
- Subscription status management
### Beginner Mode (Phase 3 - Premium)
- Guided onboarding with step tracking
- Progressive levels: Just Started → Getting Comfortable → Building Consistency → Progressing
- Milestone achievements and tracking
- Contextual tips and educational content
- Simplified metric display
### Community Events (Phase 3 - Premium)
- Event discovery and creation
- RSVP system (Going, Maybe, Not Going)
- Event types: Group Run, Race, Workshop, Social, Charity, Training Camp
- 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
### Settings/About (Phase 2 - Core)
- App version and build number display
- Links to Terms of Service and Privacy Policy documents
- User logout functionality
- Account deletion option
- Profile information display
## Service Pattern
All services follow the same architecture:
- **Protocol**: `Sendable` protocol for testability
- **Implementation**: Configurable `baseURL`, `URLSession`, `authToken`
- **Error Handling**: Typed error enums with `LocalizedError` conformance
- **HTTP Methods**: GET, POST, PATCH, DELETE via shared helpers
## API Endpoints
### Notifications
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/notifications?limit=&offset=` | List notifications |
| PATCH | `/api/notifications/:id/read` | Mark single as read |
| PATCH | `/api/notifications/read-all` | Mark all as read |
### Training Plans
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/training-plans?type=&difficulty=` | List plans |
| GET | `/api/training-plans/:id` | Get plan detail |
| POST | `/api/training-plans/generate` | Generate AI plan |
| POST | `/api/training-plans/:id/follow` | Follow plan |
| DELETE | `/api/training-plans/:id/follow` | Unfollow plan |
| PATCH | `/api/training-plans/sessions/:id/status` | Update session status |
### Races
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/races?type=&terrain=&...` | List races with filters |
| GET | `/api/races/:id` | Get race detail |
| POST/DELETE | `/api/races/:id/save` | Save/unsave race |
| POST | `/api/races/:id/register` | Register for race |
### Family Plans
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/family-plan` | Get family plan |
| POST | `/api/family-plan/invite` | Invite member |
| DELETE | `/api/family-plan/members/:id` | Remove member |
| GET | `/api/family-plan/leaderboard?metric=` | Get leaderboard |
### Beginner Mode
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/beginner-mode/config` | Get config |
| PATCH | `/api/beginner-mode/config` | Update config |
| GET | `/api/beginner-mode/milestones` | Get milestone progress |
### Community Events
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/events?type=&rsvp=&...` | List events with filters |
| GET | `/api/events/:id` | Get event detail |
| POST | `/api/events` | Create event |
| 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 |
### Settings/About
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/settings/version` | Get app version and build number |
| GET | `/api/settings/legal/terms` | Get Terms of Service document |
| GET | `/api/settings/legal/privacy` | Get Privacy Policy document |
| POST | `/api/settings/logout` | User logout |
| DELETE | `/api/settings/account` | Delete user account |
## 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, 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 {
ClubsView()
}
NavigationStack {
ChallengesView()
}
NavigationStack {
CommunityEventsView()
}
```
## Premium Features
All Phase 3 features (Training Plans, Race Discovery, Family Plans, Beginner Mode, Community Events) require a Pro subscription ($9.99/mo). Subscription status should be verified via the existing SubscriptionService before feature access.

View File

@@ -1,118 +0,0 @@
import Foundation
// MARK: - Service Protocol
protocol BeginnerModeServiceProtocol: Sendable {
func getConfig() async throws -> BeginnerConfig
func updateConfig(request: UpdateBeginnerConfigRequest) async throws -> BeginnerConfig
func getMilestoneProgress() async throws -> (milestones: [Milestone], level: BeginnerLevel)
}
// MARK: - Default Service
class BeginnerModeService: BeginnerModeServiceProtocol {
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 getConfig() async throws -> BeginnerConfig {
let url = baseURL.appendingPathComponent("/api/beginner-mode/config")
let request = try buildRequest(url: url)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
let decoded = try JSONDecoder().decode(BeginnerConfigResponse.self, from: data)
return decoded.config
}
func updateConfig(request: UpdateBeginnerConfigRequest) async throws -> BeginnerConfig {
let url = baseURL.appendingPathComponent("/api/beginner-mode/config")
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(UpdateBeginnerConfigResponse.self, from: data)
return decoded.config
}
func getMilestoneProgress() async throws -> (milestones: [Milestone], level: BeginnerLevel) {
let url = baseURL.appendingPathComponent("/api/beginner-mode/milestones")
let request = try buildRequest(url: url)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
let decoded = try JSONDecoder().decode(MilestoneProgressResponse.self, from: data)
return (decoded.milestones, decoded.currentLevel)
}
// 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 BeginnerModeError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
switch httpResponse.statusCode {
case 401: throw BeginnerModeError.unauthorized
case 403: throw BeginnerModeError.forbidden
case 404: throw BeginnerModeError.notFound
case 429: throw BeginnerModeError.rateLimited
case 500...599: throw BeginnerModeError.serverError(httpResponse.statusCode)
default: throw BeginnerModeError.httpError(httpResponse.statusCode)
}
}
}
}
// MARK: - Error Types
enum BeginnerModeError: 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 "Beginner mode config 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))"
}
}
}

View File

@@ -1,181 +0,0 @@
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?
static let defaultBaseURL: URL = URL(string: "http://localhost:3000")!
init(
baseURL: URL = defaultBaseURL,
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 urlRequest = try buildRequest(url: url, method: .post)
urlRequest.httpBody = try JSONEncoder().encode(request)
let (data, response) = try await session.data(for: urlRequest)
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 urlRequest = try buildRequest(url: url, method: .patch)
urlRequest.httpBody = try JSONEncoder().encode(request)
let (data, response) = try await session.data(for: urlRequest)
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) 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")
}
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))"
}
}
}

View File

@@ -1,177 +0,0 @@
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?
static let defaultBaseURL: URL = URL(string: "http://localhost:3000")!
init(
baseURL: URL = defaultBaseURL,
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 urlRequest = try buildRequest(url: url, method: .post)
urlRequest.httpBody = try JSONEncoder().encode(request)
let (data, response) = try await session.data(for: urlRequest)
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 urlRequest = try buildRequest(url: url, method: .patch)
urlRequest.httpBody = try JSONEncoder().encode(request)
let (data, response) = try await session.data(for: urlRequest)
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) 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")
}
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))"
}
}
}

View File

@@ -1,153 +0,0 @@
import Foundation
// MARK: - Service Protocol
protocol CommunityEventServiceProtocol: Sendable {
func listEvents(filter: EventFilter) async throws -> [CommunityEvent]
func getEvent(id: String) async throws -> (event: CommunityEvent, participants: [EventParticipant])
func createEvent(request: CreateEventRequest) async throws -> CommunityEvent
func updateEvent(id: String, request: UpdateEventRequest) async throws -> CommunityEvent
func RSVP(eventId: String, status: RSVPStatus) async throws
}
// MARK: - Default Service
class CommunityEventService: CommunityEventServiceProtocol {
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 listEvents(filter: EventFilter = EventFilter()) async throws -> [CommunityEvent] {
var components = URLComponents(url: baseURL.appendingPathComponent("/api/events"), resolvingAgainstBaseURL: true)!
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "limit", value: String(filter.limit)),
URLQueryItem(name: "offset", value: String(filter.offset))
]
if let type = filter.eventType { queryItems.append(URLQueryItem(name: "type", value: type.rawValue)) }
if let startDate = filter.startDate { queryItems.append(URLQueryItem(name: "startDate", value: ISO8601DateFormatter().string(from: startDate))) }
if let endDate = filter.endDate { queryItems.append(URLQueryItem(name: "endDate", value: ISO8601DateFormatter().string(from: endDate))) }
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))) }
if let rsvp = filter.rsvpStatus { queryItems.append(URLQueryItem(name: "rsvp", value: rsvp.rawValue)) }
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(EventListResponse.self, from: data)
return decoded.events
}
func getEvent(id: String) async throws -> (event: CommunityEvent, participants: [EventParticipant]) {
let url = baseURL.appendingPathComponent("/api/events/\(id)")
let request = try buildRequest(url: url)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
let decoded = try JSONDecoder().decode(EventDetailResponse.self, from: data)
return (decoded.event, decoded.participants)
}
func createEvent(request: CreateEventRequest) async throws -> CommunityEvent {
let url = baseURL.appendingPathComponent("/api/events")
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(CreateEventResponse.self, from: data)
return decoded.event
}
func updateEvent(id: String, request: UpdateEventRequest) async throws -> CommunityEvent {
let url = baseURL.appendingPathComponent("/api/events/\(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(UpdateEventResponse.self, from: data)
return decoded.event
}
func RSVP(eventId: String, status: RSVPStatus) async throws {
let url = baseURL.appendingPathComponent("/api/events/\(eventId)/rsvp")
var request = try buildRequest(url: url, method: .post)
request.httpBody = try JSONEncoder().encode(["status": status.rawValue])
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 CommunityEventError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
switch httpResponse.statusCode {
case 401: throw CommunityEventError.unauthorized
case 403: throw CommunityEventError.forbidden
case 404: throw CommunityEventError.notFound
case 429: throw CommunityEventError.rateLimited
case 500...599: throw CommunityEventError.serverError(httpResponse.statusCode)
default: throw CommunityEventError.httpError(httpResponse.statusCode)
}
}
}
}
// MARK: - Error Types
enum CommunityEventError: 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 "Event 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))"
}
}
}

View File

@@ -1,125 +0,0 @@
import Foundation
// MARK: - Service Protocol
protocol FamilyPlanServiceProtocol: Sendable {
func getFamilyPlan() async throws -> FamilyPlan
func inviteMember(request: InviteMemberRequest) async throws
func removeMember(id: String) async throws
func getLeaderboard(metric: LeaderboardMetric) async throws -> [FamilyLeaderboardEntry]
}
// MARK: - Default Service
class FamilyPlanService: FamilyPlanServiceProtocol {
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 getFamilyPlan() async throws -> FamilyPlan {
let url = baseURL.appendingPathComponent("/api/family-plan")
let request = try buildRequest(url: url)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
let decoded = try JSONDecoder().decode(FamilyPlanDetailResponse.self, from: data)
return decoded.plan
}
func inviteMember(request: InviteMemberRequest) async throws {
let url = baseURL.appendingPathComponent("/api/family-plan/invite")
var request = try buildRequest(url: url, method: .post)
request.httpBody = try JSONEncoder().encode(request)
let (_, response) = try await session.data(for: request)
try validateResponse(response)
}
func removeMember(id: String) async throws {
let url = baseURL.appendingPathComponent("/api/family-plan/members/\(id)")
let request = try buildRequest(url: url, method: .delete)
let (_, response) = try await session.data(for: request)
try validateResponse(response)
}
func getLeaderboard(metric: LeaderboardMetric) async throws -> [FamilyLeaderboardEntry] {
var components = URLComponents(url: baseURL.appendingPathComponent("/api/family-plan/leaderboard"), resolvingAgainstBaseURL: true)!
components.queryItems = [URLQueryItem(name: "metric", value: metric.rawValue)]
let request = try buildRequest(url: components.url!)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
let decoded = try JSONDecoder().decode(FamilyLeaderboardResponse.self, from: data)
return decoded.entries
}
// 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 FamilyPlanError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
switch httpResponse.statusCode {
case 401: throw FamilyPlanError.unauthorized
case 403: throw FamilyPlanError.forbidden
case 404: throw FamilyPlanError.notFound
case 429: throw FamilyPlanError.rateLimited
case 500...599: throw FamilyPlanError.serverError(httpResponse.statusCode)
default: throw FamilyPlanError.httpError(httpResponse.statusCode)
}
}
}
}
// MARK: - Error Types
enum FamilyPlanError: 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 "Family plan 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))"
}
}
}

View File

@@ -1,137 +0,0 @@
import Foundation
// MARK: - Service Protocol
protocol NotificationsServiceProtocol: Sendable {
func list(params: NotificationListParams = NotificationListParams()) async throws -> [NotificationItem]
func markAsRead(id: String) async throws
func markAllAsRead() async throws
func getUnreadCount() async throws -> Int
}
// MARK: - Default Service
class NotificationsService: NotificationsServiceProtocol {
private let baseURL: URL
private let session: URLSession
private let authToken: String?
static let defaultBaseURL: URL = URL(string: "http://localhost:3000")!
init(
baseURL: URL = defaultBaseURL,
session: URLSession = .shared,
authToken: String? = nil
) {
self.baseURL = baseURL
self.session = session
self.authToken = authToken
}
func list(params: NotificationListParams = NotificationListParams()) async throws -> [NotificationItem] {
var components = URLComponents(url: baseURL.appendingPathComponent("/api/notifications"), resolvingAgainstBaseURL: true)!
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "limit", value: String(params.limit)),
URLQueryItem(name: "offset", value: String(params.offset))
]
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(NotificationListResponse.self, from: data)
return decoded.notifications
}
func markAsRead(id: String) async throws {
let url = baseURL.appendingPathComponent("/api/notifications/\(id)/read")
let request = try buildRequest(url: url, method: .patch)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
_ = try JSONDecoder().decode(NotificationMarkAsReadResponse.self, from: data)
}
func markAllAsRead() async throws {
let url = baseURL.appendingPathComponent("/api/notifications/read-all")
let request = try buildRequest(url: url, method: .patch)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
_ = try JSONDecoder().decode(NotificationMarkAllReadResponse.self, from: data)
}
func getUnreadCount() async throws -> Int {
let url = baseURL.appendingPathComponent("/api/notifications/unread-count")
let request = try buildRequest(url: url)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
let decoded = try JSONDecoder().decode(NotificationUnreadCountResponse.self, from: data)
return decoded.count
}
// MARK: - Helpers
private func buildRequest(url: URL, method: HTTPMethod = .get) 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")
}
return request
}
private func validateResponse(_ response: URLResponse) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw NotificationError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
switch httpResponse.statusCode {
case 401: throw NotificationError.unauthorized
case 403: throw NotificationError.forbidden
case 404: throw NotificationError.notFound
case 429: throw NotificationError.rateLimited
case 500...599: throw NotificationError.serverError(httpResponse.statusCode)
default: throw NotificationError.httpError(httpResponse.statusCode)
}
}
}
}
// MARK: - Error Types
enum NotificationError: LocalizedError {
case invalidResponse
case unauthorized
case forbidden
case notFound
case rateLimited
case serverError(Int)
case httpError(Int)
case decodingError(Error)
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 "Notification 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))"
case .decodingError(let error): return "Decoding error: \(error.localizedDescription)"
}
}
}
// MARK: - HTTP Method (moved to Utils/HTTPMethod.swift)

View File

@@ -1,135 +0,0 @@
import Foundation
// MARK: - Service Protocol
protocol RaceServiceProtocol: Sendable {
func listRaces(filter: RaceFilter) async throws -> [Race]
func getRace(id: String) async throws -> Race
func saveRace(id: String, isSaved: Bool) async throws
func registerForRace(id: String) async throws
}
// MARK: - Default Service
class RaceService: RaceServiceProtocol {
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 listRaces(filter: RaceFilter = RaceFilter()) async throws -> [Race] {
var components = URLComponents(url: baseURL.appendingPathComponent("/api/races"), resolvingAgainstBaseURL: true)!
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "limit", value: String(filter.limit)),
URLQueryItem(name: "offset", value: String(filter.offset))
]
if let distance = filter.distanceKm { queryItems.append(URLQueryItem(name: "distance", value: String(distance))) }
if let type = filter.raceType { queryItems.append(URLQueryItem(name: "type", value: type.rawValue)) }
if let terrain = filter.terrainType { queryItems.append(URLQueryItem(name: "terrain", value: terrain.rawValue)) }
if let startDate = filter.startDate { queryItems.append(URLQueryItem(name: "startDate", value: ISO8601DateFormatter().string(from: startDate))) }
if let endDate = filter.endDate { queryItems.append(URLQueryItem(name: "endDate", value: ISO8601DateFormatter().string(from: endDate))) }
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(RaceListResponse.self, from: data)
return decoded.races
}
func getRace(id: String) async throws -> Race {
let url = baseURL.appendingPathComponent("/api/races/\(id)")
let request = try buildRequest(url: url)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
let decoded = try JSONDecoder().decode(RaceDetailResponse.self, from: data)
return decoded.race
}
func saveRace(id: String, isSaved: Bool) async throws {
let method: HTTPMethod = isSaved ? .post : .delete
let url = baseURL.appendingPathComponent("/api/races/\(id)/save")
let request = try buildRequest(url: url, method: method)
let (_, response) = try await session.data(for: request)
try validateResponse(response)
}
func registerForRace(id: String) async throws {
let url = baseURL.appendingPathComponent("/api/races/\(id)/register")
let request = try buildRequest(url: url, method: .post)
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 RaceError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
switch httpResponse.statusCode {
case 401: throw RaceError.unauthorized
case 403: throw RaceError.forbidden
case 404: throw RaceError.notFound
case 429: throw RaceError.rateLimited
case 500...599: throw RaceError.serverError(httpResponse.statusCode)
default: throw RaceError.httpError(httpResponse.statusCode)
}
}
}
}
// MARK: - Error Types
enum RaceError: 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 "Race 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))"
}
}
}

View File

@@ -1,152 +0,0 @@
import Foundation
// MARK: - Service Protocol
protocol TrainingPlanServiceProtocol: Sendable {
func listPlans(type: PlanType?, difficulty: Difficulty?) async throws -> [TrainingPlan]
func getPlan(id: String) async throws -> TrainingPlan
func generatePlan(request: GeneratePlanRequest) async throws -> TrainingPlan
func followPlan(id: String) async throws
func unfollowPlan(id: String) async throws
func updateSessionStatus(sessionId: String, status: SessionStatus) async throws
}
// MARK: - Default Service
class TrainingPlanService: TrainingPlanServiceProtocol {
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 listPlans(type: PlanType? = nil, difficulty: Difficulty? = nil) async throws -> [TrainingPlan] {
var components = URLComponents(url: baseURL.appendingPathComponent("/api/training-plans"), resolvingAgainstBaseURL: true)!
var queryItems: [URLQueryItem] = []
if let type = type { queryItems.append(URLQueryItem(name: "type", value: type.rawValue)) }
if let difficulty = difficulty { queryItems.append(URLQueryItem(name: "difficulty", value: difficulty.rawValue)) }
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(TrainingPlanListResponse.self, from: data)
return decoded.plans
}
func getPlan(id: String) async throws -> TrainingPlan {
let url = baseURL.appendingPathComponent("/api/training-plans/\(id)")
let request = try buildRequest(url: url)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
let decoded = try JSONDecoder().decode(TrainingPlanDetailResponse.self, from: data)
return decoded.plan
}
func generatePlan(request: GeneratePlanRequest) async throws -> TrainingPlan {
let url = baseURL.appendingPathComponent("/api/training-plans/generate")
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(GeneratePlanResponse.self, from: data)
return decoded.plan
}
func followPlan(id: String) async throws {
let url = baseURL.appendingPathComponent("/api/training-plans/\(id)/follow")
let request = try buildRequest(url: url, method: .post)
let (_, response) = try await session.data(for: request)
try validateResponse(response)
}
func unfollowPlan(id: String) async throws {
let url = baseURL.appendingPathComponent("/api/training-plans/\(id)/follow")
let request = try buildRequest(url: url, method: .delete)
let (_, response) = try await session.data(for: request)
try validateResponse(response)
}
func updateSessionStatus(sessionId: String, status: SessionStatus) async throws {
let url = baseURL.appendingPathComponent("/api/training-plans/sessions/\(sessionId)/status")
var request = try buildRequest(url: url, method: .patch)
let body = try JSONEncoder().encode(["status": status.rawValue])
request.httpBody = body
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 TrainingPlanError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
switch httpResponse.statusCode {
case 401: throw TrainingPlanError.unauthorized
case 403: throw TrainingPlanError.forbidden
case 404: throw TrainingPlanError.notFound
case 429: throw TrainingPlanError.rateLimited
case 500...599: throw TrainingPlanError.serverError(httpResponse.statusCode)
default: throw TrainingPlanError.httpError(httpResponse.statusCode)
}
}
}
}
// MARK: - Error Types
enum TrainingPlanError: LocalizedError {
case invalidResponse
case unauthorized
case forbidden
case notFound
case rateLimited
case serverError(Int)
case httpError(Int)
case decodingError(Error)
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 "Training plan 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))"
case .decodingError(let error): return "Decoding error: \(error.localizedDescription)"
}
}
}

View File

@@ -1,8 +0,0 @@
import Foundation
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case patch = "PATCH"
case delete = "DELETE"
}

View File

@@ -1,94 +0,0 @@
import Foundation
import SwiftUI
@MainActor
class BeginnerModeViewModel: ObservableObject {
@Published var config: BeginnerConfig?
@Published var milestones: [Milestone] = []
@Published var currentLevel: BeginnerLevel = .justStarted
@Published var isLoading: Bool = false
@Published var error: BeginnerModeError?
private let service: BeginnerModeServiceProtocol
init(service: BeginnerModeServiceProtocol = BeginnerModeService()) {
self.service = service
}
func fetchConfig() async {
isLoading = true
error = nil
defer { isLoading = false }
do {
config = try await service.getConfig()
currentLevel = config?.currentLevel ?? .justStarted
} catch let error as BeginnerModeError {
self.error = error
} catch {
print("Failed to fetch beginner config: \(error)")
}
}
func fetchMilestoneProgress() async {
isLoading = true
error = nil
defer { isLoading = false }
do {
let result = try await service.getMilestoneProgress()
milestones = result.milestones
currentLevel = result.level
} catch let error as BeginnerModeError {
self.error = error
} catch {
print("Failed to fetch milestone progress: \(error)")
}
}
func toggleBeginnerMode(isEnabled: Bool) async {
do {
let request = UpdateBeginnerConfigRequest(isEnabled: isEnabled)
let updatedConfig = try await service.updateConfig(request: request)
config = updatedConfig
objectWillChange.send()
} catch {
print("Failed to toggle beginner mode: \(error)")
}
}
func completeOnboardingStep(_ step: OnboardingStep) async {
guard var currentConfig = config else { return }
currentConfig.completedOnboardingSteps.append(step)
do {
let request = UpdateBeginnerConfigRequest(completedOnboardingSteps: currentConfig.completedOnboardingSteps)
let updatedConfig = try await service.updateConfig(request: request)
config = updatedConfig
objectWillChange.send()
} catch {
print("Failed to complete onboarding step: \(error)")
}
}
var onboardingSteps: [OnboardingStep] { OnboardingStep.allCases }
var completedOnboardingCount: Int {
config?.completedOnboardingSteps.count ?? 0
}
var remainingOnboardingSteps: [OnboardingStep] {
let completed = config?.completedOnboardingSteps ?? []
return onboardingSteps.filter { !completed.contains($0) }
}
var completedMilestoneCount: Int {
milestones.filter { $0.isCompleted }.count
}
var totalMilestoneCount: Int {
milestones.count
}
var levels: [BeginnerLevel] { BeginnerLevel.allCases }
}

View File

@@ -1,165 +0,0 @@
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 }
}

View File

@@ -1,156 +0,0 @@
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 }
}

View File

@@ -1,119 +0,0 @@
import Foundation
import SwiftUI
@MainActor
class CommunityEventViewModel: ObservableObject {
@Published var events: [CommunityEvent] = []
@Published var selectedEvent: CommunityEvent?
@Published var participants: [EventParticipant] = []
@Published var isLoading: Bool = false
@Published var error: CommunityEventError?
@Published var filter: EventFilter = EventFilter()
private let service: CommunityEventServiceProtocol
init(service: CommunityEventServiceProtocol = CommunityEventService()) {
self.service = service
}
func fetchEvents() async {
isLoading = true
error = nil
defer { isLoading = false }
do {
events = try await service.listEvents(filter: filter)
} catch let error as CommunityEventError {
self.error = error
} catch {
print("Failed to fetch events: \(error)")
}
}
func selectEvent(id: String) async {
isLoading = true
error = nil
defer { isLoading = false }
do {
let result = try await service.getEvent(id: id)
selectedEvent = result.event
participants = result.participants
if let index = events.firstIndex(where: { $0.id == id }) {
events[index] = result.event
objectWillChange.send()
}
} catch let error as CommunityEventError {
self.error = error
} catch {
print("Failed to get event: \(error)")
}
}
func createEvent(request: CreateEventRequest) async -> CommunityEvent? {
isLoading = true
error = nil
defer { isLoading = false }
do {
let event = try await service.createEvent(request: request)
events.insert(event, at: 0)
objectWillChange.send()
return event
} catch let error as CommunityEventError {
self.error = error
return nil
} catch {
print("Failed to create event: \(error)")
return nil
}
}
func updateEvent(id: String, request: UpdateEventRequest) async {
isLoading = true
error = nil
defer { isLoading = false }
do {
let updatedEvent = try await service.updateEvent(id: id, request: request)
if let index = events.firstIndex(where: { $0.id == id }) {
events[index] = updatedEvent
objectWillChange.send()
}
if selectedEvent?.id == id {
selectedEvent = updatedEvent
}
} catch let error as CommunityEventError {
self.error = error
} catch {
print("Failed to update event: \(error)")
}
}
func RSVP(eventId: String, status: RSVPStatus) async {
do {
try await service.RSVP(eventId: eventId, status: status)
if let index = events.firstIndex(where: { $0.id == eventId }) {
events[index].rsvpStatus = status
objectWillChange.send()
}
} catch {
print("Failed to RSVP: \(error)")
}
}
var upcomingEvents: [CommunityEvent] {
events.filter { $0.isUpcoming }.sorted { $0.startDate < $1.startDate }
}
var ongoingEvents: [CommunityEvent] {
events.filter { $0.isOngoing }
}
var pastEvents: [CommunityEvent] {
events.filter { $0.isPast }.sorted { $0.endDate > $1.endDate }
}
var eventTypes: [EventType] { EventType.allCases }
var rsvpStatuses: [RSVPStatus] { RSVPStatus.allCases }
}

View File

@@ -1,78 +0,0 @@
import Foundation
import SwiftUI
@MainActor
class FamilyPlanViewModel: ObservableObject {
@Published var familyPlan: FamilyPlan?
@Published var leaderboard: [FamilyLeaderboardEntry] = []
@Published var selectedMetric: LeaderboardMetric = .distance
@Published var isLoading: Bool = false
@Published var error: FamilyPlanError?
private let service: FamilyPlanServiceProtocol
init(service: FamilyPlanServiceProtocol = FamilyPlanService()) {
self.service = service
}
func fetchFamilyPlan() async {
isLoading = true
error = nil
defer { isLoading = false }
do {
familyPlan = try await service.getFamilyPlan()
} catch let error as FamilyPlanError {
self.error = error
} catch {
print("Failed to fetch family plan: \(error)")
}
}
func inviteMember(email: String, name: String) async {
guard let plan = familyPlan, plan.availableSlots > 0 else { return }
let request = InviteMemberRequest(email: email, name: name)
do {
try await service.inviteMember(request: request)
objectWillChange.send()
} catch {
print("Failed to invite member: \(error)")
}
}
func removeMember(id: String) async {
guard let index = familyPlan?.members.firstIndex(where: { $0.id == id }) else { return }
do {
try await service.removeMember(id: id)
familyPlan?.members.remove(at: index)
objectWillChange.send()
} catch {
print("Failed to remove member: \(error)")
}
}
func fetchLeaderboard() async {
isLoading = true
error = nil
defer { isLoading = false }
do {
leaderboard = try await service.getLeaderboard(metric: selectedMetric)
} catch let error as FamilyPlanError {
self.error = error
} catch {
print("Failed to fetch leaderboard: \(error)")
}
}
var activeMembers: [FamilyMember] {
familyPlan?.members.filter { $0.role == .member || $0.role == .owner } ?? []
}
var pendingInvites: [FamilyMember] {
familyPlan?.members.filter { $0.role == .pending } ?? []
}
var metrics: [LeaderboardMetric] { LeaderboardMetric.allCases }
}

View File

@@ -1,80 +0,0 @@
import Foundation
import SwiftUI
@MainActor
class NotificationsViewModel: ObservableObject {
@Published var notifications: [NotificationItem] = []
@Published var isLoading: Bool = false
@Published var badgeCount: Int = 0
@Published var lastRefreshDate: Date?
@Published var error: NotificationError?
private let notificationsService: NotificationsServiceProtocol
init(notificationsService: NotificationsServiceProtocol = NotificationsService()) {
self.notificationsService = notificationsService
}
func fetchNotifications() async {
isLoading = true
error = nil
defer {
isLoading = false
lastRefreshDate = Date()
}
do {
let fetchedNotifications = try await notificationsService.list()
notifications = fetchedNotifications.sorted { $0.createdAt > $1.createdAt }
badgeCount = notifications.filter { !$0.isRead }.count
} catch let error as NotificationError {
self.error = error
} catch {
print("Failed to fetch notifications: \(error)")
}
}
func refresh() async {
await fetchNotifications()
}
func fetchUnreadCount() async {
do {
let count = try await notificationsService.getUnreadCount()
badgeCount = count
} catch {
print("Failed to fetch unread count: \(error)")
}
}
func markAsRead(id: String) async {
guard let index = notifications.firstIndex(where: { $0.id == id }) else { return }
do {
try await notificationsService.markAsRead(id: id)
notifications[index].isRead = true
badgeCount = max(0, badgeCount - 1)
} catch {
print("Failed to mark notification as read: \(error)")
}
}
func markAllAsRead() async {
let unreadIds = notifications.filter { !$0.isRead }.map { $0.id }
guard !unreadIds.isEmpty else { return }
do {
try await notificationsService.markAllAsRead()
for index in notifications.indices {
notifications[index].isRead = true
}
badgeCount = 0
} catch {
print("Failed to mark all as read: \(error)")
}
}
var unreadCount: Int {
notifications.filter { !$0.isRead }.count
}
}

View File

@@ -1,106 +0,0 @@
import Foundation
import SwiftUI
@MainActor
class RaceDiscoveryViewModel: ObservableObject {
@Published var races: [Race] = []
@Published var savedRaces: [Race] = []
@Published var selectedRace: Race?
@Published var isLoading: Bool = false
@Published var error: RaceError?
@Published var filter: RaceFilter = RaceFilter()
private let service: RaceServiceProtocol
init(service: RaceServiceProtocol = RaceService()) {
self.service = service
}
func fetchRaces() async {
isLoading = true
error = nil
defer { isLoading = false }
do {
races = try await service.listRaces(filter: filter)
} catch let error as RaceError {
self.error = error
} catch {
print("Failed to fetch races: \(error)")
}
}
func selectRace(id: String) async {
isLoading = true
error = nil
defer { isLoading = false }
do {
let race = try await service.getRace(id: id)
selectedRace = race
if let index = races.firstIndex(where: { $0.id == id }) {
races[index] = race
objectWillChange.send()
}
} catch let error as RaceError {
self.error = error
} catch {
print("Failed to get race: \(error)")
}
}
func toggleSaveRace(id: String) async {
guard let race = races.first(where: { $0.id == id }) else { return }
let newSavedState = !race.isSaved
do {
try await service.saveRace(id: id, isSaved: newSavedState)
if let index = races.firstIndex(where: { $0.id == id }) {
races[index].isSaved = newSavedState
objectWillChange.send()
}
if newSavedState {
savedRaces.append(races.first(where: { $0.id == id }) ?? race)
} else {
savedRaces.removeAll { $0.id == id }
}
objectWillChange.send()
} catch {
print("Failed to toggle save race: \(error)")
}
}
func registerForRace(id: String) async {
do {
try await service.registerForRace(id: id)
if let index = races.firstIndex(where: { $0.id == id }) {
races[index].isRegistered = true
objectWillChange.send()
}
} catch {
print("Failed to register for race: \(error)")
}
}
func fetchSavedRaces() async {
isLoading = true
error = nil
defer { isLoading = false }
do {
savedRaces = try await service.listRaces(filter: RaceFilter(limit: 50, offset: 0))
.filter { $0.isSaved }
} catch let error as RaceError {
self.error = error
} catch {
print("Failed to fetch saved races: \(error)")
}
}
var upcomingRaces: [Race] {
races.filter { $0.isUpcoming }.sorted { $0.raceDate < $1.raceDate }
}
var raceTypes: [RaceType] { RaceType.allCases }
var terrainTypes: [TerrainType] { TerrainType.allCases }
}

View File

@@ -1,108 +0,0 @@
import Foundation
import SwiftUI
@MainActor
class TrainingPlanViewModel: ObservableObject {
@Published var plans: [TrainingPlan] = []
@Published var selectedPlan: TrainingPlan?
@Published var isLoading: Bool = false
@Published var error: TrainingPlanError?
private let service: TrainingPlanServiceProtocol
init(service: TrainingPlanServiceProtocol = TrainingPlanService()) {
self.service = service
}
func fetchPlans(type: PlanType? = nil, difficulty: Difficulty? = nil) async {
isLoading = true
error = nil
defer { isLoading = false }
do {
plans = try await service.listPlans(type: type, difficulty: difficulty)
} catch let error as TrainingPlanError {
self.error = error
} catch {
print("Failed to fetch training plans: \(error)")
}
}
func selectPlan(id: String) async {
isLoading = true
error = nil
defer { isLoading = false }
do {
selectedPlan = try await service.getPlan(id: id)
} catch let error as TrainingPlanError {
self.error = error
} catch {
print("Failed to get plan: \(error)")
}
}
func generatePlan(request: GeneratePlanRequest) async -> TrainingPlan? {
isLoading = true
error = nil
defer { isLoading = false }
do {
let plan = try await service.generatePlan(request: request)
plans.insert(plan, at: 0)
return plan
} catch let error as TrainingPlanError {
self.error = error
return nil
} catch {
print("Failed to generate plan: \(error)")
return nil
}
}
func followPlan(id: String) async {
do {
try await service.followPlan(id: id)
if let index = plans.firstIndex(where: { $0.id == id }) {
plans[index].isFollowing = true
objectWillChange.send()
}
} catch {
print("Failed to follow plan: \(error)")
}
}
func unfollowPlan(id: String) async {
do {
try await service.unfollowPlan(id: id)
if let index = plans.firstIndex(where: { $0.id == id }) {
plans[index].isFollowing = false
objectWillChange.send()
}
} catch {
print("Failed to unfollow plan: \(error)")
}
}
func updateSessionStatus(sessionId: String, status: SessionStatus) async {
do {
try await service.updateSessionStatus(sessionId: sessionId, status: status)
if var plan = selectedPlan {
for weekIndex in plan.weeklyWorkouts.indices {
for sessionIndex in plan.weeklyWorkouts[weekIndex].dailySessions.indices {
if plan.weeklyWorkouts[weekIndex].dailySessions[sessionIndex].id == sessionId {
plan.weeklyWorkouts[weekIndex].dailySessions[sessionIndex].status = status
selectedPlan = plan
return
}
}
}
}
} catch {
print("Failed to update session status: \(error)")
}
}
var planTypes: [PlanType] { PlanType.allCases }
var difficulties: [Difficulty] { Difficulty.allCases }
}

View File

@@ -1,173 +0,0 @@
import SwiftUI
struct BeginnerModeView: View {
@StateObject private var viewModel = BeginnerModeViewModel()
@State private var showingOnboarding = false
var body: some View {
NavigationView {
Group {
if viewModel.isLoading && viewModel.config == nil {
loadingView
} else {
content
}
}
.navigationTitle("Beginner Mode")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if let config = viewModel.config {
ToolbarItem(placement: .navigationBarTrailing) {
Toggle("", isOn: Binding(
get: { config.isEnabled },
set: { isEnabled in
Task {
await viewModel.toggleBeginnerMode(isEnabled: isEnabled)
}
}
))
}
}
}
}
.onAppear {
Task {
await viewModel.fetchConfig()
await viewModel.fetchMilestoneProgress()
}
}
}
@ViewBuilder
private var content: some View {
List {
Section("Current Level") {
if let config = viewModel.config {
HStack {
Image(systemName: config.currentLevel.icon)
.font(.system(size: 28))
.foregroundColor(.blue)
VStack(alignment: .leading, spacing: 2) {
Text(config.currentLevel.displayName)
.font(.headline)
Text("Workout #\(config.currentLevel.requiredWorkouts) to advance")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
}
Section("Onboarding Progress") {
Text("\(viewModel.completedOnboardingCount)/\(viewModel.onboardingSteps.count) steps completed")
.font(.subheadline)
ForEach(viewModel.remainingOnboardingSteps, id: \.self) { step in
HStack {
Image(systemName: "circle")
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(step.displayName)
.font(.subheadline)
Text(step.description)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
if viewModel.remainingOnboardingSteps.isEmpty {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("All steps completed!")
.font(.subheadline)
.foregroundColor(.green)
}
}
}
Section("Milestones") {
Text("\(viewModel.completedMilestoneCount)/\(viewModel.totalMilestoneCount) achieved")
.font(.subheadline)
ForEach(viewModel.milestones) { milestone in
MilestoneRow(milestone: milestone)
}
}
Section("Quick Tips") {
tipRow(icon: "lightbulb.fill", title: "Start Slow", message: "Begin with shorter distances and gradually increase.")
tipRow(icon: "heart.fill", title: "Stay Consistent", message: "Regular workouts yield better results than occasional long ones.")
tipRow(icon: "drop.fill", title: "Hydrate", message: "Keep water nearby during all workouts.")
tipRow(icon: "moon.fill", title: "Rest Days", message: "Recovery is when your body gets stronger.")
}
}
.listStyle(.insetGrouped)
}
private func tipRow(icon: String, title: String, message: String) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundColor(.orange)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
.fontWeight(.medium)
Text(message)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
Text("Loading Beginner Mode...")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.vertical, 60)
}
}
struct MilestoneRow: View {
let milestone: Milestone
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(milestone.isCompleted ? Color.orange.opacity(0.2) : Color.secondary.opacity(0.1))
.frame(width: 40, height: 40)
Image(systemName: milestone.isCompleted ? "\(milestone.icon).fill" : milestone.icon)
.font(.system(size: 18))
.foregroundColor(milestone.isCompleted ? .orange : .secondary)
}
VStack(alignment: .leading, spacing: 2) {
Text(milestone.title)
.font(.subheadline)
.fontWeight(.medium)
Text(milestone.description)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if milestone.isCompleted {
Image(systemName: "star.fill")
.foregroundColor(.orange)
}
}
}
}
#Preview {
BeginnerModeView()
}

View File

@@ -1,272 +0,0 @@
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)
)
}

View File

@@ -1,280 +0,0 @@
import SwiftUI
struct ChallengesView: View {
@StateObject private var viewModel = ChallengeViewModel()
@State private var showingCreateSheet = false
@State private var selectedTab: ChallengeTab = .active
@State private var alertIsPresented = false
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(viewModel: viewModel)
}
.alert("Error", isPresented: $alertIsPresented) {
Button("OK") {
viewModel.error = nil
alertIsPresented = false
}
} message: {
Text(viewModel.error?.errorDescription ?? "")
}
.onChange(of: viewModel.error) { _ in
if viewModel.error != nil {
alertIsPresented = true
}
}
}
.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
let viewModel: ChallengeViewModel
@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
)
Task {
_ = await viewModel.createChallenge(request: request)
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()
}

View File

@@ -1,276 +0,0 @@
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)
)
}

View File

@@ -1,250 +0,0 @@
import SwiftUI
struct ClubsView: View {
@StateObject private var viewModel = ClubViewModel()
@State private var showingCreateSheet = false
@State private var selectedTab: ClubTab = .discover
@State private var alertIsPresented = false
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(viewModel: viewModel)
}
.alert("Error", isPresented: $alertIsPresented) {
Button("OK") {
viewModel.error = nil
alertIsPresented = false
}
} message: {
Text(viewModel.error?.errorDescription ?? "")
}
.onChange(of: viewModel.error) { _ in
if viewModel.error != nil {
alertIsPresented = true
}
}
}
.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
let viewModel: ClubViewModel
@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
)
Task {
_ = await viewModel.createClub(request: request)
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()
}

View File

@@ -1,209 +0,0 @@
import SwiftUI
struct CommunityEventDetailView: View {
let event: CommunityEvent
@StateObject private var viewModel = CommunityEventViewModel()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
eventHeader
eventInfoSection
eventDescription
rsvpSection
participantsSection
}
.padding(.horizontal)
.padding(.bottom, 32)
}
.navigationTitle(event.title)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
Task {
await viewModel.selectEvent(id: event.id)
}
}
}
private var eventHeader: some View {
VStack(spacing: 12) {
HStack {
Image(systemName: event.eventType.icon)
.font(.system(size: 40))
.foregroundColor(event.eventType.color)
VStack(alignment: .leading, spacing: 4) {
Text(event.eventType.displayName)
.font(.title2)
.fontWeight(.bold)
if let distance = event.distanceKm {
Text("\(distance) km \u2022 \(event.location)")
} else {
Text(event.location)
}
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
Divider()
HStack(spacing: 20) {
infoItem(label: "Date", value: formatDate(event.startDate))
infoItem(label: "Participants", value: "\(event.participantCount)")
if let difficulty = event.difficulty {
infoItem(label: "Difficulty", value: difficulty.displayName)
}
}
}
.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 eventInfoSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Event Details")
.font(.headline)
VStack(alignment: .leading, spacing: 6) {
detailRow(label: "Organizer", value: event.organizerName)
detailRow(label: "Location", value: event.location)
detailRow(label: "Start", value: formatDateTime(event.startDate))
detailRow(label: "End", value: formatDateTime(event.endDate))
if let max = event.maxParticipants {
detailRow(label: "Capacity", value: "\(event.participantCount)/\(max)")
}
}
}
}
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 eventDescription: some View {
VStack(alignment: .leading, spacing: 4) {
Text("About This Event")
.font(.headline)
Text(event.description)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
private var rsvpSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Your RSVP")
.font(.headline)
HStack(spacing: 12) {
rsvpButton(title: "Going", icon: "checkmark.circle", status: .going, color: .green)
rsvpButton(title: "Maybe", icon: "questionmark.circle", status: .maybe, color: .orange)
rsvpButton(title: "Not Going", icon: "xmark.circle", status: .notGoing, color: .red)
}
}
}
private func rsvpButton(title: String, icon: String, status: RSVPStatus, color: Color) -> some View {
Button {
Task {
await viewModel.RSVP(eventId: event.id, status: status)
}
} label: {
VStack(spacing: 4) {
Image(systemName: event.rsvpStatus == status ? "\(icon).fill" : icon)
.foregroundColor(event.rsvpStatus == status ? color : .secondary)
Text(title)
.font(.caption)
.foregroundColor(event.rsvpStatus == status ? color : .secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(event.rsvpStatus == status ? color.opacity(0.15) : Color.secondary.opacity(0.08))
.cornerRadius(8)
}
}
private var participantsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Participants (\(event.participantCount))")
.font(.headline)
ForEach(viewModel.participants) { participant in
HStack(spacing: 12) {
Circle()
.fill(Color.secondary.opacity(0.2))
.frame(width: 32, height: 32)
Text(participant.name)
.font(.subheadline)
Spacer()
Text(participant.rsvpStatus.rawValue.capitalized)
.font(.caption)
.foregroundColor(participant.rsvpStatus == .going ? .green : .secondary)
}
}
}
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: date)
}
private func formatDateTime(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: date)
}
}
#Preview {
NavigationView {
CommunityEventDetailView(event: sampleEvent)
}
}
private var sampleEvent: CommunityEvent {
CommunityEvent(
id: "1",
title: "Sunday Morning Group Run",
description: "Join us for a friendly morning run through the park. All paces welcome!",
eventType: .groupRun,
location: "Central Park",
latitude: 40.7851,
longitude: -73.9683,
startDate: Date().addingTimeInterval(7 * 24 * 3600),
endDate: Date().addingTimeInterval(7 * 24 * 3600 + 3600),
distanceKm: 10,
organizerId: "user1",
organizerName: "Running Club",
maxParticipants: 50,
participantCount: 23,
imageUrl: nil,
difficulty: .beginner,
rsvpStatus: .pending,
createdAt: Date()
)
}

View File

@@ -1,236 +0,0 @@
import SwiftUI
struct CommunityEventsView: View {
@StateObject private var viewModel = CommunityEventViewModel()
@State private var showingCreateSheet = false
@State private var selectedTab: EventTab = .upcoming
enum EventTab: String, CaseIterable {
case upcoming, ongoing, past
}
var body: some View {
NavigationView {
Group {
if viewModel.isLoading && viewModel.events.isEmpty {
loadingView
} else if currentEvents.isEmpty {
emptyStateView
} else {
eventListView
}
}
.navigationTitle("Community Events")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingCreateSheet = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingCreateSheet) {
CreateEventSheet()
}
}
.onAppear {
Task {
await viewModel.fetchEvents()
}
}
}
private var currentEvents: [CommunityEvent] {
switch selectedTab {
case .upcoming: return viewModel.upcomingEvents
case .ongoing: return viewModel.ongoingEvents
case .past: return viewModel.pastEvents
}
}
private var eventListView: some View {
List {
Picker("Events", selection: $selectedTab) {
ForEach(EventTab.allCases, id: \.self) { tab in
Text(tab.rawValue.capitalized).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.top, 8)
Section(currentSectionTitle) {
ForEach(currentEvents) { event in
NavigationLink(destination: CommunityEventDetailView(event: event)) {
EventRowView(event: event)
}
}
}
}
.listStyle(.insetGrouped)
.refreshable {
await viewModel.fetchEvents()
}
}
private var currentSectionTitle: String {
switch selectedTab {
case .upcoming: return "Upcoming"
case .ongoing: return "Happening Now"
case .past: return "Past Events"
}
}
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
Text("Loading Events...")
.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) Events")
.font(.title2)
.fontWeight(.semibold)
Text("Create or discover community running events in your area.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.vertical, 60)
}
}
struct CreateEventSheet: View {
@Environment(\.dismiss) var dismiss
@State private var title = ""
@State private var description = ""
@State private var eventType: EventType = .groupRun
@State private var location = ""
@State private var distanceKm = ""
@State private var maxParticipants = ""
var body: some View {
NavigationView {
Form {
Section("Event Details") {
TextField("Event Title", text: $title)
TextField("Description", text: $description)
TextField("Location", text: $location)
}
Section("Type") {
Picker("Event Type", selection: $eventType) {
ForEach(EventType.allCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
}
Section("Optional") {
TextField("Distance (km)", text: $distanceKm)
.keyboardType(.decimalPad)
TextField("Max Participants", text: $maxParticipants)
.keyboardType(.numberPad)
}
}
.navigationTitle("Create Event")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Create") {
let request = CreateEventRequest(
title: title,
description: description,
eventType: eventType,
location: location,
latitude: 0,
longitude: 0,
startDate: Date(),
endDate: Date().addingTimeInterval(3600),
distanceKm: Double(distanceKm),
maxParticipants: Int(maxParticipants),
difficulty: nil
)
dismiss()
}
.disabled(title.isEmpty || location.isEmpty)
}
}
}
}
}
struct EventRowView: View {
let event: CommunityEvent
var body: some View {
HStack(spacing: 12) {
Image(systemName: event.eventType.icon)
.font(.system(size: 24))
.foregroundColor(event.eventType.color)
.frame(width: 44, height: 44)
.background(event.eventType.color.opacity(0.15))
.cornerRadius(10)
VStack(alignment: .leading, spacing: 4) {
Text(event.title)
.font(.headline)
Text("\(event.location) \u2022 \(event.organizerName)")
.font(.subheadline)
.foregroundColor(.secondary)
HStack(spacing: 8) {
Text(formatDate(event.startDate))
.font(.caption)
.foregroundColor(.secondary)
if let spots = event.availableSpots, spots > 0 {
Text("\(spots) spots left")
.font(.caption2)
.foregroundColor(.green)
}
}
}
Spacer()
switch event.rsvpStatus {
case .going:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
case .maybe:
Image(systemName: "questionmark.circle.fill")
.foregroundColor(.orange)
case .notGoing:
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
case .pending:
Image(systemName: "circle")
.foregroundColor(.secondary)
}
}
.padding(.vertical, 4)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: date)
}
}
#Preview {
CommunityEventsView()
}

View File

@@ -1,125 +0,0 @@
import SwiftUI
struct FamilyMemberView: View {
let member: FamilyMember
@State private var weeklyData: [(day: String, distance: Double)] = []
var body: some View {
ScrollView {
VStack(spacing: 16) {
memberHeader
statsSection
weeklyActivitySection
}
.padding(.horizontal)
.padding(.bottom, 32)
}
.navigationTitle(member.name)
.navigationBarTitleDisplayMode(.inline)
}
private var memberHeader: some View {
VStack(spacing: 16) {
ZStack {
Circle()
.fill(member.isPrimary ? Color.blue.opacity(0.2) : Color.green.opacity(0.2))
.frame(width: 80, height: 80)
Image(systemName: member.role.icon)
.font(.system(size: 36))
.foregroundColor(member.isPrimary ? .blue : .green)
}
VStack(spacing: 4) {
Text(member.name)
.font(.title3)
.fontWeight(.bold)
Text(member.role.displayName)
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.frame(maxWidth: .infinity)
.background(Color.secondary.opacity(0.1))
.cornerRadius(12)
}
private var statsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Statistics")
.font(.headline)
HStack(spacing: 16) {
statCard(value: "\(Int(member.totalDistanceKm))", label: "Total km", icon: "figure.run")
statCard(value: "\(member.totalWorkouts)", label: "Workouts", icon: "checkmark.circle")
statCard(value: "\(Int(member.weeklyDistanceKm))", label: "This Week", icon: "chart.bar.fill")
}
}
}
private func statCard(value: String, label: String, icon: String) -> some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundColor(.blue)
Text(value)
.font(.title3)
.fontWeight(.bold)
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.secondary.opacity(0.08))
.cornerRadius(10)
}
private var weeklyActivitySection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Weekly Activity")
.font(.headline)
VStack(spacing: 8) {
ForEach(DayOfWeek.allCases, id: \.self) { day in
HStack {
Text(day.displayName)
.font(.subheadline)
.frame(width: 36)
GeometryReader { geo in
RoundedRectangle(cornerRadius: 3)
.fill(Color.blue.opacity(0.6))
.frame(width: min(geo.size.width, 200), height: 24)
}
.frame(height: 24)
Text("\(Int(member.weeklyDistanceKm / 7)) km")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
}
#Preview {
NavigationView {
FamilyMemberView(member: sampleMember)
}
}
private var sampleMember: FamilyMember {
FamilyMember(
id: "1",
name: "John Doe",
email: "john@example.com",
role: .owner,
joinedAt: Date().addingTimeInterval(-30 * 24 * 3600),
avatarUrl: nil,
isPrimary: true,
totalDistanceKm: 245.5,
totalWorkouts: 42,
weeklyDistanceKm: 32.0,
weeklyWorkouts: 5
)
}

View File

@@ -1,244 +0,0 @@
import SwiftUI
struct FamilyPlanView: View {
@StateObject private var viewModel = FamilyPlanViewModel()
@State private var showingInviteSheet = false
@State private var selectedMetric: LeaderboardMetric = .distance
var body: some View {
NavigationView {
Group {
if viewModel.isLoading && viewModel.familyPlan == nil {
loadingView
} else if let plan = viewModel.familyPlan {
planContent(plan)
} else {
emptyStateView
}
}
.navigationTitle("Family Plan")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if let plan = viewModel.familyPlan, plan.isActive {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingInviteSheet = true
} label: {
Text("Invite")
}
}
}
}
.sheet(isPresented: $showingInviteSheet) {
InviteMemberSheet()
}
}
.onAppear {
Task {
await viewModel.fetchFamilyPlan()
await viewModel.fetchLeaderboard()
}
}
}
@ViewBuilder
private func planContent(_ plan: FamilyPlan) -> some View {
List {
Section("Plan Status") {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(plan.ownerName)
.font(.headline)
Text("\(plan.members.count)/\(plan.maxMembers) members")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Text(plan.subscriptionStatus.displayName)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(plan.subscriptionStatus.color.opacity(0.2))
.cornerRadius(6)
.foregroundColor(plan.subscriptionStatus.color)
}
if let renewalDate = plan.renewalDate {
HStack {
Text("Renews")
.foregroundColor(.secondary)
Spacer()
Text(formatDate(renewalDate))
.fontWeight(.medium)
}
.font(.subheadline)
}
}
Section("Members") {
ForEach(plan.members) { member in
MemberRowView(member: member)
}
}
Section("Leaderboard") {
Picker("Metric", selection: $selectedMetric) {
ForEach(viewModel.metrics, id: \.self) { metric in
Text(metric.displayName).tag(metric)
}
}
.onChange(of: selectedMetric) { newValue in
viewModel.selectedMetric = newValue
Task { await viewModel.fetchLeaderboard() }
}
if viewModel.leaderboard.isEmpty {
Text("No data yet")
.foregroundColor(.secondary)
.font(.subheadline)
} else {
ForEach(viewModel.leaderboard) { entry in
LeaderboardRow(entry: entry, metric: selectedMetric)
}
}
}
}
.listStyle(.insetGrouped)
}
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
Text("Loading Family Plan...")
.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 Family Plan")
.font(.title2)
.fontWeight(.semibold)
Text("Create a family plan to share your subscription with up to 6 members.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.vertical, 60)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: date)
}
}
struct InviteMemberSheet: View {
@Environment(\.dismiss) var dismiss
@State private var email = ""
@State private var name = ""
var body: some View {
NavigationView {
Form {
Section("New Member") {
TextField("Name", text: $name)
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
}
.navigationTitle("Invite Member")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Send Invite") {
dismiss()
}
.disabled(email.isEmpty || name.isEmpty)
}
}
}
}
}
struct MemberRowView: View {
let member: FamilyMember
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(member.isPrimary ? Color.blue.opacity(0.2) : Color.secondary.opacity(0.15))
.frame(width: 40, height: 40)
Image(systemName: member.role.icon)
.font(.system(size: 18))
.foregroundColor(member.isPrimary ? .blue : .secondary)
}
VStack(alignment: .leading, spacing: 2) {
Text(member.name)
.font(.subheadline)
.fontWeight(.medium)
Text(member.email)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(Int(member.weeklyDistanceKm)) km")
.font(.caption)
.fontWeight(.medium)
Text("this week")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 4)
}
}
struct LeaderboardRow: View {
let entry: FamilyLeaderboardEntry
let metric: LeaderboardMetric
var body: some View {
HStack(spacing: 12) {
Text("#\(entry.rank)")
.font(.headline)
.frame(width: 30)
Circle()
.fill(Color.secondary.opacity(0.2))
.frame(width: 32, height: 32)
Text(entry.memberName)
.font(.subheadline)
Spacer()
Text("\(Int(entry.value))\(metric.unit)")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.secondary)
}
}
}
#Preview {
FamilyPlanView()
}

View File

@@ -1,88 +0,0 @@
import SwiftUI
struct MainTabView: View {
@State private var selectedTab: AppTab = .home
@StateObject private var notificationVM = NotificationsViewModel()
var body: some View {
TabView(selection: $selectedTab) {
Group {
TrainingPlanView()
.tag(AppTab.home)
.tabItem {
Label(AppTab.home.title, systemImage: AppTab.home.icon)
}
ChallengesView()
.tag(AppTab.challenges)
.tabItem {
Label(AppTab.challenges.title, systemImage: AppTab.challenges.icon)
}
ClubsView()
.tag(AppTab.clubs)
.tabItem {
Label(AppTab.clubs.title, systemImage: AppTab.clubs.icon)
}
NotificationsView(viewModel: notificationVM)
.tag(AppTab.notifications)
.tabItem {
Label(AppTab.notifications.title, systemImage: AppTab.notifications.icon)
}
.badge(notificationVM.badgeCount)
SettingsView()
.tag(AppTab.profile)
.tabItem {
Label(AppTab.profile.title, systemImage: AppTab.profile.icon)
}
}
}
.onAppear {
Task {
await notificationVM.fetchUnreadCount()
}
}
.onChange(of: selectedTab) { _, newTab in
if newTab == .notifications {
Task {
await notificationVM.fetchNotifications()
await notificationVM.fetchUnreadCount()
}
}
}
}
}
enum AppTab: String, CaseIterable {
case home
case challenges
case clubs
case notifications
case profile
var title: String {
switch self {
case .home: return "Home"
case .challenges: return "Challenges"
case .clubs: return "Clubs"
case .notifications: return "Notifications"
case .profile: return "Profile"
}
}
var icon: String {
switch self {
case .home: return "house.fill"
case .challenges: return "flag.fill"
case .clubs: return "person.3.fill"
case .notifications: return "bell.fill"
case .profile: return "person.circle"
}
}
}
#Preview {
MainTabView()
}

View File

@@ -1,93 +0,0 @@
import SwiftUI
struct NotificationRowView: View {
let notification: NotificationItem
var body: some View {
HStack(spacing: 12) {
// Notification icon
Image(systemName: notification.type.icon)
.font(.system(size: 24))
.foregroundColor(notification.type.color)
.accessibilityLabel(notification.type.rawValue)
// Notification content
VStack(alignment: .leading, spacing: 4) {
Text(notification.title)
.font(.headline)
.foregroundColor(.primary)
Text(notification.message)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
}
Spacer()
// Timestamp and read indicator
VStack(alignment: .trailing, spacing: 4) {
if !notification.isRead {
Image(systemName: "circle.fill")
.font(.system(size: 8))
.foregroundColor(.blue)
}
Text(formatTimestamp(notification.createdAt))
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
.contentShape(Rectangle())
}
private func formatTimestamp(_ date: Date) -> String {
Self.formatter.localizedString(for: date, relativeTo: Date())
}
private static let formatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter
}()
}
#Preview {
List {
NotificationRowView(
notification: NotificationItem(
id: "1",
type: .loanApproved,
title: "Loan Approved",
message: "Your loan application for $500 has been approved by Sarah Johnson.",
createdAt: Date().addingTimeInterval(-3600),
isRead: false
)
)
NotificationRowView(
notification: NotificationItem(
id: "2",
type: .paymentDue,
title: "Payment Due Soon",
message: "Your payment of $150 is due in 3 days.",
createdAt: Date().addingTimeInterval(-86400 * 2),
isRead: true
)
)
NotificationRowView(
notification: NotificationItem(
id: "3",
type: .paymentReceived,
title: "Payment Received",
message: "You received a payment of $75 from Michael Chen.",
createdAt: Date().addingTimeInterval(-86400 * 5),
isRead: false
)
)
}
.listStyle(.insetGrouped)
.previewDisplayName("Notification Row Preview")
}

View File

@@ -1,105 +0,0 @@
import SwiftUI
struct NotificationsView: View {
@StateObject private var viewModel: NotificationsViewModel
@State private var showingRefreshIndicator = false
init(viewModel: NotificationsViewModel = NotificationsViewModel()) {
self._viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
NavigationStack {
Group {
if viewModel.notifications.isEmpty && !viewModel.isLoading {
emptyStateView
} else {
notificationListView
}
}
.navigationTitle("Notifications")
.toolbar {
if !viewModel.notifications.isEmpty {
ToolbarItem(placement: .navigationBarTrailing) {
if viewModel.unreadCount > 0 {
Button {
Task {
await viewModel.markAllAsRead()
}
} label: {
Text("Mark All Read")
.font(.caption)
}
.foregroundColor(.blue)
}
}
}
}
}
.onAppear {
Task {
await viewModel.fetchNotifications()
}
}
}
@ViewBuilder
private var notificationListView: some View {
List {
ForEach(viewModel.notifications) { notification in
NotificationRowView(notification: notification)
.onTapGesture {
Task {
if !notification.isRead {
await viewModel.markAsRead(id: notification.id)
}
}
}
}
.onDelete { offsets in
Task {
for index in offsets {
let notification = viewModel.notifications[index]
await viewModel.markAsRead(id: notification.id)
}
viewModel.notifications.remove(atOffsets: offsets)
}
}
}
.listStyle(.insetGrouped)
.refreshable {
await viewModel.refresh()
}
}
private var emptyStateView: some View {
VStack(spacing: 16) {
Image(systemName: "bell.slash")
.font(.system(size: 64))
.foregroundColor(.secondary)
Text("No Notifications")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
Text("You're all caught up!\nWhen you have notifications, they'll appear here.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.vertical, 60)
}
}
#Preview {
NotificationsView()
}
#Preview("With Data") {
let previewView = NotificationsView()
// Inject mock data for preview
return previewView
}

View File

@@ -1,182 +0,0 @@
import SwiftUI
struct RaceDetailView: View {
let race: Race
@StateObject private var viewModel = RaceDiscoveryViewModel()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
raceHeader
raceInfoSection
raceDescription
actionButtons
}
.padding(.horizontal)
.padding(.bottom, 32)
}
.navigationTitle(race.name)
.navigationBarTitleDisplayMode(.inline)
}
private var raceHeader: some View {
VStack(spacing: 12) {
HStack {
Image(systemName: race.raceType.icon)
.font(.system(size: 40))
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 4) {
Text(race.raceType.displayName)
.font(.title2)
.fontWeight(.bold)
Text("\(race.distanceKm) km \u2022 \(race.terrainType.displayName)")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Button(action: {
Task {
await viewModel.toggleSaveRace(id: race.id)
}
}) {
Image(systemName: race.isSaved ? "bookmark.fill" : "bookmark")
.font(.title3)
.foregroundColor(race.isSaved ? .blue : .secondary)
}
}
Divider()
HStack(spacing: 20) {
infoItem(label: "Date", value: formatDate(race.raceDate))
infoItem(label: "Location", value: race.location)
infoItem(label: "Days Left", value: "\(race.daysUntilRace)")
}
if let count = race.participantCount {
HStack(spacing: 20) {
infoItem(label: "Participants", value: "\(count)")
infoItem(label: "Elevation", value: "\(Int(race.elevationGain))m")
infoItem(label: "Terrain", value: race.terrainType.displayName)
}
}
}
.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 raceInfoSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Race Details")
.font(.headline)
VStack(alignment: .leading, spacing: 6) {
detailRow(label: "Organizer", value: race.organizerName)
detailRow(label: "Distance", value: "\(race.distanceKm) km")
detailRow(label: "Elevation Gain", value: "\(Int(race.elevationGain))m")
detailRow(label: "Terrain", value: race.terrainType.displayName)
}
}
}
private func detailRow(label: String, value: String) -> some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundColor(.secondary)
.frame(width: 120, alignment: .leading)
Text(value)
.font(.subheadline)
.fontWeight(.medium)
}
}
private var raceDescription: some View {
VStack(alignment: .leading, spacing: 4) {
Text("About This Race")
.font(.headline)
Text(race.description)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
private var actionButtons: some View {
VStack(spacing: 12) {
if let url = race.registrationUrl, !race.isRegistered {
Button {
if let registrationUrl = URL(string: url) {
UIApplication.shared.open(registrationUrl)
}
} label: {
Text("Register Now")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
}
if race.isRegistered {
HStack {
Image(systemName: "checkmark.circle.fill")
Text("Registered")
}
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.green.opacity(0.15))
.foregroundColor(.green)
.cornerRadius(10)
}
}
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: date)
}
}
#Preview {
NavigationView {
RaceDetailView(race: sampleRace)
}
}
private var sampleRace: Race {
Race(
id: "1",
name: "City Marathon 2026",
description: "An annual marathon through the heart of the city. Features a flat, fast course suitable for all levels.",
location: "Downtown",
latitude: 40.7128,
longitude: -74.006,
raceDate: Date().addingTimeInterval(90 * 24 * 3600),
distanceKm: 42.2,
raceType: .road,
organizerName: "City Athletics Club",
registrationUrl: "https://example.com/register",
imageUrl: nil,
participantCount: 5000,
isRegistered: false,
isSaved: true,
elevationGain: 120,
terrainType: .flat
)
}

View File

@@ -1,165 +0,0 @@
import SwiftUI
struct RaceDiscoveryView: View {
@StateObject private var viewModel = RaceDiscoveryViewModel()
@State private var showingFilters = false
@State private var showingSavedRaces = false
var body: some View {
NavigationView {
Group {
if viewModel.isLoading && viewModel.races.isEmpty {
loadingView
} else if viewModel.races.isEmpty {
emptyStateView
} else {
raceListView
}
}
.navigationTitle("Race Discovery")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button {
showingSavedRaces.toggle()
} label: {
Text("Saved Races")
Image(systemName: "bookmark.fill")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(isPresented: $showingSavedRaces) {
NavigationView {
SavedRacesSheet(viewModel: viewModel)
.navigationTitle("Saved Races")
.navigationBarTitleDisplayMode(.inline)
}
}
}
.onAppear {
Task {
await viewModel.fetchRaces()
}
}
}
private var raceListView: some View {
List {
Section("Upcoming Races") {
ForEach(viewModel.upcomingRaces) { race in
NavigationLink(destination: RaceDetailView(race: race)) {
RaceRowView(race: race)
}
}
}
}
.listStyle(.insetGrouped)
.refreshable {
await viewModel.fetchRaces()
}
}
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
Text("Loading Races...")
.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 Races Found")
.font(.title2)
.fontWeight(.semibold)
Text("Discover local races and events to train for.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.vertical, 60)
}
}
struct SavedRacesSheet: View {
@ObservedObject var viewModel: RaceDiscoveryViewModel
var body: some View {
List {
if viewModel.savedRaces.isEmpty {
Text("No saved races yet")
.foregroundColor(.secondary)
} else {
ForEach(viewModel.savedRaces) { race in
RaceRowView(race: race)
}
}
}
}
}
struct RaceRowView: View {
let race: Race
var body: some View {
HStack(spacing: 12) {
Image(systemName: race.raceType.icon)
.font(.system(size: 24))
.foregroundColor(.orange)
.frame(width: 44, height: 44)
.background(Color.orange.opacity(0.15))
.cornerRadius(10)
VStack(alignment: .leading, spacing: 4) {
Text(race.name)
.font(.headline)
Text("\(race.location) \u2022 \(race.distanceKm) km")
.font(.subheadline)
.foregroundColor(.secondary)
HStack(spacing: 8) {
Text(formatDate(race.raceDate))
.font(.caption)
.foregroundColor(.secondary)
if race.isRegistered {
Text("Registered")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.green.opacity(0.2))
.cornerRadius(4)
.foregroundColor(.green)
}
}
}
Spacer()
if race.isSaved {
Image(systemName: "bookmark.fill")
.foregroundColor(.blue)
}
}
.padding(.vertical, 4)
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .none
return formatter.string(from: date)
}
}
#Preview {
RaceDiscoveryView()
}

View File

@@ -1,111 +0,0 @@
import SwiftUI
struct SettingsView: View {
@StateObject private var authViewModel = AuthViewModel()
var body: some View {
List {
Section {
HStack {
Image(systemName: "person.circle")
.font(.system(size: 40))
.foregroundColor(.primary)
VStack(alignment: .leading) {
Text(authViewModel.userName ?? "User")
.font(.headline)
Text(authViewModel.userEmail ?? "email@example.com")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
}
Section("About") {
HStack {
Text("Version")
Spacer()
Text(AppSettings.appVersion)
.foregroundColor(.secondary)
}
HStack {
Text("Build")
Spacer()
Text(AppSettings.buildNumber)
.foregroundColor(.secondary)
}
}
Section("Legal") {
Link(destination: AppSettings.termsOfServiceURL ?? URL(string: "about:blank")!) {
HStack {
Text("Terms of Service")
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
Link(destination: AppSettings.privacyPolicyURL ?? URL(string: "about:blank")!) {
HStack {
Text("Privacy Policy")
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Section("Account") {
Button(action: {
authViewModel.logout()
}) {
HStack {
Image(systemName: "arrow.right.to.line")
Text("Log Out")
Spacer()
}
}
Button(role: .destructive, action: {
authViewModel.deleteAccount()
}) {
HStack {
Image(systemName: "trash")
Text("Delete Account")
Spacer()
}
}
}
}
.navigationTitle("Settings")
}
}
class AuthViewModel: ObservableObject {
@Published var userName: String?
@Published var userEmail: String?
func logout() {
// Implement logout logic
userName = nil
userEmail = nil
}
func deleteAccount() {
// Implement account deletion logic
userName = nil
userEmail = nil
}
}
#Preview {
NavigationView {
SettingsView()
}
}

View File

@@ -1,213 +0,0 @@
import SwiftUI
struct TrainingPlanDetailView: View {
let plan: TrainingPlan
@State private var expandedWeek: Int? = 1
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
planHeader
progressSection
planDescription
weeklyWorkoutsSection
}
.padding(.horizontal)
.padding(.bottom, 32)
}
.navigationTitle(plan.title)
.navigationBarTitleDisplayMode(.inline)
}
private var planHeader: some View {
VStack(spacing: 8) {
HStack {
Image(systemName: plan.planType.icon)
.font(.system(size: 36))
.foregroundColor(plan.difficulty.color)
VStack(alignment: .leading, spacing: 4) {
Text(plan.planType.displayName)
.font(.title2)
.fontWeight(.bold)
Text(plan.difficulty.displayName)
.font(.subheadline)
.foregroundColor(plan.difficulty.color)
}
Spacer()
Toggle("", isOn: Binding(
get: { plan.isFollowing },
set: { _ in }
))
}
Divider()
HStack(spacing: 24) {
statLabel(value: "\(plan.durationWeeks)", label: "Weeks")
statLabel(value: "\(plan.progress.totalSessions)", label: "Sessions")
statLabel(value: "\(Int(plan.progress.percentage))%", label: "Progress")
}
}
.padding()
.background(Color.secondary.opacity(0.1))
.cornerRadius(12)
}
private func statLabel(value: String, label: String) -> some View {
VStack(spacing: 4) {
Text(value)
.font(.title3)
.fontWeight(.bold)
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
}
private var progressSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Overall Progress")
.font(.headline)
ProgressView(value: plan.progress.percentage)
.tint(.blue)
HStack {
Text("\(plan.progress.completedWeeks)/\(plan.progress.totalWeeks) weeks")
Spacer()
Text("\(plan.progress.completedSessions)/\(plan.progress.totalSessions) sessions")
}
.font(.caption)
.foregroundColor(.secondary)
}
}
private var planDescription: some View {
VStack(alignment: .leading, spacing: 4) {
Text("About This Plan")
.font(.headline)
Text(plan.description)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
private var weeklyWorkoutsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Weekly Schedule")
.font(.headline)
ForEach(plan.weeklyWorkouts, id: \.id) { week in
WeekCard(week: week, isExpanded: expandedWeek == week.weekNumber) {
expandedWeek = expandedWeek == week.weekNumber ? nil : week.weekNumber
}
}
}
}
}
struct WeekCard: View {
let week: WeeklyWorkout
let isExpanded: Bool
let toggleAction: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Week \(week.weekNumber)")
.font(.headline)
Spacer()
Text("\(week.completedSessions)/\(week.totalSessions)")
.font(.caption)
.foregroundColor(.secondary)
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
}
.padding(.vertical, 8)
.padding(.horizontal)
.background(Color.secondary.opacity(0.1))
.cornerRadius(8)
.onTapGesture { toggleAction() }
if isExpanded {
ForEach(week.dailySessions) { session in
DailySessionRow(session: session)
}
}
}
}
}
struct DailySessionRow: View {
let session: DailySession
var body: some View {
HStack(spacing: 12) {
Image(systemName: session.workoutType.icon)
.font(.system(size: 20))
.foregroundColor(session.workoutType.color)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("\(session.dayOfWeek.displayName): \(session.workoutType.displayName)")
.font(.subheadline)
.fontWeight(.medium)
if session.status == .completed {
Text("Completed")
.font(.caption)
.foregroundColor(.green)
} else if let distance = session.targetDistanceKm {
Text("\(distance) km")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
switch session.status {
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
case .inProgress:
Image(systemName: "pause.circle.fill")
.foregroundColor(.orange)
case .skipped:
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
case .pending:
Image(systemName: "circle")
.foregroundColor(.secondary)
}
}
.padding(.vertical, 4)
}
}
#Preview {
NavigationView {
TrainingPlanDetailView(plan: samplePlan)
}
}
private var samplePlan: TrainingPlan {
TrainingPlan(
id: "1",
title: "5K Beginner Plan",
description: "A 8-week plan designed to help beginners complete their first 5K race.",
planType: .fiveK,
durationWeeks: 8,
difficulty: .beginner,
startDate: Date(),
endDate: Date().addingTimeInterval(8 * 7 * 24 * 3600),
weeklyWorkouts: [
WeeklyWorkout(id: "w1", weekNumber: 1, dailySessions: [
DailySession(id: "s1", dayOfWeek: .monday, workoutType: .easyRun, title: "Easy Run", description: "Start with a comfortable pace", targetDistanceKm: 2.0, targetDurationMinutes: 20, targetPaceMinPerKm: 10, intensity: .easy, status: .completed),
DailySession(id: "s2", dayOfWeek: .wednesday, workoutType: .rest, title: "Rest Day", description: "Recovery", targetDistanceKm: nil, targetDurationMinutes: nil, targetPaceMinPerKm: nil, intensity: .veryEasy, status: .completed),
DailySession(id: "s3", dayOfWeek: .friday, workoutType: .easyRun, title: "Easy Run", description: "Build endurance", targetDistanceKm: 2.5, targetDurationMinutes: 25, targetPaceMinPerKm: 10, intensity: .easy, status: .pending),
DailySession(id: "s4", dayOfWeek: .saturday, workoutType: .longRun, title: "Long Run", description: "Gradually increase distance", targetDistanceKm: 3.0, targetDurationMinutes: 30, targetPaceMinPerKm: 10, intensity: .moderate, status: .pending)
])
],
progress: PlanProgress(completedWeeks: 0, totalWeeks: 8, completedSessions: 2, totalSessions: 32, currentWeekNumber: 1),
isFollowing: true,
createdAt: Date()
)
}

View File

@@ -1,219 +0,0 @@
import SwiftUI
struct TrainingPlanView: View {
@StateObject private var viewModel = TrainingPlanViewModel()
@State private var selectedType: PlanType? = nil
@State private var selectedDifficulty: Difficulty? = nil
@State private var showingGenerateSheet = false
var body: some View {
NavigationView {
Group {
if viewModel.isLoading && viewModel.plans.isEmpty {
loadingView
} else if viewModel.plans.isEmpty {
emptyStateView
} else {
planListView
}
}
.navigationTitle("Training Plans")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingGenerateSheet = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingGenerateSheet) {
GeneratePlanSheet()
}
}
.onAppear {
Task {
await viewModel.fetchPlans()
}
}
}
private var planListView: some View {
List {
if selectedType != nil || selectedDifficulty != nil {
Section("Filters") {
HStack {
Text("Type: \(selectedType?.displayName ?? "All")")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text("Difficulty: \(selectedDifficulty?.displayName ?? "All")")
.font(.caption)
.foregroundColor(.secondary)
}
Button("Clear Filters") {
selectedType = nil
selectedDifficulty = nil
Task { await viewModel.fetchPlans() }
}
}
}
Section("Plans") {
ForEach(viewModel.plans) { plan in
NavigationLink(destination: TrainingPlanDetailView(plan: plan)) {
PlanRowView(plan: plan)
}
}
}
Section("Filter by Type") {
ForEach(viewModel.planTypes, id: \.self) { type in
Button(type.displayName) {
selectedType = type
Task { await viewModel.fetchPlans(type: type) }
}
.foregroundColor(type == selectedType ? .blue : .primary)
}
}
Section("Filter by Difficulty") {
ForEach(viewModel.difficulties, id: \.self) { difficulty in
Button(difficulty.displayName) {
selectedDifficulty = difficulty
Task { await viewModel.fetchPlans(difficulty: difficulty) }
}
.foregroundColor(difficulty == selectedDifficulty ? .blue : .primary)
}
}
}
.listStyle(.insetGrouped)
.refreshable {
await viewModel.fetchPlans()
}
}
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
Text("Loading Plans...")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.vertical, 60)
}
private var emptyStateView: some View {
VStack(spacing: 16) {
Image(systemName: "figure.run")
.font(.system(size: 64))
.foregroundColor(.secondary)
Text("No Training Plans")
.font(.title2)
.fontWeight(.semibold)
Text("Start by generating a personalized plan or browse available plans.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.vertical, 60)
}
}
struct GeneratePlanSheet: View {
@Environment(\.dismiss) var dismiss
@State private var planType: PlanType = .fiveK
@State private var difficulty: Difficulty = .beginner
@State private var weeklyMileage: String = ""
@State private var goalTime: String = ""
var body: some View {
NavigationView {
Form {
Section("Plan Type") {
Picker("Type", selection: $planType) {
ForEach(PlanType.allCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
}
Section("Difficulty") {
Picker("Difficulty", selection: $difficulty) {
ForEach(Difficulty.allCases, id: \.self) { diff in
Text(diff.displayName).tag(diff)
}
}
}
Section("Optional Details") {
TextField("Current Weekly Mileage (km)", text: $weeklyMileage)
.keyboardType(.decimalPad)
TextField("Goal Time (minutes)", text: $goalTime)
.keyboardType(.numberPad)
}
}
.navigationTitle("Generate Plan")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Generate") {
let request = GeneratePlanRequest(
planType: planType,
difficulty: difficulty,
startDate: Date(),
currentWeeklyMileageKm: Double(weeklyMileage),
goalTimeMinutes: Int(goalTime),
availableDays: [.monday, .wednesday, .friday, .saturday]
)
dismiss()
}
}
}
}
}
}
struct PlanRowView: View {
let plan: TrainingPlan
var body: some View {
HStack(spacing: 12) {
Image(systemName: plan.planType.icon)
.font(.system(size: 28))
.foregroundColor(plan.difficulty.color)
.frame(width: 44, height: 44)
.background(plan.difficulty.color.opacity(0.15))
.cornerRadius(10)
VStack(alignment: .leading, spacing: 4) {
Text(plan.title)
.font(.headline)
Text("\(plan.planType.displayName) \u2022 \(plan.durationWeeks) weeks \u2022 \(plan.difficulty.displayName)")
.font(.subheadline)
.foregroundColor(.secondary)
ProgressView(value: plan.progress.percentage)
.tint(.blue)
.scaleEffect(y: 0.5)
.padding(.vertical, -4)
}
Spacer()
if plan.isFollowing {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
}
.padding(.vertical, 4)
}
}
#Preview {
TrainingPlanView()
}

View File

@@ -1,211 +0,0 @@
import SwiftUI
struct WorkoutSessionView: View {
let session: DailySession
@StateObject private var viewModel = TrainingPlanViewModel()
@State private var isRunning: Bool = false
@State private var elapsedSeconds: Int = 0
var body: some View {
ScrollView {
VStack(spacing: 20) {
sessionHeader
metricsSection
workoutInstructions
actionButtons
}
.padding(.horizontal)
.padding(.bottom, 32)
}
.navigationTitle(session.workoutType.displayName)
.navigationBarTitleDisplayMode(.inline)
}
private var sessionHeader: some View {
VStack(spacing: 12) {
Image(systemName: session.workoutType.icon)
.font(.system(size: 48))
.foregroundColor(session.workoutType.color)
VStack(spacing: 4) {
Text(session.title)
.font(.title2)
.fontWeight(.bold)
Text(session.dayOfWeek.displayName)
.font(.subheadline)
.foregroundColor(.secondary)
}
HStack(spacing: 24) {
if let distance = session.targetDistanceKm {
metricCard(value: "\(distance)", label: "Target km", icon: "figure.run")
}
if let duration = session.targetDurationMinutes {
metricCard(value: "\(duration)", label: "Target min", icon: "clock")
}
if let pace = session.targetPaceMinPerKm {
metricCard(value: "\(Int(pace)):00", label: "Pace /km", icon: "speedometer")
}
}
}
.padding()
.frame(maxWidth: .infinity)
.background(Color.secondary.opacity(0.1))
.cornerRadius(12)
}
private func metricCard(value: String, label: String, icon: String) -> some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: 18))
.foregroundColor(session.workoutType.color)
Text(value)
.font(.title3)
.fontWeight(.bold)
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
}
private var metricsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Current Session")
.font(.headline)
HStack(spacing: 16) {
currentMetric(value: "\(formatElapsed(elapsedSeconds))", label: "Elapsed", icon: "stopwatch")
currentMetric(value: "0.0", label: "Distance", icon: "route")
currentMetric(value: "--:--", label: "Pace", icon: "speedometer")
}
}
}
private func currentMetric(value: String, label: String, icon: String) -> some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: 16))
.foregroundColor(.blue)
Text(value)
.font(.title3)
.fontWeight(.bold)
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.blue.opacity(0.08))
.cornerRadius(10)
}
private var workoutInstructions: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Instructions")
.font(.headline)
Text(session.description)
.font(.subheadline)
.foregroundColor(.secondary)
if session.intensity != .veryEasy {
HStack {
Text("Intensity")
Spacer()
HStack(spacing: 2) {
ForEach(1...5, id: \.self) { i in
Circle()
.fill(i <= sessionIntensityLevel ? session.workoutType.color : Color.secondary.opacity(0.2))
.frame(width: 8, height: 8)
}
}
}
.font(.caption)
}
}
}
private var sessionIntensityLevel: Int {
switch session.intensity {
case .veryEasy: return 1
case .easy: return 2
case .moderate: return 3
case .hard: return 4
case .veryHard: return 5
}
}
private var actionButtons: some View {
VStack(spacing: 12) {
if isRunning {
Button {
isRunning = false
Task {
await viewModel.updateSessionStatus(sessionId: session.id, status: .completed)
}
} label: {
Text("Finish Workout")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
Button {
isRunning = false
Task {
await viewModel.updateSessionStatus(sessionId: session.id, status: .skipped)
}
} label: {
Text("Skip Session")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.orange.opacity(0.15))
.foregroundColor(.orange)
.cornerRadius(10)
}
} else {
Button {
isRunning = true
} label: {
Text("Start Workout")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(session.workoutType.color)
.foregroundColor(.white)
.cornerRadius(10)
}
}
}
}
private func formatElapsed(_ seconds: Int) -> String {
let mins = seconds / 60
let secs = seconds % 60
return String(format: "%d:%02d", mins, secs)
}
}
#Preview {
NavigationView {
WorkoutSessionView(session: sampleSession)
}
}
private var sampleSession: DailySession {
DailySession(
id: "1",
dayOfWeek: .monday,
workoutType: .easyRun,
title: "Easy Recovery Run",
description: "Keep the pace comfortable. Focus on maintaining good form and breathing rhythm.",
targetDistanceKm: 5.0,
targetDurationMinutes: 30,
targetPaceMinPerKm: 6,
intensity: .easy,
status: .pending
)
}