add SimpleStopWatch to ActiveWorkoutSession

This commit is contained in:
Felix Förtsch
2024-10-28 12:00:56 +01:00
parent d1a87957f6
commit c722d59aff
18 changed files with 213 additions and 153 deletions

View 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)
}

View File

@@ -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))
}

View File

@@ -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

View File

@@ -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 }
}

View File

@@ -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 {

View File

@@ -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")
}
}

View File

@@ -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) {

View 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"),
]
}

View File

@@ -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 }
//}

View File

@@ -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)

View File

@@ -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] = []) {

View File

@@ -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)
}
}
}
}

View File

@@ -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
// }
//}

View File

@@ -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)
}
}

View File

@@ -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(""))
}
}
}
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -32,6 +32,7 @@ struct WorkoutsPlusApp: App {
extension WorkoutsPlusApp {
static let swiftDataSchema = Schema([
Exercise.self,
ExerciseUnit.self,
Equipment.self,
Workout.self,
WorkoutItem.self,