// // SettingsView.swift // Workouts // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import SwiftData import AVFoundation import IndieAbout import IndieBackup struct SettingsView: View { @Environment(SyncEngine.self) private var sync @Environment(AppServices.self) private var services @Query private var splits: [Split] @AppStorage("restSeconds") private var restSeconds: Int = 45 @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5 @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb @AppStorage("figureProfile") private var figureProfile: FigureProfile = .neutral @AppStorage("speakExerciseCues") private var speakExerciseCues = false @AppStorage("spokenCueStyle") private var spokenCueStyle: SpokenCueStyle = .cues @AppStorage(SpeechSettings.voiceIdentifierKey) private var speechVoiceIdentifier = "" @State private var isRestoringSeeds = false @State private var restoreSeedsMessage: String? /// Installed English voices, best quality first. Loaded on appear (not per render) since /// `speechVoices()` is a touch heavy and the list only changes when the user downloads a /// voice in system settings — re-read on each appear catches a return from doing exactly that. @State private var installedVoices: [AVSpeechSynthesisVoice] = [] @State private var showingSpeechAdjustments = false var body: some View { NavigationStack { Form { // MARK: - Workout Section Section { Stepper(value: $restSeconds, in: 10...180, step: 5) { HStack { Text("Rest Between Sets") Spacer() Text("\(restSeconds)s").foregroundColor(.secondary) } } Stepper(value: $doneCountdownSeconds, in: 3...20, step: 1) { HStack { Text("Auto-Finish Countdown") Spacer() Text("\(doneCountdownSeconds)s").foregroundColor(.secondary) } } Picker("Weight Unit", selection: $weightUnit) { ForEach(WeightUnit.allCases, id: \.self) { unit in Text(unit.displayName).tag(unit) } } Picker("Body Type", selection: $figureProfile) { ForEach(FigureProfile.allCases, id: \.self) { profile in Text(profile.displayName).tag(profile) } } } header: { Text("Workout") } footer: { Text("How long the watch waits on the finish screen before completing an exercise automatically.") } // MARK: - Voice Section voiceSection // MARK: - Library Section Section(header: Text("Library")) { NavigationLink { SplitListView() } label: { HStack { Label("Splits", systemImage: "dumbbell.fill") Spacer() Text("\(splits.count)") .foregroundStyle(.secondary) } } NavigationLink { ExerciseLibraryView() } label: { HStack { Label("Exercises", systemImage: "figure.strengthtraining.traditional") Spacer() Text("\(ExerciseMotionLibrary.exerciseNames.count)") .foregroundStyle(.secondary) } } } // MARK: - Starter Splits Section Section { Button { Task { isRestoringSeeds = true restoreSeedsMessage = nil let n = await sync.restoreSeeds() isRestoringSeeds = false restoreSeedsMessage = n == 0 ? "All starter splits are already present." : "Restored \(n) starter split\(n == 1 ? "" : "s")." } } label: { HStack { Label("Restore Starter Splits", systemImage: "arrow.counterclockwise") if isRestoringSeeds { Spacer() ProgressView() } } } .disabled(isRestoringSeeds || sync.iCloudStatus != .available) } header: { Text("Starter Splits") } footer: { if let restoreSeedsMessage { Text(restoreSeedsMessage) } else { Text("Brings back the bundled starter splits you've deleted. Splits you've edited or created are never touched.") } } // MARK: - iCloud Sync Section Section { switch sync.iCloudStatus { case .checking: Label("Connecting…", systemImage: "arrow.triangle.2.circlepath.icloud") .foregroundStyle(.secondary) case .available: Label("Connected", systemImage: "checkmark.icloud") .foregroundStyle(.secondary) case .unavailable: Label("iCloud unavailable", systemImage: "xmark.icloud") .foregroundStyle(.orange) } if let error = sync.lastSyncError { Label(error, systemImage: "exclamationmark.icloud.fill") .foregroundStyle(.orange) } NavigationLink { DiagnosticsView() } label: { Label("Diagnostics", systemImage: "stethoscope") } } header: { Text("iCloud Sync") } // MARK: - Backups Section BackupsSectionView(controller: services.backupController) // MARK: - Developer Section (debug builds only) #if DEBUG Section { NavigationLink { DuplicateCleanupView() } label: { Label("Clean Up Duplicates", systemImage: "wrench.and.screwdriver") } } header: { Text("Developer") } #endif // MARK: - About Section Section { IndieAbout(configuration: AppInfoConfiguration( documents: [ .license(extension: "md") ], changelogDocument: .changelog() )) } } .navigationTitle("Settings") .onChange(of: restSeconds) { _, _ in services.watchBridge.pushAll() } .onChange(of: doneCountdownSeconds) { _, _ in services.watchBridge.pushAll() } .onChange(of: weightUnit) { _, _ in services.watchBridge.pushAll() } .onAppear { installedVoices = SpeechSettings.availableVoices() // A previously-chosen voice that's since been uninstalled falls back to Automatic // so the picker shows a valid selection (the announcer already falls back too). if !speechVoiceIdentifier.isEmpty, !installedVoices.contains(where: { $0.identifier == speechVoiceIdentifier }) { speechVoiceIdentifier = "" } } .sheet(isPresented: $showingSpeechAdjustments) { SpeechAdjustmentsView() } } } // MARK: - Voice /// True once at least one enhanced or premium voice is installed — the nudge to go /// download a nicer voice only shows when the user is still on a basic (default) voice. private var hasUpgradedVoice: Bool { installedVoices.contains { $0.quality != .default } } /// All spoken-instruction controls. The voice and the speed/pitch/volume adjustments also /// govern the library exercise Speak button; only the toggle gates the automatic workout cue. @ViewBuilder private var voiceSection: some View { Section { if !hasUpgradedVoice { premiumVoiceCallout } Toggle("Speak Exercise Cues", isOn: $speakExerciseCues) if speakExerciseCues { Picker("Narration", selection: $spokenCueStyle) { ForEach(SpokenCueStyle.allCases) { style in Text(style.displayName).tag(style) } } } Picker("Voice", selection: $speechVoiceIdentifier) { Text("Automatic").tag("") ForEach(installedVoices, id: \.identifier) { voice in Text(SpeechSettings.displayLabel(name: voice.name, quality: voice.quality)) .tag(voice.identifier) } } Button { showingSpeechAdjustments = true } label: { HStack { Label("Speed, Pitch & Volume", systemImage: "slider.horizontal.3") Spacer() Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) .foregroundStyle(.tertiary) } } .tint(.primary) Button { let announcer = services.speechAnnouncer if announcer.isSpeaking { announcer.stop() } else { announcer.speak("This is how your exercise cues will sound.") } } label: { Label(services.speechAnnouncer.isSpeaking ? "Stop Preview" : "Preview Voice", systemImage: services.speechAnnouncer.isSpeaking ? "stop.circle" : "play.circle") } } header: { Text("Voice") } footer: { Text("Speak Exercise Cues narrates a running workout hands-free. Coming Up & Countdown announces the next exercise during a rest and counts “in 1, 2, 3, GO!” before each set; Setup & Form Read reads each exercise's cues aloud when it starts. Tap a library exercise's speaker to hear its full instructions anytime.") } } /// Shown only when no enhanced/premium voice is installed: points the user to the system /// voice download, the one lever that actually makes speech sound less robotic. private var premiumVoiceCallout: some View { Label { VStack(alignment: .leading, spacing: 2) { Text("Using a basic voice") .font(.subheadline.weight(.semibold)) Text("For a much more natural sound, download a Premium voice in Settings › Accessibility › Spoken Content › Voices, then choose it above.") .font(.footnote) .foregroundStyle(.secondary) } } icon: { Image(systemName: "sparkles") .foregroundStyle(.tint) } } } /// The speed / pitch / volume knobs, split into their own sheet with a reset. These are the /// fine-tuning controls, kept out of the main Voice list; they write the same `@AppStorage` /// keys the announcer reads, so changes preview live. private struct SpeechAdjustmentsView: View { @Environment(\.dismiss) private var dismiss @Environment(AppServices.self) private var services @AppStorage(SpeechSettings.rateKey) private var speechRate = SpeechSettings.rateDefault @AppStorage(SpeechSettings.pitchKey) private var speechPitch = SpeechSettings.pitchDefault @AppStorage(SpeechSettings.volumeKey) private var speechVolume = SpeechSettings.volumeDefault /// True when every knob already sits at its default — disables the Reset button. private var isDefault: Bool { speechRate == SpeechSettings.rateDefault && speechPitch == SpeechSettings.pitchDefault && speechVolume == SpeechSettings.volumeDefault } var body: some View { NavigationStack { Form { Section { voiceSlider("Speed", value: $speechRate, range: SpeechSettings.rateRange, low: "tortoise", high: "hare") voiceSlider("Pitch", value: $speechPitch, range: SpeechSettings.pitchRange, low: "arrow.down", high: "arrow.up") voiceSlider("Volume", value: $speechVolume, range: SpeechSettings.volumeRange, low: "speaker.fill", high: "speaker.wave.3.fill") } Section { Button { let announcer = services.speechAnnouncer if announcer.isSpeaking { announcer.stop() } else { announcer.speak("This is how your exercise cues will sound.") } } label: { Label(services.speechAnnouncer.isSpeaking ? "Stop Preview" : "Preview", systemImage: services.speechAnnouncer.isSpeaking ? "stop.circle" : "play.circle") } Button(role: .destructive, action: resetToDefaults) { Label("Reset to Defaults", systemImage: "arrow.counterclockwise") } .disabled(isDefault) } } .navigationTitle("Speed, Pitch & Volume") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } .onDisappear { services.speechAnnouncer.stop() } } .presentationDetents([.medium, .large]) } private func resetToDefaults() { withAnimation { speechRate = SpeechSettings.rateDefault speechPitch = SpeechSettings.pitchDefault speechVolume = SpeechSettings.volumeDefault } } /// A labeled slider row with low/high symbol endcaps. private func voiceSlider(_ title: String, value: Binding, range: ClosedRange, low: String, high: String) -> some View { VStack(alignment: .leading, spacing: 2) { Text(title) .font(.subheadline) Slider(value: value, in: range) { Text(title) } minimumValueLabel: { Image(systemName: low).foregroundStyle(.secondary).imageScale(.small) } maximumValueLabel: { Image(systemName: high).foregroundStyle(.secondary).imageScale(.small) } } } }