Initial commit: SportsTime trip planning app
- Three-scenario planning engine (A: date range, B: selected games, C: directional routes) - GeographicRouteExplorer with anchor game support for route exploration - Shared ItineraryBuilder for travel segment calculation - TravelEstimator for driving time/distance estimation - SwiftUI views for trip creation and detail display - CloudKit integration for schedule data - Python scraping scripts for sports schedules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
154
SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift
Normal file
154
SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// SettingsViewModel.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SettingsViewModel {
|
||||
|
||||
// MARK: - User Preferences (persisted via UserDefaults)
|
||||
|
||||
var selectedSports: Set<Sport> {
|
||||
didSet { savePreferences() }
|
||||
}
|
||||
|
||||
var maxDrivingHoursPerDay: Int {
|
||||
didSet { savePreferences() }
|
||||
}
|
||||
|
||||
var preferredGameTime: PreferredGameTime {
|
||||
didSet { savePreferences() }
|
||||
}
|
||||
|
||||
var includePlayoffGames: Bool {
|
||||
didSet { savePreferences() }
|
||||
}
|
||||
|
||||
var notificationsEnabled: Bool {
|
||||
didSet { savePreferences() }
|
||||
}
|
||||
|
||||
// MARK: - Sync State
|
||||
|
||||
private(set) var isSyncing = false
|
||||
private(set) var lastSyncDate: Date?
|
||||
private(set) var syncError: String?
|
||||
|
||||
// MARK: - App Info
|
||||
|
||||
let appVersion: String
|
||||
let buildNumber: String
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
// Load from UserDefaults using local variables first
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
// Selected sports
|
||||
if let sportStrings = defaults.stringArray(forKey: "selectedSports") {
|
||||
self.selectedSports = Set(sportStrings.compactMap { Sport(rawValue: $0) })
|
||||
} else {
|
||||
self.selectedSports = Set(Sport.supported)
|
||||
}
|
||||
|
||||
// Travel preferences - use local variable to avoid self access before init complete
|
||||
let savedDrivingHours = defaults.integer(forKey: "maxDrivingHoursPerDay")
|
||||
self.maxDrivingHoursPerDay = savedDrivingHours == 0 ? 8 : savedDrivingHours
|
||||
|
||||
if let timeRaw = defaults.string(forKey: "preferredGameTime"),
|
||||
let time = PreferredGameTime(rawValue: timeRaw) {
|
||||
self.preferredGameTime = time
|
||||
} else {
|
||||
self.preferredGameTime = .evening
|
||||
}
|
||||
|
||||
self.includePlayoffGames = defaults.object(forKey: "includePlayoffGames") as? Bool ?? true
|
||||
self.notificationsEnabled = defaults.object(forKey: "notificationsEnabled") as? Bool ?? true
|
||||
|
||||
// Last sync
|
||||
self.lastSyncDate = defaults.object(forKey: "lastSyncDate") as? Date
|
||||
|
||||
// App info
|
||||
self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||
self.buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func syncSchedules() async {
|
||||
isSyncing = true
|
||||
syncError = nil
|
||||
|
||||
do {
|
||||
// Trigger data reload from provider
|
||||
await AppDataProvider.shared.loadInitialData()
|
||||
|
||||
lastSyncDate = Date()
|
||||
UserDefaults.standard.set(lastSyncDate, forKey: "lastSyncDate")
|
||||
} catch {
|
||||
syncError = error.localizedDescription
|
||||
}
|
||||
|
||||
isSyncing = false
|
||||
}
|
||||
|
||||
func toggleSport(_ sport: Sport) {
|
||||
if selectedSports.contains(sport) {
|
||||
// Don't allow removing all sports
|
||||
guard selectedSports.count > 1 else { return }
|
||||
selectedSports.remove(sport)
|
||||
} else {
|
||||
selectedSports.insert(sport)
|
||||
}
|
||||
}
|
||||
|
||||
func resetToDefaults() {
|
||||
selectedSports = Set(Sport.supported)
|
||||
maxDrivingHoursPerDay = 8
|
||||
preferredGameTime = .evening
|
||||
includePlayoffGames = true
|
||||
notificationsEnabled = true
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func savePreferences() {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(selectedSports.map(\.rawValue), forKey: "selectedSports")
|
||||
defaults.set(maxDrivingHoursPerDay, forKey: "maxDrivingHoursPerDay")
|
||||
defaults.set(preferredGameTime.rawValue, forKey: "preferredGameTime")
|
||||
defaults.set(includePlayoffGames, forKey: "includePlayoffGames")
|
||||
defaults.set(notificationsEnabled, forKey: "notificationsEnabled")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
enum PreferredGameTime: String, CaseIterable, Identifiable {
|
||||
case any = "any"
|
||||
case afternoon = "afternoon"
|
||||
case evening = "evening"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .any: return "Any Time"
|
||||
case .afternoon: return "Afternoon"
|
||||
case .evening: return "Evening"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .any: return "No preference"
|
||||
case .afternoon: return "1 PM - 5 PM"
|
||||
case .evening: return "6 PM - 10 PM"
|
||||
}
|
||||
}
|
||||
}
|
||||
228
SportsTime/Features/Settings/Views/SettingsView.swift
Normal file
228
SportsTime/Features/Settings/Views/SettingsView.swift
Normal file
@@ -0,0 +1,228 @@
|
||||
//
|
||||
// SettingsView.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@State private var viewModel = SettingsViewModel()
|
||||
@State private var showResetConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// Sports Preferences
|
||||
sportsSection
|
||||
|
||||
// Travel Preferences
|
||||
travelSection
|
||||
|
||||
// Game Preferences
|
||||
gamePreferencesSection
|
||||
|
||||
// Notifications
|
||||
notificationsSection
|
||||
|
||||
// Data Sync
|
||||
dataSection
|
||||
|
||||
// About
|
||||
aboutSection
|
||||
|
||||
// Reset
|
||||
resetSection
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.alert("Reset Settings", isPresented: $showResetConfirmation) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Reset", role: .destructive) {
|
||||
viewModel.resetToDefaults()
|
||||
}
|
||||
} message: {
|
||||
Text("This will reset all settings to their default values.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sports Section
|
||||
|
||||
private var sportsSection: some View {
|
||||
Section {
|
||||
ForEach(Sport.supported) { sport in
|
||||
Toggle(isOn: Binding(
|
||||
get: { viewModel.selectedSports.contains(sport) },
|
||||
set: { _ in viewModel.toggleSport(sport) }
|
||||
)) {
|
||||
Label {
|
||||
Text(sport.displayName)
|
||||
} icon: {
|
||||
Image(systemName: sport.iconName)
|
||||
.foregroundStyle(sportColor(for: sport))
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Favorite Sports")
|
||||
} footer: {
|
||||
Text("Selected sports will be shown by default in schedules and trip planning.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Travel Section
|
||||
|
||||
private var travelSection: some View {
|
||||
Section {
|
||||
Stepper(value: $viewModel.maxDrivingHoursPerDay, in: 2...12) {
|
||||
HStack {
|
||||
Text("Max Driving Per Day")
|
||||
Spacer()
|
||||
Text("\(viewModel.maxDrivingHoursPerDay) hours")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Travel Preferences")
|
||||
} footer: {
|
||||
Text("Trips will be optimized to keep daily driving within this limit.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Game Preferences Section
|
||||
|
||||
private var gamePreferencesSection: some View {
|
||||
Section {
|
||||
Picker("Preferred Game Time", selection: $viewModel.preferredGameTime) {
|
||||
ForEach(PreferredGameTime.allCases) { time in
|
||||
VStack(alignment: .leading) {
|
||||
Text(time.displayName)
|
||||
}
|
||||
.tag(time)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Include Playoff Games", isOn: $viewModel.includePlayoffGames)
|
||||
} header: {
|
||||
Text("Game Preferences")
|
||||
} footer: {
|
||||
Text("These preferences affect trip optimization.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notifications Section
|
||||
|
||||
private var notificationsSection: some View {
|
||||
Section {
|
||||
Toggle("Schedule Updates", isOn: $viewModel.notificationsEnabled)
|
||||
} header: {
|
||||
Text("Notifications")
|
||||
} footer: {
|
||||
Text("Get notified when games in your trips are rescheduled.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Section
|
||||
|
||||
private var dataSection: some View {
|
||||
Section {
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.syncSchedules()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Sync Schedules", systemImage: "arrow.triangle.2.circlepath")
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewModel.isSyncing {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isSyncing)
|
||||
|
||||
if let lastSync = viewModel.lastSyncDate {
|
||||
HStack {
|
||||
Text("Last Sync")
|
||||
Spacer()
|
||||
Text(lastSync, style: .relative)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = viewModel.syncError {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(.red)
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Data")
|
||||
} footer: {
|
||||
#if targetEnvironment(simulator)
|
||||
Text("Using stub data (Simulator mode)")
|
||||
#else
|
||||
Text("Schedule data is synced from CloudKit.")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - About Section
|
||||
|
||||
private var aboutSection: some View {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Version")
|
||||
Spacer()
|
||||
Text("\(viewModel.appVersion) (\(viewModel.buildNumber))")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Link(destination: URL(string: "https://sportstime.app/privacy")!) {
|
||||
Label("Privacy Policy", systemImage: "hand.raised")
|
||||
}
|
||||
|
||||
Link(destination: URL(string: "https://sportstime.app/terms")!) {
|
||||
Label("Terms of Service", systemImage: "doc.text")
|
||||
}
|
||||
|
||||
Link(destination: URL(string: "mailto:support@sportstime.app")!) {
|
||||
Label("Contact Support", systemImage: "envelope")
|
||||
}
|
||||
} header: {
|
||||
Text("About")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reset Section
|
||||
|
||||
private var resetSection: some View {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
showResetConfirmation = true
|
||||
} label: {
|
||||
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func sportColor(for sport: Sport) -> Color {
|
||||
switch sport {
|
||||
case .mlb: return .red
|
||||
case .nba: return .orange
|
||||
case .nhl: return .blue
|
||||
case .nfl: return .green
|
||||
case .mls: return .purple
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user