Files
workouts/Workouts/Views/Settings/SettingsView.swift
T
rzen 9741b7d834 Pick rest time on a 1-second wheel
Replaces the 5-second Steppers in Settings and the routine editor's
custom-rest row with a shared SecondsWheelRow: the row shows the value
and tapping it expands an inline wheel (10-180s, 1s steps) beneath.
2026-07-16 19:44:22 -04:00

326 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// SettingsView.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import AVFoundation
import IndieAbout
import IndieBackup
struct SettingsView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@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 = ""
/// 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 {
SecondsWheelRow(title: "Rest Between Sets", range: 10...180, seconds: $restSeconds)
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: - Goals Section
Section(header: Text("Goals")) {
NavigationLink {
GoalsListView()
} label: {
HStack {
Label("Goals", systemImage: "target")
Spacer()
Text("\(GoalKind.allCases.count)")
.foregroundStyle(.secondary)
}
}
}
// 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")
}
NavigationLink {
ExerciseFigureReviewView()
} label: {
Label("Review Exercise Figures", systemImage: "figure.strengthtraining.traditional")
}
} 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<Double>,
range: ClosedRange<Double>, 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)
}
}
}
}