Files
workouts/Workouts/Views/Settings/SettingsView.swift
T
rzen 8b20d7ad08 Preview the next exercise and count into each set during rests
The run flow's between-exercise rest now previews the next exercise — its
figure and a large "Coming up" name — and the hands-free narration gains a
per-second count-in ("<Exercise>, in 1, 2, 3, GO!") spoken before every set,
plus a "Coming up" announcement as a between-exercise rest begins. A new
Settings > Narration picker chooses the count-in cues, the setup/form read,
or both.

Also fixes spoken cues going silent after the first exercise in a flow split:
the stop-on-teardown moved from the per-exercise view (rebuilt on every
hand-off) to the run host, which stays mounted for the whole run. The audio
session now holds its duck across the per-second count so background music
doesn't pulse between words.

Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh
2026-07-10 09:38:45 -04:00

376 lines
16 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 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<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)
}
}
}
}