From 57a460761a7fb6ec66090f44d4723e2f05b59475 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 3 May 2026 15:21:01 -0400 Subject: [PATCH] FRE-4665: Implement Phase 3 AI training plans and premium features - Models: TrainingPlan, Race, FamilyPlan, BeginnerMode, CommunityEvent - Services: 5 service layers with protocol-based architecture - ViewModels: 5 view models with @MainActor ObservableObject pattern - Views: 10 SwiftUI views for all Phase 3 features - Updated README with full Phase 3 documentation Co-Authored-By: Paperclip --- Lendair/Models/BeginnerMode.swift | 201 +++++++++++ Lendair/Models/CommunityEvent.swift | 243 ++++++++++++++ Lendair/Models/FamilyPlan.swift | 217 ++++++++++++ Lendair/Models/Race.swift | 183 ++++++++++ Lendair/Models/TrainingPlan.swift | 312 ++++++++++++++++++ Lendair/README.md | 198 +++++++---- Lendair/Services/BeginnerModeService.swift | 118 +++++++ Lendair/Services/CommunityEventService.swift | 153 +++++++++ Lendair/Services/FamilyPlanService.swift | 125 +++++++ Lendair/Services/RaceService.swift | 135 ++++++++ Lendair/Services/TrainingPlanService.swift | 152 +++++++++ .../ViewModels/BeginnerModeViewModel.swift | 94 ++++++ .../ViewModels/CommunityEventViewModel.swift | 119 +++++++ Lendair/ViewModels/FamilyPlanViewModel.swift | 78 +++++ .../ViewModels/RaceDiscoveryViewModel.swift | 106 ++++++ .../ViewModels/TrainingPlanViewModel.swift | 108 ++++++ Lendair/Views/BeginnerModeView.swift | 173 ++++++++++ Lendair/Views/CommunityEventDetailView.swift | 209 ++++++++++++ Lendair/Views/CommunityEventsView.swift | 236 +++++++++++++ Lendair/Views/FamilyMemberView.swift | 125 +++++++ Lendair/Views/FamilyPlanView.swift | 244 ++++++++++++++ Lendair/Views/RaceDetailView.swift | 182 ++++++++++ Lendair/Views/RaceDiscoveryView.swift | 165 +++++++++ Lendair/Views/TrainingPlanDetailView.swift | 213 ++++++++++++ Lendair/Views/TrainingPlanView.swift | 219 ++++++++++++ Lendair/Views/WorkoutSessionView.swift | 211 ++++++++++++ 26 files changed, 4457 insertions(+), 62 deletions(-) create mode 100644 Lendair/Models/BeginnerMode.swift create mode 100644 Lendair/Models/CommunityEvent.swift create mode 100644 Lendair/Models/FamilyPlan.swift create mode 100644 Lendair/Models/Race.swift create mode 100644 Lendair/Models/TrainingPlan.swift create mode 100644 Lendair/Services/BeginnerModeService.swift create mode 100644 Lendair/Services/CommunityEventService.swift create mode 100644 Lendair/Services/FamilyPlanService.swift create mode 100644 Lendair/Services/RaceService.swift create mode 100644 Lendair/Services/TrainingPlanService.swift create mode 100644 Lendair/ViewModels/BeginnerModeViewModel.swift create mode 100644 Lendair/ViewModels/CommunityEventViewModel.swift create mode 100644 Lendair/ViewModels/FamilyPlanViewModel.swift create mode 100644 Lendair/ViewModels/RaceDiscoveryViewModel.swift create mode 100644 Lendair/ViewModels/TrainingPlanViewModel.swift create mode 100644 Lendair/Views/BeginnerModeView.swift create mode 100644 Lendair/Views/CommunityEventDetailView.swift create mode 100644 Lendair/Views/CommunityEventsView.swift create mode 100644 Lendair/Views/FamilyMemberView.swift create mode 100644 Lendair/Views/FamilyPlanView.swift create mode 100644 Lendair/Views/RaceDetailView.swift create mode 100644 Lendair/Views/RaceDiscoveryView.swift create mode 100644 Lendair/Views/TrainingPlanDetailView.swift create mode 100644 Lendair/Views/TrainingPlanView.swift create mode 100644 Lendair/Views/WorkoutSessionView.swift diff --git a/Lendair/Models/BeginnerMode.swift b/Lendair/Models/BeginnerMode.swift new file mode 100644 index 000000000..ecf309bb3 --- /dev/null +++ b/Lendair/Models/BeginnerMode.swift @@ -0,0 +1,201 @@ +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 + 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 +} diff --git a/Lendair/Models/CommunityEvent.swift b/Lendair/Models/CommunityEvent.swift new file mode 100644 index 000000000..4f7e6e7da --- /dev/null +++ b/Lendair/Models/CommunityEvent.swift @@ -0,0 +1,243 @@ +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 +} diff --git a/Lendair/Models/FamilyPlan.swift b/Lendair/Models/FamilyPlan.swift new file mode 100644 index 000000000..90ee899cd --- /dev/null +++ b/Lendair/Models/FamilyPlan.swift @@ -0,0 +1,217 @@ +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 +} diff --git a/Lendair/Models/Race.swift b/Lendair/Models/Race.swift new file mode 100644 index 000000000..e32421b40 --- /dev/null +++ b/Lendair/Models/Race.swift @@ -0,0 +1,183 @@ +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? +} diff --git a/Lendair/Models/TrainingPlan.swift b/Lendair/Models/TrainingPlan.swift new file mode 100644 index 000000000..e0ff8ce6e --- /dev/null +++ b/Lendair/Models/TrainingPlan.swift @@ -0,0 +1,312 @@ +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 +} diff --git a/Lendair/README.md b/Lendair/README.md index ea25467b3..3d18ed59c 100644 --- a/Lendair/README.md +++ b/Lendair/README.md @@ -1,12 +1,12 @@ -# Lendair iOS Notifications +# Lendair iOS App ## Overview -SwiftUI implementation of the notifications feature for the Lendair iOS app. +SwiftUI iOS app with modular feature architecture following MVVM pattern. ## Architecture ### MVVM Pattern -- **View**: `Views/` - SwiftUI views for notification display +- **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 @@ -15,95 +15,169 @@ SwiftUI implementation of the notifications feature for the Lendair iOS app. ``` Lendair/ ├── Models/ -│ └── Notification.swift # NotificationItem, NotificationType, API response types +│ ├── 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 ├── Services/ -│ └── NotificationService.swift # NotificationsServiceProtocol + implementation +│ ├── NotificationService.swift # NotificationsServiceProtocol + implementation +│ ├── TrainingPlanService.swift # TrainingPlanServiceProtocol + implementation +│ ├── RaceService.swift # RaceServiceProtocol + implementation +│ ├── FamilyPlanService.swift # FamilyPlanServiceProtocol + implementation +│ ├── BeginnerModeService.swift # BeginnerModeServiceProtocol + implementation +│ └── CommunityEventService.swift # CommunityEventServiceProtocol + implementation ├── ViewModels/ -│ └── NotificationsViewModel.swift # State management, mark-as-read actions +│ ├── NotificationsViewModel.swift +│ ├── TrainingPlanViewModel.swift +│ ├── RaceDiscoveryViewModel.swift +│ ├── FamilyPlanViewModel.swift +│ ├── BeginnerModeViewModel.swift +│ └── CommunityEventViewModel.swift ├── Views/ -│ ├── NotificationsView.swift # Main notifications list screen -│ └── NotificationRowView.swift # Individual notification row +│ ├── NotificationsView.swift +│ ├── NotificationRowView.swift +│ ├── TrainingPlanView.swift +│ ├── TrainingPlanDetailView.swift +│ ├── WorkoutSessionView.swift +│ ├── RaceDiscoveryView.swift +│ ├── RaceDetailView.swift +│ ├── FamilyPlanView.swift +│ ├── FamilyMemberView.swift +│ ├── BeginnerModeView.swift +│ ├── CommunityEventsView.swift +│ └── CommunityEventDetailView.swift └── README.md ``` -## Components +## Features -### NotificationsView (`Views/NotificationsView.swift`) -- Main navigation container for the notifications screen -- Pull-to-refresh via `.refreshable` -- Empty state when no notifications -- "Mark All Read" toolbar button when unread count > 0 -- Tap-to-mark-as-read on individual rows -- Swipe-to-delete (TODO: backend integration) +### Notifications +- Notification list with pull-to-refresh +- Mark-as-read (individual and bulk) +- Type-specific icons and color coding +- Empty state handling -### NotificationRowView (`Views/NotificationRowView.swift`) -- Individual notification list item -- Type-specific SF Symbol icon with color coding -- Read/unread indicator (blue dot) -- Relative timestamp display +### 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 -### NotificationsViewModel (`ViewModels/NotificationsViewModel.swift`) -- `@Published notifications` — sorted by createdAt descending -- `@Published isLoading` — loading state for UI feedback -- `@Published error` — typed error state (NotificationError) -- `fetchNotifications()` — loads from service -- `markAsRead(id:)` — marks single notification, updates local state -- `markAllAsRead()` — marks all unread, updates local state -- `unreadCount` — computed property for badge display +### 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 -### NotificationsService (`Services/NotificationService.swift`) -- Protocol: `NotificationsServiceProtocol` (Sendable, testable) -- `list(params:)` — GET `/api/notifications?limit=&offset=` -- `markAsRead(id:)` — PATCH `/api/notifications/:id/read` -- `markAllAsRead()` — PATCH `/api/notifications/read-all` -- Error handling: `NotificationError` enum with localized descriptions -- Configurable: baseURL, URLSession, authToken +### 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 -### Models (`Models/Notification.swift`) -- `NotificationItem` — Identifiable, Equatable, Codable -- `NotificationType` — 6 cases with icon/color mappings -- `NotificationListParams` — pagination parameters -- `NotificationListResponse`, `NotificationMarkAsReadResponse`, `NotificationMarkAllReadResponse` — API response types +### 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 -## Notification Types +### 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 -| Type | Icon | Color | -|------|------|-------| -| `LOAN_APPROVED` | checkmark.circle.fill | Green | -| `LOAN_REJECTED` | xmark.circle.fill | Red | -| `PAYMENT_RECEIVED` | arrow.down.circle.fill | Green | -| `PAYMENT_DUE` | exclamationmark.circle.fill | Orange | -| `NEW_LENDER` | person.circle.fill | Blue | -| `SYSTEM_UPDATE` | info.circle.fill | Gray | +## 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 | + ## Testing -Tests are in `LendairTests/NotificationServiceTests.swift`: -- 12 ViewModel tests (fetch, mark-as-read, mark-all-read, unread count, refresh, error handling) -- 6 Model tests (icons, colors, equality, raw values, params) -- Uses `MockNotificationsService` conforming to `NotificationsServiceProtocol` +Tests are in `LendairTests/`: +- Uses mock services conforming to feature protocols +- ViewModel tests cover fetch, update, error handling, and computed properties +- Model tests cover enum cases, display values, and equality ## Usage ```swift -// In your MainTabView or navigation stack +// Feature views can be integrated into your navigation stack NavigationStack { - NotificationsView() + TrainingPlanView() +} + +NavigationStack { + RaceDiscoveryView() +} + +NavigationStack { + CommunityEventsView() } ``` -## Future Enhancements +## Premium Features -1. **Push Notifications**: Integrate with UNUserNotificationCenter -2. **Notification Preferences**: Allow users to customize notification types -3. **Deep Linking**: Navigate to relevant screens when tapping notifications -4. **Offline Support**: Cache notifications locally with Core Data -5. **Analytics**: Track notification engagement metrics +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. diff --git a/Lendair/Services/BeginnerModeService.swift b/Lendair/Services/BeginnerModeService.swift new file mode 100644 index 000000000..26e88e078 --- /dev/null +++ b/Lendair/Services/BeginnerModeService.swift @@ -0,0 +1,118 @@ +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))" + } + } +} diff --git a/Lendair/Services/CommunityEventService.swift b/Lendair/Services/CommunityEventService.swift new file mode 100644 index 000000000..c8d49f287 --- /dev/null +++ b/Lendair/Services/CommunityEventService.swift @@ -0,0 +1,153 @@ +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))" + } + } +} diff --git a/Lendair/Services/FamilyPlanService.swift b/Lendair/Services/FamilyPlanService.swift new file mode 100644 index 000000000..e2aec2a7a --- /dev/null +++ b/Lendair/Services/FamilyPlanService.swift @@ -0,0 +1,125 @@ +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))" + } + } +} diff --git a/Lendair/Services/RaceService.swift b/Lendair/Services/RaceService.swift new file mode 100644 index 000000000..f52af835a --- /dev/null +++ b/Lendair/Services/RaceService.swift @@ -0,0 +1,135 @@ +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))" + } + } +} diff --git a/Lendair/Services/TrainingPlanService.swift b/Lendair/Services/TrainingPlanService.swift new file mode 100644 index 000000000..082eaf227 --- /dev/null +++ b/Lendair/Services/TrainingPlanService.swift @@ -0,0 +1,152 @@ +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)" + } + } +} diff --git a/Lendair/ViewModels/BeginnerModeViewModel.swift b/Lendair/ViewModels/BeginnerModeViewModel.swift new file mode 100644 index 000000000..ba330d453 --- /dev/null +++ b/Lendair/ViewModels/BeginnerModeViewModel.swift @@ -0,0 +1,94 @@ +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 } +} diff --git a/Lendair/ViewModels/CommunityEventViewModel.swift b/Lendair/ViewModels/CommunityEventViewModel.swift new file mode 100644 index 000000000..e2af2aee7 --- /dev/null +++ b/Lendair/ViewModels/CommunityEventViewModel.swift @@ -0,0 +1,119 @@ +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 } +} diff --git a/Lendair/ViewModels/FamilyPlanViewModel.swift b/Lendair/ViewModels/FamilyPlanViewModel.swift new file mode 100644 index 000000000..19613eb45 --- /dev/null +++ b/Lendair/ViewModels/FamilyPlanViewModel.swift @@ -0,0 +1,78 @@ +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 } +} diff --git a/Lendair/ViewModels/RaceDiscoveryViewModel.swift b/Lendair/ViewModels/RaceDiscoveryViewModel.swift new file mode 100644 index 000000000..13a283797 --- /dev/null +++ b/Lendair/ViewModels/RaceDiscoveryViewModel.swift @@ -0,0 +1,106 @@ +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 } +} diff --git a/Lendair/ViewModels/TrainingPlanViewModel.swift b/Lendair/ViewModels/TrainingPlanViewModel.swift new file mode 100644 index 000000000..bdd19cea7 --- /dev/null +++ b/Lendair/ViewModels/TrainingPlanViewModel.swift @@ -0,0 +1,108 @@ +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 } +} diff --git a/Lendair/Views/BeginnerModeView.swift b/Lendair/Views/BeginnerModeView.swift new file mode 100644 index 000000000..f74c2fdc5 --- /dev/null +++ b/Lendair/Views/BeginnerModeView.swift @@ -0,0 +1,173 @@ +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() +} diff --git a/Lendair/Views/CommunityEventDetailView.swift b/Lendair/Views/CommunityEventDetailView.swift new file mode 100644 index 000000000..33a53524a --- /dev/null +++ b/Lendair/Views/CommunityEventDetailView.swift @@ -0,0 +1,209 @@ +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() + ) +} diff --git a/Lendair/Views/CommunityEventsView.swift b/Lendair/Views/CommunityEventsView.swift new file mode 100644 index 000000000..843cd44a7 --- /dev/null +++ b/Lendair/Views/CommunityEventsView.swift @@ -0,0 +1,236 @@ +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() +} diff --git a/Lendair/Views/FamilyMemberView.swift b/Lendair/Views/FamilyMemberView.swift new file mode 100644 index 000000000..c4a7ff06f --- /dev/null +++ b/Lendair/Views/FamilyMemberView.swift @@ -0,0 +1,125 @@ +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 + ) +} diff --git a/Lendair/Views/FamilyPlanView.swift b/Lendair/Views/FamilyPlanView.swift new file mode 100644 index 000000000..55a9c3be4 --- /dev/null +++ b/Lendair/Views/FamilyPlanView.swift @@ -0,0 +1,244 @@ +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() +} diff --git a/Lendair/Views/RaceDetailView.swift b/Lendair/Views/RaceDetailView.swift new file mode 100644 index 000000000..3859efd2b --- /dev/null +++ b/Lendair/Views/RaceDetailView.swift @@ -0,0 +1,182 @@ +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 + ) +} diff --git a/Lendair/Views/RaceDiscoveryView.swift b/Lendair/Views/RaceDiscoveryView.swift new file mode 100644 index 000000000..51b875afa --- /dev/null +++ b/Lendair/Views/RaceDiscoveryView.swift @@ -0,0 +1,165 @@ +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() +} diff --git a/Lendair/Views/TrainingPlanDetailView.swift b/Lendair/Views/TrainingPlanDetailView.swift new file mode 100644 index 000000000..574ef485c --- /dev/null +++ b/Lendair/Views/TrainingPlanDetailView.swift @@ -0,0 +1,213 @@ +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() + ) +} diff --git a/Lendair/Views/TrainingPlanView.swift b/Lendair/Views/TrainingPlanView.swift new file mode 100644 index 000000000..5180d41c6 --- /dev/null +++ b/Lendair/Views/TrainingPlanView.swift @@ -0,0 +1,219 @@ +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() +} diff --git a/Lendair/Views/WorkoutSessionView.swift b/Lendair/Views/WorkoutSessionView.swift new file mode 100644 index 000000000..63f69670b --- /dev/null +++ b/Lendair/Views/WorkoutSessionView.swift @@ -0,0 +1,211 @@ +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 + ) +}