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