Skip to content
Draft
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
3 changes: 3 additions & 0 deletions Dropped/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ struct ContentView: View {
}) {
Label("AI Workout Generator", systemImage: "bolt.circle")
}
NavigationLink(destination: SettingsView()) {
Label("Settings", systemImage: "gear")
}
}
.navigationTitle("Dropped")
}
Expand Down
27 changes: 27 additions & 0 deletions Dropped/DroppedApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,34 @@

import SwiftUI

/// Observable object that manages the app's theme preference
///
/// This class monitors changes to the user's theme preference and updates
/// the app's color scheme accordingly. It observes UserDataManager for theme changes.
class ThemeManager: ObservableObject {
@Published var selectedTheme: AppTheme = .system

init() {
// Load the user's theme preference
let userData = UserDataManager.shared.loadUserData()
selectedTheme = AppTheme(rawValue: userData.theme) ?? .system

// Listen for theme changes
NotificationCenter.default.addObserver(
forName: NSNotification.Name("ThemeDidChange"),
object: nil,
queue: .main
) { [weak self] _ in
let userData = UserDataManager.shared.loadUserData()
self?.selectedTheme = AppTheme(rawValue: userData.theme) ?? .system
}
}
}

@main
struct DroppedApp: App {
@StateObject private var themeManager = ThemeManager()

init() {
// Setup for UI testing
if CommandLine.arguments.contains("-resetUserDefaults") {
Expand All @@ -23,6 +49,7 @@ struct DroppedApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(themeManager.selectedTheme.colorScheme)
}
}
}
30 changes: 29 additions & 1 deletion Dropped/Models/UserData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,20 +128,43 @@ enum TrainingGoal: String, CaseIterable, Identifiable, Codable {
var id: String { self.rawValue }
}

/// Represents the user's preferred color scheme for the app
enum AppTheme: String, CaseIterable, Identifiable, Codable {
case system = "System"
case light = "Light"
case dark = "Dark"

var id: String { self.rawValue }

/// Returns the corresponding SwiftUI ColorScheme, or nil for system preference
var colorScheme: ColorScheme? {
switch self {
case .system:
return nil
case .light:
return .light
case .dark:
return .dark
}
}
}

/// User profile data including physical and training parameters
struct UserData: Codable, Equatable {
var weight: Double // Always stored in kg for consistency
var weightUnit: String // Store the preferred display unit
var ftp: Int
var trainingHoursPerWeek: Int
var trainingGoal: String
var theme: String // Store the preferred theme

static let defaultData = UserData(
weight: 70.0,
weightUnit: WeightUnit.pounds.rawValue,
ftp: 200,
trainingHoursPerWeek: 5,
trainingGoal: TrainingGoal.haveFun.rawValue
trainingGoal: TrainingGoal.haveFun.rawValue,
theme: AppTheme.system.rawValue
)

/// Helper function to get weight in the user's preferred unit
Expand All @@ -151,6 +174,11 @@ struct UserData: Codable, Equatable {
}
return weight
}

/// Helper function to get the user's preferred theme
func appTheme() -> AppTheme {
return AppTheme(rawValue: theme) ?? .system
}
}

