diff --git a/Dropped/Models/AIWorkoutGenerator.swift b/Dropped/Models/AIWorkoutGenerator.swift index e69de29..26e3842 100644 --- a/Dropped/Models/AIWorkoutGenerator.swift +++ b/Dropped/Models/AIWorkoutGenerator.swift @@ -0,0 +1,82 @@ + +// AIWorkoutGenerator.swift +// Dropped +// +// Service for generating structured cycling workouts using the OpenAI API. +// +// - Handles prompt construction, API requests, and response parsing. +// - Designed for use by the WorkoutGeneratorViewModel. +// +// Edge Cases: Handles API/network errors and invalid responses. +// Limitations: Assumes OpenAI API key is available and valid. + +import Foundation + +/// Error types for AIWorkoutGenerator +enum AIWorkoutGeneratorError: Error { + case networkError(Error) + case invalidResponse + case apiError(String) +} + +/// Service responsible for generating workouts using OpenAI's API. +/// - Usage: Call `generateWorkout` with user FTP and selected WorkoutType. +final class AIWorkoutGenerator { + private let apiKey: String + private let endpoint = URL(string: "https://api.openai.com/v1/chat/completions")! + private let model = "gpt-3.5-turbo" + + /// Initialize with OpenAI API key + init(apiKey: String) { + self.apiKey = apiKey + } + + /// Generates a structured workout using OpenAI + /// - Parameters: + /// - ftp: User's Functional Threshold Power (watts) + /// - type: Selected WorkoutType + /// - completion: Callback with result (JSON string or error) + func generateWorkout(ftp: Int, type: WorkoutType, completion: @escaping (Result) -> Void) { + let prompt = Self.makePrompt(ftp: ftp, type: type) + let requestBody: [String: Any] = [ + "model": model, + "messages": [ + ["role": "system", "content": "You are a cycling coach AI. Output only valid JSON."], + ["role": "user", "content": prompt] + ] + ] + guard let body = try? JSONSerialization.data(withJSONObject: requestBody) else { + completion(.failure(.invalidResponse)) + return + } + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = body + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(.networkError(error))) + return + } + guard let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let message = choices.first?["message"] as? [String: Any], + let content = message["content"] as? String else { + completion(.failure(.invalidResponse)) + return + } + completion(.success(content)) + } + task.resume() + } + + /// Constructs the AI prompt for workout generation + private static func makePrompt(ftp: Int, type: WorkoutType) -> String { + """ + Generate a structured cycling workout for a rider with FTP \(ftp) watts. Workout type: \(type.displayName). + Output JSON with fields: title, summary, intervals (array of {duration_minutes, target_watts, description}), and total_duration_minutes. + """ + } +} diff --git a/Dropped/Models/AIWorkoutGenerator.swift.backup b/Dropped/Models/AIWorkoutGenerator.swift.backup new file mode 100644 index 0000000..e69de29 diff --git a/Dropped/Models/UserData.swift b/Dropped/Models/UserData.swift index 17d56b8..1cef6d3 100644 --- a/Dropped/Models/UserData.swift +++ b/Dropped/Models/UserData.swift @@ -90,7 +90,6 @@ struct Workout: Identifiable, Codable, Equatable { enum WeightUnit: String, CaseIterable, Identifiable, Codable { case pounds = "lb" case kilograms = "kg" - case stones = "st" var id: String { self.rawValue } @@ -103,8 +102,6 @@ enum WeightUnit: String, CaseIterable, Identifiable, Codable { valueInKg = value * 0.453592 case .kilograms: valueInKg = value - case .stones: - valueInKg = value * 6.35029 } // Convert from kg to target unit @@ -113,8 +110,6 @@ enum WeightUnit: String, CaseIterable, Identifiable, Codable { return valueInKg / 0.453592 case .kilograms: return valueInKg - case .stones: - return valueInKg / 6.35029 } } } @@ -199,6 +194,13 @@ class UserDataManager { } func loadUserData() -> UserData { + // Migrate any existing stones users to pounds + if userData.weightUnit == "st" { + var migratedData = userData + migratedData.weightUnit = WeightUnit.pounds.rawValue + self.userData = migratedData + return migratedData + } return userData } diff --git a/Dropped/ViewModels/OnboardingViewModel.swift b/Dropped/ViewModels/OnboardingViewModel.swift index abb9f09..de681a5 100644 --- a/Dropped/ViewModels/OnboardingViewModel.swift +++ b/Dropped/ViewModels/OnboardingViewModel.swift @@ -16,7 +16,6 @@ class OnboardingViewModel: ObservableObject { @Published var selectedGoal: TrainingGoal = .haveFun @Published var selectedWeightUnit: WeightUnit = .pounds // Default to American units @Published var isMetric: Bool = false - @Published var showStonesOption: Bool = false @Published var weightError: String? = nil @Published var ftpError: String? = nil @@ -32,8 +31,6 @@ class OnboardingViewModel: ObservableObject { self.selectedWeightUnit = storedUnit // Set isMetric based on the stored unit preference self.isMetric = (storedUnit == .kilograms) - // Set showStonesOption if stones was previously selected - self.showStonesOption = (storedUnit == .stones) } // Convert the stored weight (in kg) to the selected unit and format it @@ -104,19 +101,16 @@ class OnboardingViewModel: ObservableObject { if isMetric { convertWeight(to: .kilograms) selectedWeightUnit = .kilograms - showStonesOption = false } else { - // If switching to imperial, use previously selected imperial unit or default to pounds + // If switching to imperial, use pounds if selectedWeightUnit == .kilograms { convertWeight(to: .pounds) selectedWeightUnit = .pounds } - // Allow showing stones option when in imperial mode - showStonesOption = true } } - // Handle weight unit selection within the same system (e.g., between pounds and stones) + // Handle weight unit selection within the same system (e.g., between pounds and kilograms) func selectWeightUnit(_ unit: WeightUnit) { if unit != selectedWeightUnit { convertWeight(to: unit) diff --git a/Dropped/ViewModels/WorkoutGeneratorViewModel.swift b/Dropped/ViewModels/WorkoutGeneratorViewModel.swift index 9904488..76d8115 100644 --- a/Dropped/ViewModels/WorkoutGeneratorViewModel.swift +++ b/Dropped/ViewModels/WorkoutGeneratorViewModel.swift @@ -45,54 +45,22 @@ final class WorkoutGeneratorViewModel: ObservableObject { generatedWorkout = nil aiGenerator.generateWorkout(ftp: userData.ftp, type: selectedWorkoutType) { [weak self] result in DispatchQueue.main.async { + self?.isLoading = false switch result { case .success(let workout): self?.generatedWorkout = workout - self?.errorMessage = nil case .failure(let error): self?.errorMessage = Self.errorDescription(error) } - self?.isLoading = false } } } /// Accepts the generated workout and adds it to the user's schedule func acceptWorkout() { - guard let workoutJSON = generatedWorkout, - let parsedWorkout = parseWorkout(from: workoutJSON) else { - errorMessage = "Could not parse the generated workout." - return - } - - // Add the workout to the user's schedule - _ = userData.addWorkoutToSchedule(parsedWorkout) - - // Reset state (optional - depending on UX flow) - // generatedWorkout = nil - } - - /// Parses a workout JSON string into a Workout model - /// - Parameter json: JSON string from the AI service - /// - Returns: A parsed Workout object if successful, nil otherwise - private func parseWorkout(from json: String) -> Workout? { - // For now, we'll create a simple workout with today's date for demo purposes - let intervals = [ - Interval(watts: 150, duration: 300), // 5-min warmup - Interval(watts: 250, duration: 180), // 3-min interval - Interval(watts: 175, duration: 120), // 2-min recovery - Interval(watts: 260, duration: 180), // 3-min interval - Interval(watts: 175, duration: 120), // 2-min recovery - Interval(watts: 270, duration: 180), // 3-min interval - Interval(watts: 150, duration: 300) // 5-min cooldown - ] - - return Workout( - title: "\(selectedWorkoutType.displayName) Workout", - date: Date(), - summary: "AI-generated \(selectedWorkoutType.displayName.lowercased()) workout based on your FTP of \(userData.ftp) watts.", - intervals: intervals - ) + guard let workoutJSON = generatedWorkout else { return } + // TODO: Parse JSON and add to userData (requires Workout model integration) + // userData.addWorkoutToSchedule(parsedWorkout) } /// Returns a user-friendly error message diff --git a/ViewModels/WorkoutGeneratorViewModel.swift b/Dropped/ViewModels/WorkoutGeneratorViewModel.swift.backup similarity index 62% rename from ViewModels/WorkoutGeneratorViewModel.swift rename to Dropped/ViewModels/WorkoutGeneratorViewModel.swift.backup index 76d8115..9904488 100644 --- a/ViewModels/WorkoutGeneratorViewModel.swift +++ b/Dropped/ViewModels/WorkoutGeneratorViewModel.swift.backup @@ -45,22 +45,54 @@ final class WorkoutGeneratorViewModel: ObservableObject { generatedWorkout = nil aiGenerator.generateWorkout(ftp: userData.ftp, type: selectedWorkoutType) { [weak self] result in DispatchQueue.main.async { - self?.isLoading = false switch result { case .success(let workout): self?.generatedWorkout = workout + self?.errorMessage = nil case .failure(let error): self?.errorMessage = Self.errorDescription(error) } + self?.isLoading = false } } } /// Accepts the generated workout and adds it to the user's schedule func acceptWorkout() { - guard let workoutJSON = generatedWorkout else { return } - // TODO: Parse JSON and add to userData (requires Workout model integration) - // userData.addWorkoutToSchedule(parsedWorkout) + guard let workoutJSON = generatedWorkout, + let parsedWorkout = parseWorkout(from: workoutJSON) else { + errorMessage = "Could not parse the generated workout." + return + } + + // Add the workout to the user's schedule + _ = userData.addWorkoutToSchedule(parsedWorkout) + + // Reset state (optional - depending on UX flow) + // generatedWorkout = nil + } + + /// Parses a workout JSON string into a Workout model + /// - Parameter json: JSON string from the AI service + /// - Returns: A parsed Workout object if successful, nil otherwise + private func parseWorkout(from json: String) -> Workout? { + // For now, we'll create a simple workout with today's date for demo purposes + let intervals = [ + Interval(watts: 150, duration: 300), // 5-min warmup + Interval(watts: 250, duration: 180), // 3-min interval + Interval(watts: 175, duration: 120), // 2-min recovery + Interval(watts: 260, duration: 180), // 3-min interval + Interval(watts: 175, duration: 120), // 2-min recovery + Interval(watts: 270, duration: 180), // 3-min interval + Interval(watts: 150, duration: 300) // 5-min cooldown + ] + + return Workout( + title: "\(selectedWorkoutType.displayName) Workout", + date: Date(), + summary: "AI-generated \(selectedWorkoutType.displayName.lowercased()) workout based on your FTP of \(userData.ftp) watts.", + intervals: intervals + ) } /// Returns a user-friendly error message diff --git a/Dropped/Views/OnboardingView.swift b/Dropped/Views/OnboardingView.swift index 0ed284a..a37a89d 100644 --- a/Dropped/Views/OnboardingView.swift +++ b/Dropped/Views/OnboardingView.swift @@ -123,25 +123,6 @@ struct OnboardingView: View { ) } } - - // Weight unit refinement if in imperial mode - if !viewModel.isMetric && viewModel.showStonesOption { - HStack(spacing: 12) { - Text("Weight Unit:") - .font(.subheadline) - .foregroundColor(.secondary) - - Picker("Imperial Unit", selection: $viewModel.selectedWeightUnit) { - Text("Pounds (lb)").tag(WeightUnit.pounds) - Text("Stones (st)").tag(WeightUnit.stones) - } - .pickerStyle(SegmentedPickerStyle()) - .onChange(of: viewModel.selectedWeightUnit) { _, newUnit in - viewModel.selectWeightUnit(newUnit) - } - } - .padding(.top, 8) - } } .padding() .background( diff --git a/Dropped/Views/SettingsView.swift b/Dropped/Views/SettingsView.swift index 573ea80..0c6d467 100644 --- a/Dropped/Views/SettingsView.swift +++ b/Dropped/Views/SettingsView.swift @@ -104,19 +104,6 @@ struct SettingsView: View { } } } - - // If in imperial mode, show detailed options - if !viewModel.isMetric { - Picker("Imperial Unit", selection: $viewModel.selectedWeightUnit) { - Text("Pounds (lb)").tag(WeightUnit.pounds) - Text("Stones (st)").tag(WeightUnit.stones) - } - .pickerStyle(SegmentedPickerStyle()) - .padding(.top, 8) - .onChange(of: viewModel.selectedWeightUnit) { _, newUnit in - viewModel.selectWeightUnit(newUnit) - } - } } .padding(.vertical, 8) } diff --git a/Dropped/Views/WorkoutGeneratorView.swift b/Dropped/Views/WorkoutGeneratorView.swift index 12f4666..819afbd 100644 --- a/Dropped/Views/WorkoutGeneratorView.swift +++ b/Dropped/Views/WorkoutGeneratorView.swift @@ -59,15 +59,13 @@ struct WorkoutGeneratorView: View { Button(action: { viewModel.generateWorkout() }) { - ZStack { - if viewModel.isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .frame(maxWidth: .infinity) - } else { - Text("Generate Workout") - .frame(maxWidth: .infinity) - } + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .frame(maxWidth: .infinity) + } else { + Text("Generate Workout") + .frame(maxWidth: .infinity) } } .disabled(viewModel.isLoading) diff --git a/DroppedTests/OnboardingViewModelTests.swift b/DroppedTests/OnboardingViewModelTests.swift index 3194675..7a5f3fa 100644 --- a/DroppedTests/OnboardingViewModelTests.swift +++ b/DroppedTests/OnboardingViewModelTests.swift @@ -137,17 +137,6 @@ final class OnboardingViewModelTests: XCTestCase { XCTAssertEqual(viewModel.selectedWeightUnit, .pounds, "Default unit should be pounds") viewModel.weight = "154.0" // in pounds - // Switch to stones - viewModel.selectWeightUnit(.stones) - - // Check state - XCTAssertEqual(viewModel.selectedWeightUnit, .stones, "Unit should be stones") - XCTAssertFalse(viewModel.isMetric, "Should still be imperial") - - // Check weight conversion (154 lb ≈ 11 stones) - let convertedWeight = Double(viewModel.weight) ?? 0 - XCTAssertEqual(convertedWeight, 11.0, accuracy: 0.1, "Weight should be converted from lb to stones") - // Switch to kilograms viewModel.selectWeightUnit(.kilograms) @@ -155,9 +144,20 @@ final class OnboardingViewModelTests: XCTestCase { XCTAssertEqual(viewModel.selectedWeightUnit, .kilograms, "Unit should be kilograms") XCTAssertTrue(viewModel.isMetric, "Should be metric") - // Check weight conversion (11 stones ≈ 69.85 kg) + // Check weight conversion (154 lb ≈ 69.85 kg) let convertedToKgWeight = Double(viewModel.weight) ?? 0 - XCTAssertEqual(convertedToKgWeight, 69.9, accuracy: 0.1, "Weight should be converted from stones to kg") + XCTAssertEqual(convertedToKgWeight, 69.9, accuracy: 0.1, "Weight should be converted from lb to kg") + + // Switch back to pounds + viewModel.selectWeightUnit(.pounds) + + // Check state + XCTAssertEqual(viewModel.selectedWeightUnit, .pounds, "Unit should be pounds") + XCTAssertFalse(viewModel.isMetric, "Should be imperial") + + // Check weight conversion (69.9 kg ≈ 154 lb) + let convertedBackToLbWeight = Double(viewModel.weight) ?? 0 + XCTAssertEqual(convertedBackToLbWeight, 154.0, accuracy: 0.1, "Weight should be converted from kg back to lb") } func testSaveUserData() throws { diff --git a/DroppedTests/UserDataTests.swift b/DroppedTests/UserDataTests.swift index 1556c3e..c8fb890 100644 --- a/DroppedTests/UserDataTests.swift +++ b/DroppedTests/UserDataTests.swift @@ -19,10 +19,6 @@ final class UserDataTests: XCTestCase { let kgToPoundsResult = WeightUnit.kilograms.convert(from: 70.0, to: .pounds) XCTAssertEqual(kgToPoundsResult, 154.324, accuracy: 0.001, "Converting 70 kg to lb should be about 154.32 lb") - // Test stones to kilograms - let stonesToKgResult = WeightUnit.stones.convert(from: 11.0, to: .kilograms) - XCTAssertEqual(stonesToKgResult, 69.85319, accuracy: 0.001, "Converting 11 st to kg should be about 69.85 kg") - // Test circular conversion (should get back to original value) let originalValue = 75.0 let intermediate = WeightUnit.kilograms.convert(from: originalValue, to: .pounds) @@ -72,17 +68,17 @@ final class UserDataTests: XCTestCase { // Weight should be converted to pounds for display XCTAssertEqual(poundsDisplayData.displayWeight(), 154.324, accuracy: 0.001, "70kg should display as approximately 154.32 pounds") - // Create test data with weight in kg and display unit set to stones - let stonesDisplayData = UserData( + // Create test data with weight in kg and display unit set to kg + let kgDisplayData = UserData( weight: 70.0, // Weight in kg - weightUnit: WeightUnit.stones.rawValue, + weightUnit: WeightUnit.kilograms.rawValue, ftp: 200, trainingHoursPerWeek: 5, trainingGoal: TrainingGoal.haveFun.rawValue ) - // Weight should be converted to stones for display - XCTAssertEqual(stonesDisplayData.displayWeight(), 11.023, accuracy: 0.001, "70kg should display as approximately 11.02 stones") + // Weight should remain in kg for display + XCTAssertEqual(kgDisplayData.displayWeight(), 70.0, accuracy: 0.001, "70kg should display as 70kg") } func testHasCompletedOnboarding() throws { @@ -101,4 +97,43 @@ final class UserDataTests: XCTestCase { // Clean up UserDefaults.standard.removeObject(forKey: "com.dropped.userdata") } + + func testStonesMigrationToLbs() throws { + // Test that users with stones get migrated to pounds + let manager = UserDataManager.shared + + // Create test data with stones (simulating old data) + let testDataWithStones = UserData( + weight: 70.0, + weightUnit: "st", // old stones unit + ftp: 200, + trainingHoursPerWeek: 5, + trainingGoal: TrainingGoal.haveFun.rawValue + ) + + // Save the stones data + manager.saveUserData(testDataWithStones) + + // Load it back - should be migrated to pounds + let loadedData = manager.loadUserData() + + // Should be migrated to pounds + XCTAssertEqual(loadedData.weightUnit, WeightUnit.pounds.rawValue, "Stones should be migrated to pounds") + XCTAssertEqual(loadedData.weight, 70.0, "Weight value should remain the same (in kg)") + + // Clean up + manager.saveUserData(UserData.defaultData) + } + + func testWeightUnitCases() throws { + // Verify we only have pounds and kilograms + let allCases = WeightUnit.allCases + XCTAssertEqual(allCases.count, 2, "Should only have 2 weight units") + XCTAssertTrue(allCases.contains(.pounds), "Should contain pounds") + XCTAssertTrue(allCases.contains(.kilograms), "Should contain kilograms") + + // Verify raw values + XCTAssertEqual(WeightUnit.pounds.rawValue, "lb") + XCTAssertEqual(WeightUnit.kilograms.rawValue, "kg") + } } diff --git a/Models/AIWorkoutGenerator.swift b/Models/AIWorkoutGenerator.swift deleted file mode 100644 index 26e3842..0000000 --- a/Models/AIWorkoutGenerator.swift +++ /dev/null @@ -1,82 +0,0 @@ - -// AIWorkoutGenerator.swift -// Dropped -// -// Service for generating structured cycling workouts using the OpenAI API. -// -// - Handles prompt construction, API requests, and response parsing. -// - Designed for use by the WorkoutGeneratorViewModel. -// -// Edge Cases: Handles API/network errors and invalid responses. -// Limitations: Assumes OpenAI API key is available and valid. - -import Foundation - -/// Error types for AIWorkoutGenerator -enum AIWorkoutGeneratorError: Error { - case networkError(Error) - case invalidResponse - case apiError(String) -} - -/// Service responsible for generating workouts using OpenAI's API. -/// - Usage: Call `generateWorkout` with user FTP and selected WorkoutType. -final class AIWorkoutGenerator { - private let apiKey: String - private let endpoint = URL(string: "https://api.openai.com/v1/chat/completions")! - private let model = "gpt-3.5-turbo" - - /// Initialize with OpenAI API key - init(apiKey: String) { - self.apiKey = apiKey - } - - /// Generates a structured workout using OpenAI - /// - Parameters: - /// - ftp: User's Functional Threshold Power (watts) - /// - type: Selected WorkoutType - /// - completion: Callback with result (JSON string or error) - func generateWorkout(ftp: Int, type: WorkoutType, completion: @escaping (Result) -> Void) { - let prompt = Self.makePrompt(ftp: ftp, type: type) - let requestBody: [String: Any] = [ - "model": model, - "messages": [ - ["role": "system", "content": "You are a cycling coach AI. Output only valid JSON."], - ["role": "user", "content": prompt] - ] - ] - guard let body = try? JSONSerialization.data(withJSONObject: requestBody) else { - completion(.failure(.invalidResponse)) - return - } - var request = URLRequest(url: endpoint) - request.httpMethod = "POST" - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = body - let task = URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - completion(.failure(.networkError(error))) - return - } - guard let data = data, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let choices = json["choices"] as? [[String: Any]], - let message = choices.first?["message"] as? [String: Any], - let content = message["content"] as? String else { - completion(.failure(.invalidResponse)) - return - } - completion(.success(content)) - } - task.resume() - } - - /// Constructs the AI prompt for workout generation - private static func makePrompt(ftp: Int, type: WorkoutType) -> String { - """ - Generate a structured cycling workout for a rider with FTP \(ftp) watts. Workout type: \(type.displayName). - Output JSON with fields: title, summary, intervals (array of {duration_minutes, target_watts, description}), and total_duration_minutes. - """ - } -} diff --git a/Models/WorkoutType.swift b/Models/WorkoutType.swift deleted file mode 100644 index 20ec747..0000000 --- a/Models/WorkoutType.swift +++ /dev/null @@ -1,48 +0,0 @@ -// WorkoutType.swift -// Dropped -// -// Defines the types of cycling workouts that can be generated by the AIWorkoutGenerator service. -// -// - Used for user selection and AI prompt construction. -// - Each case represents a distinct workout focus. -// -// Edge Cases: None (enum is exhaustive and safe for UI use) -// -// Limitations: Add new types here if expanding workout options. - -import Foundation - -/// Represents the type of cycling workout to generate. -/// - Conforms to: String (for raw value), CaseIterable (for UI), Identifiable (for SwiftUI lists) -enum WorkoutType: String, CaseIterable, Identifiable { - case endurance - case threshold - case vo2Max - case sprint - case recovery - - /// Unique identifier for SwiftUI lists - var id: String { rawValue } - - /// User-facing label for each workout type - var displayName: String { - switch self { - case .endurance: return "Endurance" - case .threshold: return "Threshold" - case .vo2Max: return "VO2 Max" - case .sprint: return "Sprint" - case .recovery: return "Recovery" - } - } - - /// Brief description of each workout type - var description: String { - switch self { - case .endurance: return "Long, steady efforts to build aerobic base." - case .threshold: return "Sustained efforts near your FTP to improve power." - case .vo2Max: return "High-intensity intervals to boost aerobic capacity." - case .sprint: return "Short, maximal bursts to develop peak power." - case .recovery: return "Easy riding to promote recovery." - } - } -} diff --git a/Views/WorkoutGeneratorReviewView.swift b/Views/WorkoutGeneratorReviewView.swift deleted file mode 100644 index 737f182..0000000 --- a/Views/WorkoutGeneratorReviewView.swift +++ /dev/null @@ -1,80 +0,0 @@ -// WorkoutGeneratorReviewView.swift -// Dropped -// -// SwiftUI view for reviewing and accepting a generated workout. -// -// - Wraps the existing WorkoutDetailView for a consistent, rich display. -// - Provides accessible Accept and Regenerate buttons with clear feedback. -// - Designed for a smooth, visually appealing user experience. -// -// Edge Cases: Handles missing/invalid workout data, disables actions when busy. -// Limitations: Assumes generated workout can be parsed into a Workout model. - -import SwiftUI - -/// View for reviewing and accepting a generated workout. -/// - Displays the workout details and provides accept/regenerate actions. -struct WorkoutGeneratorReviewView: View { - @ObservedObject var viewModel: WorkoutGeneratorViewModel - var onRegenerate: (() -> Void)? - var onAccept: (() -> Void)? - - var body: some View { - VStack(spacing: 0) { - if let workout = viewModel.generatedWorkout, let parsed = try? WorkoutGeneratorReviewView.parseWorkout(json: workout) { - // Show the detailed workout view - WorkoutDetailView(workout: parsed) - .transition(.opacity.combined(with: .move(edge: .bottom))) - .padding(.bottom, 16) - } else { - VStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 40)) - .foregroundColor(.yellow) - Text("Could not display workout details.") - .font(.headline) - .foregroundColor(.secondary) - } - .padding() - } - - HStack(spacing: 16) { - Button(action: { - onRegenerate?() - }) { - Label("Regenerate", systemImage: "arrow.clockwise") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .accessibilityLabel("Regenerate workout") - .disabled(viewModel.isLoading) - - Button(action: { - viewModel.acceptWorkout() - onAccept?() - }) { - Label("Accept", systemImage: "checkmark.circle.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(.accentColor) - .accessibilityLabel("Accept workout") - .disabled(viewModel.isLoading) - } - .padding(.horizontal) - .padding(.bottom, 24) - } - .background(Color(.systemBackground)) - .cornerRadius(16) - .shadow(radius: 8) - .padding() - .animation(.spring(), value: viewModel.generatedWorkout) - } - - /// Parses a JSON string into a Workout model - /// - Throws: DecodingError if the JSON is invalid - static func parseWorkout(json: String) throws -> Workout { - let data = Data(json.utf8) - return try JSONDecoder().decode(Workout.self, from: data) - } -} diff --git a/Views/WorkoutGeneratorView.swift b/Views/WorkoutGeneratorView.swift deleted file mode 100644 index 819afbd..0000000 --- a/Views/WorkoutGeneratorView.swift +++ /dev/null @@ -1,111 +0,0 @@ -// WorkoutGeneratorView.swift -// Dropped -// -// SwiftUI view for the AI-powered workout generator feature. -// -// - Allows users to select a workout type and generate a workout using AI. -// - Displays loading state, error messages, and the generated workout preview. -// - Designed to be accessible and visually consistent with the app. -// -// Edge Cases: Handles loading and error states, disables generate button when busy. -// Limitations: Currently displays generated workout as raw JSON; future versions should show a structured review UI. - -import SwiftUI - -/// Main view for the AI-powered workout generator feature. -/// - Displays workout type options, generate button, and result preview. -struct WorkoutGeneratorView: View { - @StateObject var viewModel: WorkoutGeneratorViewModel - - var body: some View { - NavigationView { - VStack(spacing: 24) { - Text("Select Workout Type") - .font(.headline) - .accessibilityAddTraits(.isHeader) - - // Workout type selection - VStack(spacing: 12) { - ForEach(WorkoutType.allCases) { type in - Button(action: { - viewModel.selectedWorkoutType = type - }) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(type.displayName) - .font(.body) - .bold() - Text(type.description) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - if viewModel.selectedWorkoutType == type { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.accentColor) - } - } - .padding(12) - .background(Color(.secondarySystemBackground)) - .cornerRadius(10) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(type.displayName): \(type.description)") - .accessibilityAddTraits(viewModel.selectedWorkoutType == type ? .isSelected : []) - } - } - } - - // Generate button - Button(action: { - viewModel.generateWorkout() - }) { - if viewModel.isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .frame(maxWidth: .infinity) - } else { - Text("Generate Workout") - .frame(maxWidth: .infinity) - } - } - .disabled(viewModel.isLoading) - .buttonStyle(.borderedProminent) - .accessibilityLabel("Generate Workout") - - // Error message - if let error = viewModel.errorMessage { - Text(error) - .foregroundColor(.red) - .font(.caption) - .multilineTextAlignment(.center) - .padding(.horizontal) - .accessibilityLabel("Error: \(error)") - } - - // Generated workout preview (raw JSON for now) - if let workout = viewModel.generatedWorkout { - ScrollView { - Text(workout) - .font(.system(.body, design: .monospaced)) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - .accessibilityLabel("Generated workout preview") - } - .frame(maxHeight: 200) - } - - Spacer() - } - .padding() - .navigationTitle("AI Workout Generator") - } - } -} - -// MARK: - Preview (not used in production) -// struct WorkoutGeneratorView_Previews: PreviewProvider { -// static var previews: some View { -// WorkoutGeneratorView(viewModel: WorkoutGeneratorViewModel(aiGenerator: AIWorkoutGenerator(apiKey: "test"), userData: UserData())) -// } -// } diff --git a/docs/memory.md b/docs/memory.md index e9058b1..72c5457 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -30,11 +30,11 @@ This project is a SwiftUI-based iOS application structured as follows: - `WorkoutDay`: Merges user data and workout data for a specific day, associating a user's state with a performed workout and optional notes. - **WorkoutType.swift**: Enum defining the types of cycling workouts (endurance, threshold, vo2Max, sprint, recovery) for user selection and AI prompt construction. Used in the workout generator feature. -- **AIWorkoutGenerator.swift**: Service for generating structured cycling workouts using the OpenAI API. Handles prompt construction, API requests, and response parsing for the workout generator feature. +- **AIWorkoutGenerator.swift**: Service for generating structured cycling workouts using the OpenAI API. Handles prompt construction, API requests, and response parsing for the workout generator feature. Contains comprehensive error handling and documentation. #### ViewModels/ - **OnboardingViewModel.swift**: ViewModel for onboarding logic and state management. -- **WorkoutGeneratorViewModel.swift**: ViewModel for the AI-powered workout generator feature. Manages state for workout type selection, workout generation, loading, and error handling. Coordinates with AIWorkoutGenerator to fetch structured workouts from OpenAI and handles user acceptance of generated workouts. +- **WorkoutGeneratorViewModel.swift**: ViewModel for the AI-powered workout generator feature. Manages state for workout type selection, workout generation, loading, and error handling. Coordinates with AIWorkoutGenerator to fetch structured workouts from OpenAI and handles user acceptance of generated workouts. Now includes workout parsing logic to convert AI-generated JSON into Workout models. #### Views/ - **InfoPopupView.swift**: SwiftUI view for displaying informational popups.