add SimpleStopWatch to ActiveWorkoutSession
This commit is contained in:
66
WorkoutsPlus/Components/SimpleStopWatch.swift
Normal file
66
WorkoutsPlus/Components/SimpleStopWatch.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// SimpleStopWatch.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 12.09.24.
|
||||
//
|
||||
import SwiftUI
|
||||
|
||||
struct SimpleStopWatch: View {
|
||||
@State var startDate: Date
|
||||
@Binding var duration: TimeInterval
|
||||
|
||||
@State private var isPaused = false
|
||||
@State private var timeString: String = "00:00:00"
|
||||
@State private var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
Text(timeString)
|
||||
.onReceive(timer) { _ in
|
||||
if !isPaused {
|
||||
duration += 1
|
||||
timeString = format(time: duration)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
isPaused.toggle()
|
||||
}
|
||||
.onAppear() {
|
||||
duration = Date().timeIntervalSince(startDate)
|
||||
timeString = format(time: duration)
|
||||
startTimer()
|
||||
}
|
||||
.onDisappear() {
|
||||
stopTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer.upstream.connect().cancel()
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
}
|
||||
|
||||
private func format(time: TimeInterval) -> String {
|
||||
let totalTimeInSeconds = Int(time)
|
||||
|
||||
let hours = totalTimeInSeconds / 3600
|
||||
let minutes = (totalTimeInSeconds % 3600) / 60
|
||||
let seconds = totalTimeInSeconds % 60
|
||||
|
||||
var formattedTime = ""
|
||||
if hours > 0 {
|
||||
formattedTime += String(format: "%d:", hours)
|
||||
}
|
||||
formattedTime += String(format: "%02d:%02d", minutes, seconds)
|
||||
|
||||
return formattedTime
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
@Previewable @State var duration = 0.0
|
||||
SimpleStopWatch(startDate: Date.now, duration: $duration)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
//
|
||||
// TimerView.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 12.09.24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TimerView: View {
|
||||
@Binding var isActive: Bool
|
||||
@State private var time = 0
|
||||
var startDate: Date?
|
||||
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
Text("\(time)")
|
||||
.onReceive(timer) { _ in
|
||||
if isActive {
|
||||
if let startDate = startDate {
|
||||
self.time = Int(Date.now.timeIntervalSince(startDate))
|
||||
} else {
|
||||
self.time += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
self.timer.upstream.connect().cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TimerView(isActive: .constant(true), startDate: Date().addingTimeInterval(-3600)) // Example startDate 1 hour ago
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TimerView(isActive: .constant(true))
|
||||
}
|
||||
@@ -12,7 +12,8 @@ public class Defaults: ObservableObject {
|
||||
@AppStorage("isDebug") public var isDebug = true
|
||||
|
||||
@AppStorage("isFirstAppStart") public var isFirstAppStart = true
|
||||
@AppStorage("isOnboarding") public var isOnboarding = true
|
||||
// TODO: isOnboarding should be true before shipping
|
||||
@AppStorage("isOnboarding") public var isOnboarding = false
|
||||
@AppStorage("isTrainerMode") public var isTrainerMode = false
|
||||
@AppStorage("isWorkingOut") public var isWorkingOut = false
|
||||
|
||||
|
||||
@@ -16,3 +16,8 @@ protocol Positionable: Identifiable {
|
||||
var id: UUID { get }
|
||||
var position: Int { get set }
|
||||
}
|
||||
|
||||
protocol Unit {
|
||||
var name: String { get }
|
||||
var symbol: String { get }
|
||||
}
|
||||
|
||||
@@ -45,11 +45,13 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
// MARK: Workout Controls
|
||||
if let activeWorkoutSession {
|
||||
if let _ = activeWorkoutSession {
|
||||
Section {
|
||||
NavigationLink(
|
||||
destination: ActiveWorkoutSession(
|
||||
activeWorkoutSession: $activeWorkoutSession)
|
||||
activeWorkoutSession: Binding(
|
||||
get: { activeWorkoutSession! },
|
||||
set: { activeWorkoutSession = $0 }))
|
||||
) {
|
||||
HStack {
|
||||
Label("Back to Session", systemImage: "memories")
|
||||
@@ -61,8 +63,8 @@ struct ContentView: View {
|
||||
}
|
||||
ActiveWorkoutSessionControls(
|
||||
session: Binding(
|
||||
get: { self.activeWorkoutSession! },
|
||||
set: { self.activeWorkoutSession = $0 }
|
||||
get: { activeWorkoutSession! },
|
||||
set: { activeWorkoutSession = $0 }
|
||||
))
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -24,15 +24,19 @@ final class Exercise: Nameable {
|
||||
var creationDate: Date = Date.now
|
||||
|
||||
@Attribute(.unique) var name: String
|
||||
var exerciseDescription: String = ""
|
||||
var isPartOfProgression: Bool = false
|
||||
var metric: ExerciseMetric
|
||||
var exerciseDescription = ""
|
||||
|
||||
var metric = ExerciseMetric.none
|
||||
var unit: ExerciseUnit
|
||||
|
||||
var isPartOfProgression = false
|
||||
|
||||
var equipment: [Equipment] = []
|
||||
|
||||
init(_ name: String, _ metric: ExerciseMetric = .none) {
|
||||
self.name = name
|
||||
self.metric = metric
|
||||
self.unit = ExerciseUnit(name: "Meters", symbol: "m")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,13 @@ struct ExerciseEditor: View {
|
||||
|
||||
@State private var name: String = ""
|
||||
@State private var description: String = ""
|
||||
@State private var metric: ExerciseMetric = .none
|
||||
@State private var metric = ExerciseMetric.none
|
||||
@State private var unit = ExerciseUnit(name: "", symbol: "")
|
||||
|
||||
@State private var isPartOfProgression: Bool = false
|
||||
|
||||
let exerciseUnits = ExerciseUnit.getUnits
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
@@ -45,6 +48,12 @@ struct ExerciseEditor: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
Picker("Select Exercise Unit", selection: $unit) {
|
||||
ForEach(exerciseUnits, id: \.self) { unit in
|
||||
Text("\(unit.name) (\(unit.symbol))").tag(unit as ExerciseUnit?)
|
||||
}
|
||||
}
|
||||
.pickerStyle(NavigationLinkPickerStyle())
|
||||
}
|
||||
Section(footer: Text("Feature coming soon.")) {
|
||||
Toggle(isOn: $isPartOfProgression) {
|
||||
|
||||
28
WorkoutsPlus/Features/Exercise/ExerciseUnit.swift
Normal file
28
WorkoutsPlus/Features/Exercise/ExerciseUnit.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// ExerciseUnit.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 23.10.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class ExerciseUnit: Unit {
|
||||
var name: String
|
||||
var symbol: String
|
||||
|
||||
init(name: String, symbol: String) {
|
||||
self.name = name
|
||||
self.symbol = symbol
|
||||
}
|
||||
}
|
||||
|
||||
extension ExerciseUnit: Identifiable {
|
||||
static let getUnits = [
|
||||
ExerciseUnit(name: "Kilograms", symbol: "kg"),
|
||||
ExerciseUnit(name: "Kilometers", symbol: "km"),
|
||||
ExerciseUnit(name: "Meters", symbol: "m"),
|
||||
]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
//
|
||||
// Random.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 01.10.24.
|
||||
//
|
||||
|
||||
//import Foundation
|
||||
//
|
||||
//enum Unit: String, CaseIterable, Codable {
|
||||
// case none = ""
|
||||
//
|
||||
// case kilogram = "kg"
|
||||
// case gram = "g"
|
||||
//
|
||||
// case liter = "l"
|
||||
// case milliliter = "ml"
|
||||
//
|
||||
// case kilometer = "km"
|
||||
// case meter = "m"
|
||||
//
|
||||
// case hour = "h"
|
||||
// case minute = "min"
|
||||
// case second = "s"
|
||||
//
|
||||
// var description: String { rawValue }
|
||||
//}
|
||||
@@ -68,22 +68,8 @@ struct WorkoutDetail: View {
|
||||
.navigationTitle("\(workout.name)")
|
||||
.toolbar {
|
||||
// TODO: Add proper Sharing for workouts.
|
||||
// ToolbarItem() { ShareLink(item: URL(filePath: "felixfoertsch.de")!) }
|
||||
if (isWorkingOut) {
|
||||
Button(action: {
|
||||
isWorkingOut = false
|
||||
activeWorkoutSession?.stop()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "stop.fill")
|
||||
Text("Stop")
|
||||
}
|
||||
}
|
||||
.bold()
|
||||
.fontDesign(.rounded)
|
||||
.tint(.red)
|
||||
}
|
||||
else {
|
||||
// ToolbarItem() { ShareLink(item: URL(filePath: "felixfoertsch.de")!) }
|
||||
if (!isWorkingOut) {
|
||||
Button(action: {
|
||||
isWorkingOut = true
|
||||
activeWorkoutSession = workout.start()
|
||||
@@ -93,10 +79,11 @@ struct WorkoutDetail: View {
|
||||
Text("Start")
|
||||
}
|
||||
}
|
||||
.bold()
|
||||
.fontWeight(.bold)
|
||||
.fontDesign(.rounded)
|
||||
.tint(.green)
|
||||
}
|
||||
// TODO: Maybe return to ActiveSessionView
|
||||
}
|
||||
.sheet(isPresented: $isPresentingWorkoutItemLibrarySheet) {
|
||||
WorkoutItemLibrarySheet(workout: workout)
|
||||
|
||||
@@ -45,6 +45,7 @@ final class WorkoutItem: Nameable, Positionable {
|
||||
var exercise: Exercise // Do Push-up | Run Marathon
|
||||
var plannedReps: Int // 8 times | 1 time
|
||||
var plannedValue: Double // With 10 | 42,187
|
||||
var unit: ExerciseUnit
|
||||
var metric: ExerciseMetric? // kg (weight) | km (distance)
|
||||
|
||||
enum WorkoutItemType: Codable {
|
||||
@@ -64,7 +65,7 @@ final class WorkoutItem: Nameable, Positionable {
|
||||
// 0
|
||||
self.plannedValue = 0
|
||||
// kg
|
||||
self.metric = exercise.metric
|
||||
self.unit = exercise.unit
|
||||
}
|
||||
|
||||
// init(set: [WorkoutItem] = []) {
|
||||
|
||||
@@ -10,6 +10,8 @@ import SwiftData
|
||||
|
||||
struct WorkoutLibrary: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Default(\.isWorkingOut) var isWorkingOut
|
||||
|
||||
@Binding var activeWorkoutSession: WorkoutSession?
|
||||
|
||||
@Query(sort: \Workout.name) private var workouts: [Workout]
|
||||
@@ -39,11 +41,13 @@ struct WorkoutLibrary: View {
|
||||
Text(workout.name)
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
activeWorkoutSession = workout.start()
|
||||
} label: {
|
||||
Label("Start", systemImage: "play")
|
||||
.tint(.green)
|
||||
if !isWorkingOut {
|
||||
Button {
|
||||
activeWorkoutSession = workout.start()
|
||||
} label: {
|
||||
Label("Start", systemImage: "play")
|
||||
.tint(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,51 @@
|
||||
//
|
||||
// ActiveWorkoutSession.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 12.09.24.
|
||||
//
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// This view can only be viewed, if there exists a WorkoutSession that can be considered active (a person working out).
|
||||
struct ActiveWorkoutSession: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@Default(\.isWorkingOut) var isWorkingOut
|
||||
@State var isTimerRunning: Bool = true
|
||||
|
||||
@Query(sort: \WorkoutSession.name) var workoutSessions: [WorkoutSession]
|
||||
@Binding var activeWorkoutSession: WorkoutSession?
|
||||
@Binding var activeWorkoutSession: WorkoutSession
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
VStack {
|
||||
Text(activeWorkoutSession.name)
|
||||
SimpleStopWatch(
|
||||
startDate: activeWorkoutSession.startDate,
|
||||
duration: $activeWorkoutSession.workoutDuration)
|
||||
.font(.system(.largeTitle, design: .monospaced))
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
List {
|
||||
Section(header: Text("Workout")) {
|
||||
Text(activeWorkoutSession!.name)
|
||||
}
|
||||
Section(header: Text("Exercises")) {
|
||||
ForEach(activeWorkoutSession!.workoutSessionItems) { workoutItem in
|
||||
ForEach(activeWorkoutSession.workoutSessionItems) { workoutItem in
|
||||
ActiveWorkoutSessionListItem(workoutItem: workoutItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Session")
|
||||
// .navigationTitle(activeWorkoutSession.name)
|
||||
// .navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
Button(action: {
|
||||
isWorkingOut = false
|
||||
activeWorkoutSession?.stop()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "stop.fill")
|
||||
Text("Stop")
|
||||
}
|
||||
Button(action: {
|
||||
isWorkingOut = false
|
||||
activeWorkoutSession.isPaused.toggle()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "stop.fill")
|
||||
Text("Stop")
|
||||
}
|
||||
.bold()
|
||||
.fontDesign(.rounded)
|
||||
.tint(.red)
|
||||
}
|
||||
.fontWeight(.bold)
|
||||
.fontDesign(.rounded)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +62,7 @@ struct ActiveWorkoutSession: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
@Previewable @State var activeWorkoutSession: WorkoutSession?
|
||||
@Previewable @State var workout = Workout.sampleData.first!
|
||||
@Previewable @State var activeWorkoutSession = Workout.sampleData.first!.start()
|
||||
|
||||
NavigationStack {
|
||||
ActiveWorkoutSession(activeWorkoutSession: $activeWorkoutSession)
|
||||
@@ -65,14 +72,3 @@ struct ActiveWorkoutSession: View {
|
||||
}
|
||||
.modelContainer(SampleData.shared.modelContainer)
|
||||
}
|
||||
|
||||
//#Preview("Empty modelContainer") {
|
||||
// @Previewable @State var activeWorkoutSession: WorkoutSession?
|
||||
//
|
||||
// NavigationStack {
|
||||
// ActiveWorkoutSession(activeWorkoutSession: $activeWorkoutSession)
|
||||
// }
|
||||
// .onAppear {
|
||||
// Defaults.shared.isWorkingOut = false
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -31,11 +31,11 @@ struct ActiveWorkoutSessionControls: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.bold()
|
||||
.fontWeight(.bold)
|
||||
.tint(.primary)
|
||||
Button(action: {
|
||||
// TODO: Implement proper Pausing
|
||||
session.pause()
|
||||
session.isPaused = true
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "pause.fill")
|
||||
@@ -43,7 +43,7 @@ struct ActiveWorkoutSessionControls: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.bold()
|
||||
.fontWeight(.bold)
|
||||
.tint(.gray)
|
||||
.disabled(true)
|
||||
Button(action: {
|
||||
@@ -55,7 +55,7 @@ struct ActiveWorkoutSessionControls: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.bold()
|
||||
.fontWeight(.bold)
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,16 @@ struct ActiveWorkoutSessionListItem: View {
|
||||
var workoutItem: WorkoutSessionItem
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text(String(workoutItem.plannedReps) + "x")
|
||||
Text(String(workoutItem.plannedValue))
|
||||
Text(String(workoutItem.unit.symbol))
|
||||
if let metric = workoutItem.metric {
|
||||
Text(metric.rawValue)
|
||||
}
|
||||
Text(workoutItem.name)
|
||||
.fontWeight(.bold)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
// TODO: Implement a sheet view; don't use ExerciseDetail since its purpose is editing
|
||||
@@ -20,6 +28,18 @@ struct ActiveWorkoutSessionListItem: View {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Planned: ")
|
||||
|
||||
if let actualRepsDone = workoutItem.actualReps {
|
||||
Text("Actual: ")
|
||||
Text(String(actualRepsDone))
|
||||
} else {
|
||||
Text("Actual: ")
|
||||
TextField("", text: .constant(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ final class WorkoutSession: Nameable {
|
||||
}
|
||||
|
||||
// State
|
||||
// var isPaused: Bool
|
||||
var isPaused = false
|
||||
// var isCancelled: Bool
|
||||
// var isDeleted: Bool
|
||||
// var isSynced: Bool
|
||||
@@ -34,9 +34,10 @@ final class WorkoutSession: Nameable {
|
||||
// My workout session was completed at:
|
||||
var stopDate: Date? = nil
|
||||
// My workout session took me x seconds.
|
||||
var duration: TimeInterval? = nil
|
||||
var workoutDuration: TimeInterval = 0.0
|
||||
var pauseDuration: TimeInterval = 0.0
|
||||
// My workout session is completed and moved to the workout log.
|
||||
var isCompleted: Bool = false
|
||||
var isCompleted = false
|
||||
|
||||
// Exercise Progress
|
||||
var currentExercise = 0
|
||||
@@ -46,20 +47,19 @@ final class WorkoutSession: Nameable {
|
||||
}
|
||||
|
||||
// MARK: -- Workout Controls
|
||||
// func start(with workout: Workout) {
|
||||
// self.workout = workout
|
||||
// startDate = Date.now
|
||||
// }
|
||||
|
||||
func pause() {
|
||||
// TODO: Implement proper Pause
|
||||
}
|
||||
// func pause() {
|
||||
// isPaused = true
|
||||
// }
|
||||
//
|
||||
// func resume() {
|
||||
// isPaused = false
|
||||
// }
|
||||
|
||||
// Call stop() to terminate the workout.
|
||||
func stop() {
|
||||
isCompleted = true
|
||||
stopDate = Date.now
|
||||
duration = stopDate!.timeIntervalSince(startDate)
|
||||
// duration = stopDate!.timeIntervalSince(startDate)
|
||||
}
|
||||
|
||||
func prevExercise() {
|
||||
@@ -69,7 +69,7 @@ final class WorkoutSession: Nameable {
|
||||
}
|
||||
|
||||
func nextExercise() {
|
||||
|
||||
|
||||
}
|
||||
|
||||
// MARK: -- Workout Information
|
||||
|
||||
@@ -16,9 +16,10 @@ final class WorkoutSessionItem: Nameable, Positionable {
|
||||
|
||||
var workoutSession: WorkoutSession
|
||||
|
||||
var plannedReps: Int // 8 times | 1 time
|
||||
var plannedValue: Double // With 10 | 42,187
|
||||
var metric: ExerciseMetric? // kg (weight) | km (distance)
|
||||
var plannedReps: Int // 8 times | 1 time
|
||||
var plannedValue: Double // With 10 | 42,187
|
||||
var unit: ExerciseUnit
|
||||
var metric: ExerciseMetric? // kg (weight) | km (distance)
|
||||
|
||||
var actualReps: Int?
|
||||
var actualValue: Double?
|
||||
@@ -29,6 +30,7 @@ final class WorkoutSessionItem: Nameable, Positionable {
|
||||
self.position = planned.position
|
||||
self.plannedReps = planned.plannedReps
|
||||
self.plannedValue = planned.plannedValue
|
||||
self.unit = planned.unit
|
||||
self.metric = planned.metric
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ struct WorkoutsPlusApp: App {
|
||||
extension WorkoutsPlusApp {
|
||||
static let swiftDataSchema = Schema([
|
||||
Exercise.self,
|
||||
ExerciseUnit.self,
|
||||
Equipment.self,
|
||||
Workout.self,
|
||||
WorkoutItem.self,
|
||||
|
||||
Reference in New Issue
Block a user