// MARK: - Integrated Models
Expand Down
3 changes: 2 additions & 1 deletion Dropped/ViewModels/OnboardingViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ class OnboardingViewModel: ObservableObject {
weightUnit: selectedWeightUnit.rawValue,
ftp: Int(ftp)!,
trainingHoursPerWeek: Int(trainingHoursPerWeek)!,
trainingGoal: selectedGoal.rawValue
trainingGoal: selectedGoal.rawValue,
theme: AppTheme.system.rawValue
)
UserDataManager.shared.saveUserData(userData)
return true
Expand Down
51 changes: 47 additions & 4 deletions Dropped/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class SettingsViewModel: ObservableObject {
@Published var userData: UserData
@Published var selectedWeightUnit: WeightUnit
@Published var isMetric: Bool
@Published var selectedTheme: AppTheme

init() {
let loadedData = UserDataManager.shared.loadUserData()
Expand All @@ -23,6 +24,8 @@ class SettingsViewModel: ObservableObject {
self.selectedWeightUnit = .pounds
self.isMetric = false
}

self.selectedTheme = AppTheme(rawValue: loadedData.theme) ?? .system
}

func toggleUnitSystem() {
Expand Down Expand Up @@ -57,6 +60,15 @@ class SettingsViewModel: ObservableObject {
userData.weightUnit = selectedWeightUnit.rawValue
UserDataManager.shared.saveUserData(userData)
}

func updateTheme(_ theme: AppTheme) {
selectedTheme = theme
userData.theme = theme.rawValue
UserDataManager.shared.saveUserData(userData)

// Notify observers that the theme has changed
NotificationCenter.default.post(name: NSNotification.Name("ThemeDidChange"), object: nil)
}
}

struct SettingsView: View {
Expand All @@ -66,6 +78,29 @@ struct SettingsView: View {
var body: some View {
NavigationView {
List {
// Appearance Section
Section(header: Text("Appearance")) {
VStack(alignment: .leading, spacing: 10) {
Text("Theme")
.font(.headline)

Picker("Theme", selection: $viewModel.selectedTheme) {
ForEach(AppTheme.allCases) { theme in
HStack {
Image(systemName: themeIcon(for: theme))
Text(theme.rawValue)
}
.tag(theme)
}
}
.pickerStyle(SegmentedPickerStyle())
.onChange(of: viewModel.selectedTheme) { _, newTheme in
viewModel.updateTheme(newTheme)
}
}
.padding(.vertical, 8)
}

// Measurement Units Section
Section(header: Text("Measurement Units")) {
// Weight Units
Expand Down Expand Up @@ -162,6 +197,18 @@ struct SettingsView: View {
}
}
}

/// Returns an appropriate icon for the given theme
private func themeIcon(for theme: AppTheme) -> String {
switch theme {
case .system:
return "sparkles"
case .light:
return "sun.max.fill"
case .dark:
return "moon.fill"
}
}
}

struct UnitSelectionButton: View {
Expand All @@ -188,7 +235,3 @@ struct UnitSelectionButton: View {
.foregroundColor(isSelected ? .white : .primary)
}
}

#Preview {
SettingsView()
}
78 changes: 75 additions & 3 deletions DroppedTests/UserDataTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ final class UserDataTests: XCTestCase {
weightUnit: WeightUnit.kilograms.rawValue,
ftp: 250,
trainingHoursPerWeek: 8,
trainingGoal: TrainingGoal.getFaster.rawValue
trainingGoal: TrainingGoal.getFaster.rawValue,
theme: AppTheme.dark.rawValue
)

// Save test data
Expand All @@ -52,6 +53,7 @@ final class UserDataTests: XCTestCase {
XCTAssertEqual(loadedData.ftp, testData.ftp, "Loaded FTP should match saved FTP")
XCTAssertEqual(loadedData.trainingHoursPerWeek, testData.trainingHoursPerWeek, "Loaded training hours should match saved hours")
XCTAssertEqual(loadedData.trainingGoal, testData.trainingGoal, "Loaded training goal should match saved goal")
XCTAssertEqual(loadedData.theme, testData.theme, "Loaded theme should match saved theme")

// Clean up by resetting to default data
UserDataManager.shared.saveUserData(UserData.defaultData)
Expand All @@ -66,7 +68,8 @@ final class UserDataTests: XCTestCase {
weightUnit: WeightUnit.pounds.rawValue,
ftp: 200,
trainingHoursPerWeek: 5,
trainingGoal: TrainingGoal.haveFun.rawValue
trainingGoal: TrainingGoal.haveFun.rawValue,
theme: AppTheme.system.rawValue
)

// Weight should be converted to pounds for display
Expand All @@ -78,7 +81,8 @@ final class UserDataTests: XCTestCase {
weightUnit: WeightUnit.stones.rawValue,
ftp: 200,
trainingHoursPerWeek: 5,
trainingGoal: TrainingGoal.haveFun.rawValue
trainingGoal: TrainingGoal.haveFun.rawValue,
theme: AppTheme.system.rawValue
)

// Weight should be converted to stones for display
Expand All @@ -101,4 +105,72 @@ final class UserDataTests: XCTestCase {
// Clean up
UserDefaults.standard.removeObject(forKey: "com.dropped.userdata")
}

func testAppThemeEnum() throws {
// Test that all theme cases have valid color schemes
XCTAssertNil(AppTheme.system.colorScheme, "System theme should return nil color scheme")
XCTAssertEqual(AppTheme.light.colorScheme, .light, "Light theme should return .light color scheme")
XCTAssertEqual(AppTheme.dark.colorScheme, .dark, "Dark theme should return .dark color scheme")

// Test theme raw values
XCTAssertEqual(AppTheme.system.rawValue, "System")
XCTAssertEqual(AppTheme.light.rawValue, "Light")
XCTAssertEqual(AppTheme.dark.rawValue, "Dark")

// Test theme initialization from raw value
XCTAssertEqual(AppTheme(rawValue: "System"), .system)
XCTAssertEqual(AppTheme(rawValue: "Light"), .light)
XCTAssertEqual(AppTheme(rawValue: "Dark"), .dark)
XCTAssertNil(AppTheme(rawValue: "Invalid"))
}

func testUserDataAppTheme() throws {
// Test appTheme() helper function
let systemThemeData = UserData(
weight: 70.0,
weightUnit: WeightUnit.kilograms.rawValue,
ftp: 200,
trainingHoursPerWeek: 5,
trainingGoal: TrainingGoal.haveFun.rawValue,
theme: AppTheme.system.rawValue
)
XCTAssertEqual(systemThemeData.appTheme(), .system, "appTheme() should return system theme")

let lightThemeData = UserData(
weight: 70.0,
weightUnit: WeightUnit.kilograms.rawValue,
ftp: 200,
trainingHoursPerWeek: 5,
trainingGoal: TrainingGoal.haveFun.rawValue,
theme: AppTheme.light.rawValue
)
XCTAssertEqual(lightThemeData.appTheme(), .light, "appTheme() should return light theme")

let darkThemeData = UserData(
weight: 70.0,
weightUnit: WeightUnit.kilograms.rawValue,
ftp: 200,
trainingHoursPerWeek: 5,
trainingGoal: TrainingGoal.haveFun.rawValue,
theme: AppTheme.dark.rawValue
)
XCTAssertEqual(darkThemeData.appTheme(), .dark, "appTheme() should return dark theme")

// Test invalid theme defaults to system
let invalidThemeData = UserData(
weight: 70.0,
weightUnit: WeightUnit.kilograms.rawValue,
ftp: 200,
trainingHoursPerWeek: 5,
trainingGoal: TrainingGoal.haveFun.rawValue,
theme: "InvalidTheme"
)
XCTAssertEqual(invalidThemeData.appTheme(), .system, "Invalid theme should default to system")
}

func testDefaultUserDataHasSystemTheme() throws {
// Verify that the default UserData uses system theme
XCTAssertEqual(UserData.defaultData.theme, AppTheme.system.rawValue, "Default user data should use system theme")
XCTAssertEqual(UserData.defaultData.appTheme(), .system, "Default user data appTheme() should return system")
}
}
Loading