The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
387 lines
16 KiB
Swift
387 lines
16 KiB
Swift
//
|
||
// 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 routines: [Routine]
|
||
|
||
@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 {
|
||
GoalsListView()
|
||
} label: {
|
||
HStack {
|
||
Label("Goals", systemImage: "target")
|
||
Spacer()
|
||
Text("\(GoalKind.allCases.count)")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
NavigationLink {
|
||
RoutineListView()
|
||
} label: {
|
||
HStack {
|
||
Label("Routines", systemImage: "dumbbell.fill")
|
||
Spacer()
|
||
Text("\(routines.count)")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
NavigationLink {
|
||
ExerciseLibraryView()
|
||
} label: {
|
||
HStack {
|
||
Label("Exercises", systemImage: "figure.strengthtraining.traditional")
|
||
Spacer()
|
||
Text("\(ExerciseMotionLibrary.exerciseNames.count)")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Starter Routines Section
|
||
Section {
|
||
Button {
|
||
Task {
|
||
isRestoringSeeds = true
|
||
restoreSeedsMessage = nil
|
||
let n = await sync.restoreSeeds()
|
||
isRestoringSeeds = false
|
||
restoreSeedsMessage = n == 0
|
||
? "All starter routines are already present."
|
||
: "Restored \(n) starter routine\(n == 1 ? "" : "s")."
|
||
}
|
||
} label: {
|
||
HStack {
|
||
Label("Restore Starter Routines", systemImage: "arrow.counterclockwise")
|
||
if isRestoringSeeds {
|
||
Spacer()
|
||
ProgressView()
|
||
}
|
||
}
|
||
}
|
||
.disabled(isRestoringSeeds || sync.iCloudStatus != .available)
|
||
} header: {
|
||
Text("Starter Routines")
|
||
} footer: {
|
||
if let restoreSeedsMessage {
|
||
Text(restoreSeedsMessage)
|
||
} else {
|
||
Text("Brings back the bundled starter routines you've deleted. Routines 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)
|
||
}
|
||
}
|
||
}
|
||
}
|