Files
FrenoCorp/Lendair/Views/TrainingPlanView.swift
Michael Freno 57a460761a 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>
2026-05-03 15:21:01 -04:00

220 lines
7.5 KiB
Swift

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()
}