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 <noreply@paperclip.ing>
This commit is contained in:
201
Lendair/Models/BeginnerMode.swift
Normal file
201
Lendair/Models/BeginnerMode.swift
Normal file
@@ -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<String>
|
||||
var preferredMetric: MetricType
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case isEnabled, currentLevel, completedOnboardingSteps, milestones, shownTips, preferredMetric
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Beginner Level
|
||||
|
||||
enum BeginnerLevel: String, CaseIterable, Codable {
|
||||
case justStarted
|
||||
case gettingComfortable
|
||||
case buildingConsistency
|
||||
case progressing
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .justStarted: return "Just Started"
|
||||
case .gettingComfortable: return "Getting Comfortable"
|
||||
case .buildingConsistency: return "Building Consistency"
|
||||
case .progressing: return "Progressing"
|
||||
}
|
||||
}
|
||||
|
||||
var requiredWorkouts: Int {
|
||||
switch self {
|
||||
case .justStarted: return 0
|
||||
case .gettingComfortable: return 5
|
||||
case .buildingConsistency: return 15
|
||||
case .progressing: return 30
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .justStarted: return "sparkles"
|
||||
case .gettingComfortable: return "leaf.fill"
|
||||
case .buildingConsistency: return "chart.bar.fill"
|
||||
case .progressing: return "bolt.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Onboarding Step
|
||||
|
||||
enum OnboardingStep: String, CaseIterable, Codable {
|
||||
case profileSetup
|
||||
case goalSelection
|
||||
case firstActivity
|
||||
case inviteFriends
|
||||
case enableNotifications
|
||||
case choosePlan
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .profileSetup: return "Profile Setup"
|
||||
case .goalSelection: return "Set Goals"
|
||||
case .firstActivity: return "First Activity"
|
||||
case .inviteFriends: return "Invite Friends"
|
||||
case .enableNotifications: return "Enable Notifications"
|
||||
case .choosePlan: return "Choose a Plan"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .profileSetup: return "Complete your profile with your name and photo"
|
||||
case .goalSelection: return "Tell us what you want to achieve"
|
||||
case .firstActivity: return "Record your first workout"
|
||||
case .inviteFriends: return "Invite friends to join Nessa"
|
||||
case .enableNotifications: return "Get reminders and updates"
|
||||
case .choosePlan: return "Pick a training plan to follow"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Milestone
|
||||
|
||||
struct Milestone: Identifiable, Codable {
|
||||
let id: String
|
||||
let title: String
|
||||
let description: String
|
||||
let icon: String
|
||||
let requirement: MilestoneRequirement
|
||||
var isCompleted: Bool
|
||||
var completedAt: Date?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, description, icon, requirement, isCompleted, completedAt
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
title: String,
|
||||
description: String,
|
||||
icon: String,
|
||||
requirement: MilestoneRequirement,
|
||||
isCompleted: Bool,
|
||||
completedAt: Date?
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.icon = icon
|
||||
self.requirement = requirement
|
||||
self.isCompleted = isCompleted
|
||||
self.completedAt = completedAt
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Milestone Requirement
|
||||
|
||||
struct MilestoneRequirement: Codable {
|
||||
let type: RequirementType
|
||||
let targetValue: Double
|
||||
}
|
||||
|
||||
enum RequirementType: String, CaseIterable, Codable {
|
||||
case totalDistanceKm
|
||||
case totalWorkouts
|
||||
case consecutiveDays
|
||||
case weeklyConsistency
|
||||
case firstWorkout
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .totalDistanceKm: return "Total Distance"
|
||||
case .totalWorkouts: return "Total Workouts"
|
||||
case .consecutiveDays: return "Streak"
|
||||
case .weeklyConsistency: return "Weekly Consistency"
|
||||
case .firstWorkout: return "First Workout"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tip
|
||||
|
||||
struct BeginnerTip: Identifiable, Codable {
|
||||
let id: String
|
||||
let context: TipContext
|
||||
let title: String
|
||||
let message: String
|
||||
var isShown: Bool
|
||||
}
|
||||
|
||||
enum TipContext: String, CaseIterable, Codable {
|
||||
case beforeWorkout
|
||||
case afterWorkout
|
||||
case dailyReminder
|
||||
case progressUpdate
|
||||
case restDay
|
||||
}
|
||||
|
||||
// MARK: - Metric Type
|
||||
|
||||
enum MetricType: String, CaseIterable, Codable {
|
||||
case distance
|
||||
case duration
|
||||
case pace
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .distance: return "Distance"
|
||||
case .duration: return "Duration"
|
||||
case .pace: return "Pace"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Response Types
|
||||
|
||||
struct BeginnerConfigResponse: Decodable {
|
||||
let config: BeginnerConfig
|
||||
}
|
||||
|
||||
struct UpdateBeginnerConfigRequest: Encodable {
|
||||
var isEnabled: Bool?
|
||||
var completedOnboardingSteps: [OnboardingStep]?
|
||||
var preferredMetric: MetricType?
|
||||
}
|
||||
|
||||
struct UpdateBeginnerConfigResponse: Decodable {
|
||||
let success: Bool
|
||||
let config: BeginnerConfig
|
||||
}
|
||||
|
||||
struct MilestoneProgressResponse: Decodable {
|
||||
let milestones: [Milestone]
|
||||
let currentLevel: BeginnerLevel
|
||||
}
|
||||
243
Lendair/Models/CommunityEvent.swift
Normal file
243
Lendair/Models/CommunityEvent.swift
Normal file
@@ -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
|
||||
}
|
||||
217
Lendair/Models/FamilyPlan.swift
Normal file
217
Lendair/Models/FamilyPlan.swift
Normal file
@@ -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
|
||||
}
|
||||
183
Lendair/Models/Race.swift
Normal file
183
Lendair/Models/Race.swift
Normal file
@@ -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?
|
||||
}
|
||||
312
Lendair/Models/TrainingPlan.swift
Normal file
312
Lendair/Models/TrainingPlan.swift
Normal file
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
118
Lendair/Services/BeginnerModeService.swift
Normal file
118
Lendair/Services/BeginnerModeService.swift
Normal file
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
153
Lendair/Services/CommunityEventService.swift
Normal file
153
Lendair/Services/CommunityEventService.swift
Normal file
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
125
Lendair/Services/FamilyPlanService.swift
Normal file
125
Lendair/Services/FamilyPlanService.swift
Normal file
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
135
Lendair/Services/RaceService.swift
Normal file
135
Lendair/Services/RaceService.swift
Normal file
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
152
Lendair/Services/TrainingPlanService.swift
Normal file
152
Lendair/Services/TrainingPlanService.swift
Normal file
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
94
Lendair/ViewModels/BeginnerModeViewModel.swift
Normal file
94
Lendair/ViewModels/BeginnerModeViewModel.swift
Normal file
@@ -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 }
|
||||
}
|
||||
119
Lendair/ViewModels/CommunityEventViewModel.swift
Normal file
119
Lendair/ViewModels/CommunityEventViewModel.swift
Normal file
@@ -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 }
|
||||
}
|
||||
78
Lendair/ViewModels/FamilyPlanViewModel.swift
Normal file
78
Lendair/ViewModels/FamilyPlanViewModel.swift
Normal file
@@ -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 }
|
||||
}
|
||||
106
Lendair/ViewModels/RaceDiscoveryViewModel.swift
Normal file
106
Lendair/ViewModels/RaceDiscoveryViewModel.swift
Normal file
@@ -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 }
|
||||
}
|
||||
108
Lendair/ViewModels/TrainingPlanViewModel.swift
Normal file
108
Lendair/ViewModels/TrainingPlanViewModel.swift
Normal file
@@ -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 }
|
||||
}
|
||||
173
Lendair/Views/BeginnerModeView.swift
Normal file
173
Lendair/Views/BeginnerModeView.swift
Normal file
@@ -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()
|
||||
}
|
||||
209
Lendair/Views/CommunityEventDetailView.swift
Normal file
209
Lendair/Views/CommunityEventDetailView.swift
Normal file
@@ -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()
|
||||
)
|
||||
}
|
||||
236
Lendair/Views/CommunityEventsView.swift
Normal file
236
Lendair/Views/CommunityEventsView.swift
Normal file
@@ -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()
|
||||
}
|
||||
125
Lendair/Views/FamilyMemberView.swift
Normal file
125
Lendair/Views/FamilyMemberView.swift
Normal file
@@ -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
|
||||
)
|
||||
}
|
||||
244
Lendair/Views/FamilyPlanView.swift
Normal file
244
Lendair/Views/FamilyPlanView.swift
Normal file
@@ -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()
|
||||
}
|
||||
182
Lendair/Views/RaceDetailView.swift
Normal file
182
Lendair/Views/RaceDetailView.swift
Normal file
@@ -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
|
||||
)
|
||||
}
|
||||
165
Lendair/Views/RaceDiscoveryView.swift
Normal file
165
Lendair/Views/RaceDiscoveryView.swift
Normal file
@@ -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()
|
||||
}
|
||||
213
Lendair/Views/TrainingPlanDetailView.swift
Normal file
213
Lendair/Views/TrainingPlanDetailView.swift
Normal file
@@ -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()
|
||||
)
|
||||
}
|
||||
219
Lendair/Views/TrainingPlanView.swift
Normal file
219
Lendair/Views/TrainingPlanView.swift
Normal file
@@ -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()
|
||||
}
|
||||
211
Lendair/Views/WorkoutSessionView.swift
Normal file
211
Lendair/Views/WorkoutSessionView.swift
Normal file
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user