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