initial pre-viable version of watch app

This commit is contained in:
2025-07-20 19:44:53 -04:00
parent 33b88cb8f0
commit 68d90160c6
35 changed files with 2108 additions and 179 deletions

View File

@ -0,0 +1,230 @@
//
// ActiveWorkoutListView.swift
// Workouts
//
// Created by rzen on 7/20/25 at 6:35 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
struct ActiveWorkoutListView: View {
@Environment(\.modelContext) private var modelContext
let workouts: [Workout]
var body: some View {
List {
ForEach(workouts) { workout in
NavigationLink {
WorkoutDetailView(workout: workout)
} label: {
WorkoutCardView(workout: workout)
}
// .listRowSeparator(.hidden)
.listRowBackground(
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.2))
.padding(
EdgeInsets(
top: 4,
leading: 8,
bottom: 4,
trailing: 8
)
)
)
}
}
.listStyle(.carousel)
// .navigationTitle("Workouts")
}
}
struct WorkoutCardView: View {
let workout: Workout
var body: some View {
VStack(alignment: .leading) {
// Split icon
if let split = workout.split {
Image(systemName: split.systemImage)
.font(.system(size: 48))
.foregroundStyle(split.getColor())
} else {
Image(systemName: "dumbbell.fill")
.font(.system(size: 24))
.foregroundStyle(.gray)
}
// VStack(alignment: .leading, spacing: 4) {
// Split name
Text(workout.split?.name ?? "Workout")
.font(.headline)
.foregroundStyle(.white)
// Workout status
Text(workout.status?.name ?? "Not Started")
.font(.caption)
.foregroundStyle(Color.accentColor)
// }
// Spacer()
}
// .padding(.vertical, 8)
}
}
struct WorkoutDetailView: View {
let workout: Workout
var body: some View {
VStack(alignment: .center, spacing: 8) {
if let logs = workout.logs?.sorted(by: { $0.order < $1.order }), !logs.isEmpty {
List {
ForEach(logs) { log in
NavigationLink {
WorkoutLogDetailView(log: log)
} label: {
WorkoutLogCardView(log: log)
}
.listRowBackground(
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.2))
.padding(
EdgeInsets(
top: 4,
leading: 8,
bottom: 4,
trailing: 8
)
)
)
}
}
.listStyle(.carousel)
} else {
Text("No exercises in this workout")
.font(.body)
.foregroundStyle(.secondary)
.padding()
Spacer()
}
}
}
}
struct WorkoutLogCardView: View {
let log: WorkoutLog
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Exercise name
Text(log.exerciseName)
.font(.headline)
.lineLimit(1)
// Status
Text(log.status?.name ?? "Not Started")
.font(.caption)
.foregroundStyle(Color.accentColor)
// Sets, Reps, Weight
HStack(spacing: 12) {
Text("\(log.weight) lbs")
Spacer()
Text("\(log.sets) × \(log.reps)")
}
}
}
}
struct WorkoutLogDetailView: View {
let log: WorkoutLog
var body: some View {
VStack(spacing: 16) {
Text(log.exerciseName)
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Sets:")
.foregroundStyle(.secondary)
Spacer()
Text("\(log.sets)")
}
HStack {
Text("Reps:")
.foregroundStyle(.secondary)
Spacer()
Text("\(log.reps)")
}
HStack {
Text("Weight:")
.foregroundStyle(.secondary)
Spacer()
Text("\(log.weight)")
}
HStack {
Text("Status:")
.foregroundStyle(.secondary)
Spacer()
Text(log.status?.name ?? "Not Started")
.foregroundStyle(statusColor(for: log.status))
}
}
.padding()
.background(Color.secondary.opacity(0.1))
.cornerRadius(10)
NavigationLink {
ExerciseProgressControlView(log: log)
} label: {
Text("Start Exercise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.accentColor)
Spacer()
}
.padding()
}
private func statusColor(for status: WorkoutStatus?) -> Color {
guard let status = status else { return .secondary }
switch status {
case .notStarted:
return .secondary
case .inProgress:
return .blue
case .completed:
return .green
case .skipped:
return .red
}
}
}
//#Preview {
// let container = AppContainer.preview
// let split = Split(name: "Upper Body", color: "blue", systemImage: "figure.strengthtraining.traditional")
// let workout1 = Workout(start: Date(), end: nil, split: split)
// workout1.status = .inProgress
//
// let split2 = Split(name: "Lower Body", color: "red", systemImage: "figure.run")
// let workout2 = Workout(start: Date().addingTimeInterval(-3600), end: nil, split: split2)
// workout2.status = .notStarted
//
// return ActiveWorkoutListView(workouts: [workout1, workout2])
// .modelContainer(container)
//}

View File

