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:
2026-05-03 15:21:01 -04:00
parent db23f533af
commit 57a460761a
26 changed files with 4457 additions and 62 deletions

View 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 }
}

View 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 }
}

View 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 }
}

View 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 }
}

View 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 }
}