Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions Dropped/Models/AIWorkoutGenerator.swift
Original file line number Diff line number Diff line change
@@ -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<String, AIWorkoutGeneratorError>) -> 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.
"""
}
}
Empty file.
12 changes: 7 additions & 5 deletions Dropped/Models/UserData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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
Expand All @@ -113,8 +110,6 @@ enum WeightUnit: String, CaseIterable, Identifiable, Codable {
return valueInKg / 0.453592
case .kilograms:
return valueInKg
case .stones:
return valueInKg / 6.35029
}
}
}
Expand Down Expand Up @@ -199,6 +194,13 @@ class UserDataManager {
}

func loadUserData() -> UserData {
// Migrate any existing stones users to pounds
if userData.weightUnit == "st" {
Copy link

Copilot AI Jun 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Avoid using the hardcoded literal "st". Define a legacy constant or extension to represent the old stones unit, which improves readability and reduces magic strings.

Suggested change
if userData.weightUnit == "st" {
if userData.weightUnit == WeightUnit.stones.rawValue {

Copilot uses AI. Check for mistakes.
var migratedData = userData
migratedData.weightUnit = WeightUnit.pounds.rawValue
self.userData = migratedData
Copy link

Copilot AI Jun 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The migration logic updates in-memory userData but does not persist these changes to storage. Consider calling saveUserData(migratedData) within this block to ensure the migration is saved.

Suggested change
self.userData = migratedData
saveUserData(migratedData)

Copilot uses AI. Check for mistakes.
return migratedData
}
return userData
}

Expand Down
10 changes: 2 additions & 8 deletions Dropped/ViewModels/OnboardingViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
40 changes: 4 additions & 36 deletions Dropped/ViewModels/WorkoutGeneratorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 0 additions & 19 deletions Dropped/Views/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
13 changes: 0 additions & 13 deletions Dropped/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
16 changes: 7 additions & 9 deletions Dropped/Views/WorkoutGeneratorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 13 additions & 13 deletions DroppedTests/OnboardingViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,27 +137,27 @@ 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)

// Check state
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 {
Expand Down
Loading
Loading