@ -0,0 +1,268 @@
//
// ExerciseProgressControlView.swift
// Workouts
//
// Created by rzen on 7/20/25 at 7:19 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
enum ExerciseState: Identifiable {
case set(number: Int)
case rest(afterSet: Int)
case done
var id: String {
switch self {
case .set(let number):
return "set_\(number)"
case .rest(let afterSet):
return "rest_\(afterSet)"
case .done:
return "done"
}
}
var isRest: Bool {
if case .rest = self {
return true
}
return false
}
var isSet: Bool {
if case .set = self {
return true
}
return false
}
var isDone: Bool {
if case .done = self {
return true
}
return false
}
}
struct ExerciseProgressControlView: View {
let log: WorkoutLog
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@State private var exerciseStates: [ExerciseState] = []
@State private var currentStateIndex: Int = 0
@State private var elapsedSeconds: Int = 0
@State private var timer: Timer? = nil
@State private var previousStateIndex: Int = 0
var body: some View {
TabView(selection: $currentStateIndex) {
ForEach(Array(exerciseStates.enumerated()), id: \.element.id) { index, state in
ExerciseStateView(
state: state,
elapsedSeconds: elapsedSeconds,
onComplete: {
moveToNextState()
}
)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: currentStateIndex) { oldValue, newValue in
if oldValue != newValue {
// Reset timer when user swipes to a new state
elapsedSeconds = 0
}
}
.onAppear {
setupExerciseStates()
startTimer()
}
.onDisappear {
stopTimer()
}
}
private func setupExerciseStates() {
var states: [ExerciseState] = []
// Create states for each set and rest period
for setNumber in 1...log.sets {
states.append(.set(number: setNumber))
// Add rest period after each set except the last one
if setNumber < log.sets {
states.append(.rest(afterSet: setNumber))
}
}
// Add done state at the end
states.append(.done)
exerciseStates = states
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
elapsedSeconds += 1
// Check if we need to provide haptic feedback during rest periods
if let currentState = exerciseStates[safe: currentStateIndex] {
if currentState.isRest {
provideRestHapticFeedback()
} else if currentState.isDone && elapsedSeconds >= 10 {
// Auto-complete after 10 seconds on the DONE state
completeExercise()
}
}
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func moveToNextState() {
if currentStateIndex < exerciseStates.count - 1 {
withAnimation {
currentStateIndex += 1
elapsedSeconds = 0
}
} else {
// We've reached the end (DONE state)
completeExercise()
}
}
private func provideRestHapticFeedback() {
// Provide haptic feedback based on elapsed time
if elapsedSeconds % 60 == 0 && elapsedSeconds > 0 {
// Triple tap every 60 seconds
HapticFeedback.tripleTap()
} else if elapsedSeconds % 30 == 0 && elapsedSeconds > 0 {
// Double tap every 30 seconds
HapticFeedback.doubleTap()
} else if elapsedSeconds % 10 == 0 && elapsedSeconds > 0 {
// Single tap every 10 seconds
HapticFeedback.success()
}
}
private func completeExercise() {
// Update the workout log status to completed
log.status = .completed
// Provide "tada" haptic feedback
HapticFeedback.tripleTap()
// Dismiss this view to return to WorkoutDetailView
dismiss()
}
}
struct ExerciseStateView: View {
let state: ExerciseState
let elapsedSeconds: Int
let onComplete: () -> Void
var body: some View {
VStack(spacing: 20) {
// Title based on state
Text(stateTitle)
.font(.title3)
.fontWeight(.bold)
// Timer display
Text(timeFormatted)
.font(.system(size: 48, weight: .semibold, design: .monospaced))
.foregroundStyle(state.isRest ? .orange : .accentColor)
// Only show Done button and countdown for the final state
if state.isDone {
// Countdown message
if elapsedSeconds < 10 {
Text("Completing automatically in \(10 - elapsedSeconds) seconds")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
} else {
Text("Auto-completing...")
.font(.caption)
.foregroundStyle(.secondary)
}
// Done button
Button(action: onComplete) {
Text("Done")
.font(.headline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.green)
.padding(.horizontal)
}
}
.padding()
}
private var stateTitle: String {
switch state {
case .set(let number):
return "Set \(number) in progress"
case .rest:
return "Resting"
case .done:
return "Exercise Complete"
}
}
private var buttonTitle: String {
switch state {
case .set:
return "Complete Set"
case .rest:
return "Start Next Set"
case .done:
return "DONE"
}
}
private var buttonColor: Color {
switch state {
case .set:
return .accentColor
case .rest:
return .orange
case .done:
return .green
}
}
private var timeFormatted: String {
let minutes = elapsedSeconds / 60
let seconds = elapsedSeconds % 60
return String(format: "%02d:%02d", minutes, seconds)
}
}
// Extension to safely access array elements
extension Array {
subscript(safe index: Index) -> Element? {
return indices.contains(index) ? self[index] : nil
}
}
//#Preview {
// let container = AppContainer.preview
// let workout = Workout(start: Date(), end: nil, split: nil)
// let log = WorkoutLog(workout: workout, exerciseName: "Bench Press", date: Date(), sets: 3, reps: 10, weight: 135)
//
// ExerciseProgressControlView(log: log)
// .modelContainer(container)
//}

View File

@ -0,0 +1,317 @@
import SwiftUI
import SwiftData
import WatchKit
// Enum to track the current phase of the exercise
enum ExercisePhase {
case notStarted
case exercising(setNumber: Int)
case resting(setNumber: Int, elapsedSeconds: Int)
case completed
}
struct ExerciseProgressView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
let log: WorkoutLog
@State private var phase: ExercisePhase = .notStarted
@State private var hapticSeconds: Int = 0
@State private var restSeconds: Int = 0
@State private var hapticTimer: Timer? = nil
@State private var restTimer: Timer? = nil
var body: some View {
ScrollView {
VStack(spacing: 15) {
exerciseHeader
switch phase {
case .notStarted:
startPhaseView
case .exercising(let setNumber):
exercisingPhaseView(setNumber: setNumber)
case .resting(let setNumber, let elapsedSeconds):
restingPhaseView(setNumber: setNumber, elapsedSeconds: elapsedSeconds)
case .completed:
completedPhaseView
}
}
.padding()
}
.navigationTitle(log.exerciseName)
.navigationBarTitleDisplayMode(.inline)
.onDisappear {
stopTimers()
}
.gesture(
DragGesture(minimumDistance: 50)
.onEnded { gesture in
if gesture.translation.width < 0 {
// Swipe left - progress to next phase
handleSwipeLeft()
} else if gesture.translation.height < 0 && gesture.translation.height < -50 {
// Swipe up - cancel current set
handleSwipeUp()
}
}
)
}
// MARK: - View Components
private var exerciseHeader: some View {
VStack(alignment: .leading, spacing: 5) {
Text(log.exerciseName)
.font(.headline)
.foregroundColor(.primary)
Text("\(log.sets) sets × \(log.reps) reps")
.font(.subheadline)
.foregroundColor(.secondary)
Text("\(log.weight) lbs")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 5)
}
private var startPhaseView: some View {
VStack(spacing: 20) {
Text("Ready to start?")
.font(.headline)
Button(action: startFirstSet) {
Text("Start First Set")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
}
private func exercisingPhaseView(setNumber: Int) -> some View {
VStack(spacing: 20) {
Text("Set \(setNumber) of \(log.sets)")
.font(.headline)
Text("Exercising...")
.foregroundColor(.secondary)
HStack(spacing: 20) {
Button(action: completeSet) {
Text("Complete")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.green)
Button(action: cancelSet) {
Text("Cancel")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.red)
}
Text("Or swipe left to complete")
.font(.caption)
.foregroundColor(.secondary)
Text("Swipe up to cancel")
.font(.caption)
.foregroundColor(.secondary)
}
}
private func restingPhaseView(setNumber: Int, elapsedSeconds: Int) -> some View {
VStack(spacing: 20) {
Text("Rest after Set \(setNumber)")
.font(.headline)
Text("Rest time: \(formatSeconds(elapsedSeconds))")
.foregroundColor(.secondary)
if setNumber < (log.sets) {
Button(action: { startNextSet(after: setNumber) }) {
Text("Start Set \(setNumber + 1)")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
Text("Or swipe left to start next set")
.font(.caption)
.foregroundColor(.secondary)
} else {
Button(action: completeExercise) {
Text("Complete Exercise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.green)
Text("Or swipe left to complete")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private var completedPhaseView: some View {
VStack(spacing: 20) {
Text("Exercise Completed!")
.font(.headline)
.foregroundColor(.green)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 50))
.foregroundColor(.green)
Button(action: { dismiss() }) {
Text("Return to Workout")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
}
// MARK: - Action Handlers
private func handleSwipeLeft() {
switch phase {
case .notStarted:
startFirstSet()
case .exercising:
completeSet()
case .resting(let setNumber, _):
if setNumber < (log.sets) {
startNextSet(after: setNumber)
} else {
completeExercise()
}
case .completed:
dismiss()
}
}
private func handleSwipeUp() {
if case .exercising = phase {
cancelSet()
}
}
private func startFirstSet() {
phase = .exercising(setNumber: 1)
startHapticTimer()
}
private func startNextSet(after completedSetNumber: Int) {
stopTimers()
let nextSetNumber = completedSetNumber + 1
phase = .exercising(setNumber: nextSetNumber)
startHapticTimer()
}
private func completeSet() {
stopTimers()
if case .exercising(let setNumber) = phase {
// Start rest timer
phase = .resting(setNumber: setNumber, elapsedSeconds: 0)
startRestTimer()
startHapticTimer()
// Play completion haptic
HapticFeedback.success()
}
}
private func cancelSet() {
// Just go back to the previous state
stopTimers()
phase = .notStarted
}
private func completeExercise() {
stopTimers()
// Update workout log
log.completed = true
log.status = .completed
try? modelContext.save()
// Show completion screen
phase = .completed
// Play completion haptic
HapticFeedback.success()
}
// MARK: - Timer Management
private func startHapticTimer() {
hapticSeconds = 0
hapticTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
hapticSeconds += 1
// Provide haptic feedback based on time intervals
if hapticSeconds % 60 == 0 {
// Triple tap every 60 seconds
HapticFeedback.tripleTap()
} else if hapticSeconds % 30 == 0 {
// Double tap every 30 seconds
HapticFeedback.doubleTap()
} else if hapticSeconds % 10 == 0 {
// Light tap every 10 seconds
HapticFeedback.click()
}
}
}
private func startRestTimer() {
restSeconds = 0
restTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
restSeconds += 1
if case .resting(let setNumber, _) = phase {
phase = .resting(setNumber: setNumber, elapsedSeconds: restSeconds)
}
}
}
private func stopTimers() {
hapticTimer?.invalidate()
hapticTimer = nil
restTimer?.invalidate()
restTimer = nil
}
// MARK: - Helper Functions
private func formatSeconds(_ seconds: Int) -> String {
let minutes = seconds / 60
let remainingSeconds = seconds % 60
return String(format: "%d:%02d", minutes, remainingSeconds)
}
}
//#Preview {
// let config = ModelConfiguration(isStoredInMemoryOnly: true)
// let container = try! ModelContainer(for: SchemaV1.models, configurations: config)
//
// // Create sample data
// let exercise = Exercise(name: "Bench Press", sets: 3, reps: 8, weight: 60.0)
// let workout = Workout(name: "Chest Day", date: Date())
// let log = WorkoutLog(exercise: exercise, workout: workout)
//
// NavigationStack {
// ExerciseProgressView(log: log)
// .modelContainer(container)
// }
//}

View File

@ -0,0 +1,117 @@
import SwiftUI
import SwiftData
struct WorkoutLogListView: View {
@Environment(\.modelContext) private var modelContext
let workout: Workout
@State private var selectedLogIndex: Int = 0
var body: some View {
VStack {
if let logs = workout.logs?.sorted(by: { $0.order < $1.order }), !logs.isEmpty {
TabView(selection: $selectedLogIndex) {
ForEach(Array(logs.enumerated()), id: \.element.id) { index, log in
WorkoutLogCard(log: log, index: index)
.tag(index)
}
}
.tabViewStyle(.page)
// .indexViewStyle(.page(backgroundDisplayMode: .always))
} else {
Text("No exercises in this workout")
.foregroundStyle(.secondary)
}
}
.navigationTitle(workout.split?.name ?? "Workout")
.navigationBarTitleDisplayMode(.inline)
}
}
struct WorkoutLogCard: View {
let log: WorkoutLog
let index: Int
var body: some View {
VStack(spacing: 12) {
Text(log.exerciseName)
.font(.headline)
.multilineTextAlignment(.center)
HStack {
VStack {
Text("\(log.sets)")
.font(.title2)
Text("Sets")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
VStack {
Text("\(log.reps)")
.font(.title2)
Text("Reps")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
VStack {
Text("\(log.weight)")
.font(.title2)
Text("Weight")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
NavigationLink {
ExerciseProgressView(log: log)
} label: {
Text("Start")
.font(.headline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.blue)
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
Text(log.status?.name ?? "Not Started")
.font(.caption)
.foregroundStyle(statusColor(for: log.status))
}
.padding()
.background(Color.secondary.opacity(0.2))
.cornerRadius(12)
.padding(.horizontal)
}
private func statusColor(for status: WorkoutStatus?) -> Color {
guard let status = status else { return .secondary }
switch status {
case .notStarted:
return .secondary
case .inProgress:
return .blue
case .completed:
return .green
case .skipped:
return .red
}
}
}
#Preview {
let container = AppContainer.preview
let workout = Workout(start: Date(), end: Date(), split: nil)
let log1 = WorkoutLog(workout: workout, exerciseName: "Bench Press", date: Date(), sets: 3, reps: 10, weight: 135)
let log2 = WorkoutLog(workout: workout, exerciseName: "Squats", date: Date(), order: 1, sets: 3, reps: 8, weight: 225)
return WorkoutLogListView(workout: workout)
.modelContainer(container)
}