Stabilize iOS/watchOS/tvOS apps and add cross-platform audit remediation

This commit is contained in:
Trey t
2026-02-11 12:54:40 -06:00
parent e40275e694
commit acce712261
77 changed files with 2940 additions and 765 deletions

View File

@@ -19,7 +19,7 @@ struct AllWorkoutsListView: View {
@Binding var uniqueWorkoutUsers: [RegisteredUser]?
@State private var filteredRegisterdUser: RegisteredUser?
@State var workouts: [Workout]
let workouts: [Workout]
let selectedWorkout: ((Workout) -> Void)
@State var filteredWorkouts = [Workout]()
var refresh: (() -> Void)
@@ -38,19 +38,23 @@ struct AllWorkoutsListView: View {
uniqueWorkoutUsers: $uniqueWorkoutUsers,
filteredRegisterdUser: $filteredRegisterdUser,
filteredWorkouts: $filteredWorkouts,
workouts: $workouts,
workouts: .constant(workouts),
currentSort: $currentSort)
}
ScrollView {
LazyVStack(spacing: 10) {
ForEach(filteredWorkouts, id:\.id) { workout in
WorkoutOverviewView(workout: workout)
.padding([.leading, .trailing], 4)
.contentShape(Rectangle())
.onTapGesture {
selectedWorkout(workout)
}
Button(action: {
selectedWorkout(workout)
}, label: {
WorkoutOverviewView(workout: workout)
.padding([.leading, .trailing], 4)
.contentShape(Rectangle())
})
.buttonStyle(.plain)
.accessibilityLabel("Open \(workout.name)")
.accessibilityHint("Shows workout details")
}
}
}
@@ -71,6 +75,9 @@ struct AllWorkoutsListView: View {
.onAppear{
filterWorkouts()
}
.onChange(of: workouts) { _ in
filterWorkouts()
}
}
func filterWorkouts() {

View File

@@ -8,6 +8,7 @@
import Foundation
import SwiftUI
import HealthKit
import SharedCore
enum MainViewTypes: Int, CaseIterable {
case AllWorkout = 0
@@ -33,6 +34,7 @@ struct AllWorkoutsView: View {
@State public var needsUpdating: Bool = true
@ObservedObject var dataStore = DataStore.shared
@ObservedObject var userStore = UserStore.shared
@State private var showWorkoutDetail = false
@State private var selectedWorkout: Workout? {
@@ -50,8 +52,9 @@ struct AllWorkoutsView: View {
@State private var showLoginView = false
@State private var selectedSegment: MainViewTypes = .AllWorkout
@State var selectedDate: Date = Date()
private let runtimeReporter = RuntimeReporter.shared
let pub = NotificationCenter.default.publisher(for: NSNotification.Name("CreatedNewWorkout"))
let pub = NotificationCenter.default.publisher(for: AppNotifications.createdNewWorkout)
var body: some View {
ZStack {
@@ -76,12 +79,12 @@ struct AllWorkoutsView: View {
selectedWorkout = workout
}, refresh: {
self.needsUpdating = true
maybeUpdateShit()
maybeRefreshData()
})
Divider()
case .MyWorkouts:
PlannedWorkoutView(workouts: UserStore.shared.plannedWorkouts,
PlannedWorkoutView(workouts: userStore.plannedWorkouts,
selectedPlannedWorkout: $selectedPlannedWorkout)
}
}
@@ -93,7 +96,7 @@ struct AllWorkoutsView: View {
.onAppear{
// UserStore.shared.logout()
authorizeHealthKit()
maybeUpdateShit()
maybeRefreshData()
}
.sheet(item: $selectedWorkout) { item in
let isPreview = item.id == bridgeModule.currentWorkoutInfo.workout?.id
@@ -107,20 +110,20 @@ struct AllWorkoutsView: View {
.sheet(isPresented: $showLoginView) {
LoginView(completion: {
self.needsUpdating = true
maybeUpdateShit()
maybeRefreshData()
})
.interactiveDismissDisabled()
}
.onReceive(pub) { (output) in
self.needsUpdating = true
maybeUpdateShit()
maybeRefreshData()
}
}
func maybeUpdateShit() {
if UserStore.shared.token != nil{
if UserStore.shared.plannedWorkouts.isEmpty {
UserStore.shared.fetchPlannedWorkouts()
func maybeRefreshData() {
if userStore.token != nil{
if userStore.plannedWorkouts.isEmpty {
userStore.fetchPlannedWorkouts()
}
if needsUpdating {
@@ -128,6 +131,7 @@ struct AllWorkoutsView: View {
dataStore.fetchAllData(completion: {
DispatchQueue.main.async {
guard let allWorkouts = dataStore.allWorkouts else {
self.isUpdating = false
return
}
self.workouts = allWorkouts.sorted(by: {
@@ -140,22 +144,31 @@ struct AllWorkoutsView: View {
})
}
} else {
isUpdating = false
showLoginView = true
}
}
func authorizeHealthKit() {
let healthKitTypes: Set = [
HKObjectType.quantityType(forIdentifier: .heartRate)!,
HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!,
HKObjectType.quantityType(forIdentifier: .oxygenSaturation)!,
HKObjectType.activitySummaryType(),
HKQuantityType.workoutType()
]
let quantityTypes = [
HKObjectType.quantityType(forIdentifier: .heartRate),
HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
HKObjectType.quantityType(forIdentifier: .oxygenSaturation)
].compactMap { $0 }
let healthKitTypes: Set<HKObjectType> = Set(
quantityTypes + [
HKObjectType.activitySummaryType(),
HKQuantityType.workoutType()
]
)
healthStore.requestAuthorization(toShare: nil, read: healthKitTypes) { (succ, error) in
if !succ {
// fatalError("Error requesting authorization from health store: \(String(describing: error)))")
healthStore.requestAuthorization(toShare: nil, read: healthKitTypes) { (success, error) in
if success == false {
runtimeReporter.recordWarning(
"HealthKit authorization request did not succeed",
metadata: ["error": error?.localizedDescription ?? "unknown"]
)
}
}
}

View File

@@ -7,6 +7,7 @@
import SwiftUI
import HealthKit
import SharedCore
struct CompletedWorkoutView: View {
@ObservedObject var bridgeModule = BridgeModule.shared
@@ -15,6 +16,9 @@ struct CompletedWorkoutView: View {
@State var notes: String = ""
@State var isUploading: Bool = false
@State var gettingHealthKitData: Bool = false
@State private var hasError = false
@State private var errorMessage = ""
private let runtimeReporter = RuntimeReporter.shared
var postData: [String: Any]
let healthKitHelper = HealthKitHelper()
@@ -60,7 +64,6 @@ struct CompletedWorkoutView: View {
Spacer()
Button("Upload", action: {
isUploading = true
upload(postBody: postData)
})
.frame(maxWidth: .infinity, alignment: .center)
@@ -70,11 +73,14 @@ struct CompletedWorkoutView: View {
.cornerRadius(Constants.buttonRadius)
.padding()
.frame(maxWidth: .infinity)
.disabled(isUploading || gettingHealthKitData)
}
.padding([.leading, .trailing])
}
.onAppear{
bridgeModule.sendWorkoutCompleteToWatch()
.alert("Upload Failed", isPresented: $hasError) {
Button("OK", role: .cancel) {}
} message: {
Text(errorMessage)
}
// .onChange(of: bridgeModule.healthKitUUID, perform: { healthKitUUID in
// if let healthKitUUID = healthKitUUID {
@@ -92,6 +98,11 @@ struct CompletedWorkoutView: View {
}
func upload(postBody: [String: Any]) {
guard isUploading == false else {
return
}
isUploading = true
var _postBody = postBody
_postBody["difficulty"] = difficulty
_postBody["notes"] = notes
@@ -103,6 +114,7 @@ struct CompletedWorkoutView: View {
switch result {
case .success(_):
DispatchQueue.main.async {
self.isUploading = false
bridgeModule.resetCurrentWorkout()
dismiss()
completedWorkoutDismissed?(true)
@@ -110,8 +122,13 @@ struct CompletedWorkoutView: View {
case .failure(let failure):
DispatchQueue.main.async {
self.isUploading = false
self.errorMessage = failure.localizedDescription
self.hasError = true
}
print(failure)
runtimeReporter.recordError(
"Completed workout upload failed",
metadata: ["error": failure.localizedDescription]
)
}
})
}

View File

@@ -10,15 +10,16 @@ import AVFoundation
struct CreateExerciseActionsView: View {
@ObservedObject var workoutExercise: CreateWorkoutExercise
var superset: CreateWorkoutSuperSet
@ObservedObject var superset: CreateWorkoutSuperSet
var viewModel: WorkoutViewModel
@State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!)
@State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null"))
@State private var currentVideoURL: URL?
@State var videoExercise: Exercise? {
didSet {
if let viddd = self.videoExercise?.videoURL,
let url = URL(string: BaseURLs.currentBaseURL + viddd) {
self.avPlayer = AVPlayer(url: url)
updatePlayer(for: url)
}
}
}
@@ -37,6 +38,7 @@ struct CreateExerciseActionsView: View {
}, onDecrement: {
workoutExercise.decreaseReps()
})
.accessibilityLabel("Reps")
}
}
@@ -49,6 +51,7 @@ struct CreateExerciseActionsView: View {
}, onDecrement: {
workoutExercise.decreaseWeight()
})
.accessibilityLabel("Weight")
}
HStack {
@@ -66,6 +69,7 @@ struct CreateExerciseActionsView: View {
}, onDecrement: {
workoutExercise.decreaseDuration()
})
.accessibilityLabel("Duration")
}
HStack {
@@ -80,6 +84,8 @@ struct CreateExerciseActionsView: View {
.background(.blue)
.cornerRadius(Constants.buttonRadius)
.buttonStyle(BorderlessButtonStyle())
.accessibilityLabel("Preview exercise video")
.accessibilityHint("Opens a video preview for this exercise")
Spacer()
@@ -89,7 +95,6 @@ struct CreateExerciseActionsView: View {
superset
.deleteExerciseForChosenSuperset(exercise: workoutExercise)
viewModel.increaseRandomNumberForUpdating()
viewModel.objectWillChange.send()
}) {
Image(systemName: "trash.fill")
}
@@ -98,6 +103,8 @@ struct CreateExerciseActionsView: View {
.background(.red)
.cornerRadius(Constants.buttonRadius)
.buttonStyle(BorderlessButtonStyle())
.accessibilityLabel("Delete exercise")
.accessibilityHint("Removes this exercise from the superset")
Spacer()
}
@@ -109,6 +116,23 @@ struct CreateExerciseActionsView: View {
avPlayer.play()
}
}
.onDisappear {
avPlayer.pause()
}
}
private func updatePlayer(for url: URL) {
if currentVideoURL == url {
avPlayer.seek(to: .zero)
avPlayer.isMuted = true
avPlayer.play()
return
}
currentVideoURL = url
avPlayer = AVPlayer(url: url)
avPlayer.isMuted = true
avPlayer.play()
}
}

View File

@@ -6,6 +6,7 @@
//
import SwiftUI
import SharedCore
class CreateWorkoutExercise: ObservableObject, Identifiable {
let id = UUID()
@@ -49,7 +50,7 @@ class CreateWorkoutExercise: ObservableObject, Identifiable {
}
func decreaseWeight() {
self.weight -= 15
self.weight -= 5
if self.weight < 0 {
self.weight = 0
}
@@ -90,6 +91,8 @@ class WorkoutViewModel: ObservableObject {
@Published var superSets = [CreateWorkoutSuperSet]()
@Published var title = String()
@Published var description = String()
@Published var validationError: String?
@Published var isUploading = false
@Published var randomValueForUpdatingValue = 0
func increaseRandomNumberForUpdating() {
@@ -111,60 +114,82 @@ class WorkoutViewModel: ObservableObject {
}
func showRoundsError() {
validationError = "Each superset must have at least one round."
}
func showNoDurationOrReps() {
validationError = "Each exercise must have reps or duration."
}
func uploadWorkout() {
guard isUploading == false else {
return
}
validationError = nil
let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard normalizedTitle.isEmpty == false else {
validationError = "Workout title is required."
return
}
var supersets = [[String: Any]]()
var supersetOrder = 1
superSets.forEach({ superset in
if superset.numberOfRounds == 0 {
showRoundsError()
return
}
for (supersetOffset, superset) in superSets.enumerated() {
var supersetInfo = [String: Any]()
supersetInfo["name"] = ""
supersetInfo["rounds"] = superset.numberOfRounds
supersetInfo["order"] = supersetOrder
supersetInfo["order"] = supersetOffset + 1
var exercises = [[String: Any]]()
var exerciseOrder = 1
for exercise in superset.exercises {
if exercise.reps == 0 && exercise.duration == 0 {
showNoDurationOrReps()
return
}
for (exerciseOffset, exercise) in superset.exercises.enumerated() {
let item = ["id": exercise.exercise.id,
"reps": exercise.reps,
"weight": exercise.weight,
"duration": exercise.duration,
"order": exerciseOrder] as [String : Any]
"order": exerciseOffset + 1] as [String : Any]
exercises.append(item)
exerciseOrder += 1
}
supersetInfo["exercises"] = exercises
supersets.append(supersetInfo)
supersetOrder += 1
})
let uploadBody = ["name": title,
}
if supersets.isEmpty {
validationError = "Add at least one superset before uploading."
return
}
let issues = WorkoutValidation.validateSupersets(supersets)
if let issue = issues.first {
switch issue.code {
case "invalid_rounds":
showRoundsError()
case "invalid_exercise_payload":
showNoDurationOrReps()
default:
validationError = issue.message
}
return
}
let uploadBody = ["name": normalizedTitle,
"description": description,
"supersets": supersets] as [String : Any]
isUploading = true
CreateWorkoutFetchable(postData: uploadBody).fetch(completion: { result in
DispatchQueue.main.async {
switch result {
case .success(_):
self.superSets.removeAll()
self.title = ""
NotificationCenter.default.post(name: NSNotification.Name("CreatedNewWorkout"), object: nil, userInfo: nil)
case .failure(let failure):
print(failure)
}
self.isUploading = false
switch result {
case .success(_):
self.superSets.removeAll()
self.title = ""
self.description = ""
NotificationCenter.default.post(
name: AppNotifications.createdNewWorkout,
object: nil,
userInfo: nil
)
case .failure(let failure):
self.validationError = "Failed to upload workout: \(failure.localizedDescription)"
}
})
}

View File

@@ -11,6 +11,11 @@ struct CreateWorkoutMainView: View {
@StateObject var viewModel = WorkoutViewModel()
@State var selectedCreateWorkoutSuperSet: CreateWorkoutSuperSet?
@State private var showAddExercise = false
private var canSubmit: Bool {
viewModel.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false &&
viewModel.isUploading == false
}
var body: some View {
VStack {
@@ -28,7 +33,7 @@ struct CreateWorkoutMainView: View {
ScrollViewReader { proxy in
List() {
ForEach($viewModel.superSets, id: \.id) { superset in
ForEach(viewModel.superSets) { superset in
CreateWorkoutSupersetView(
selectedCreateWorkoutSuperSet: $selectedCreateWorkoutSuperSet,
showAddExercise: $showAddExercise,
@@ -38,7 +43,9 @@ struct CreateWorkoutMainView: View {
// after adding new exercise we have to scroll to the bottom
// where the new exercise is sooo keep this so we can scroll
// to id 999
Text("this is the bottom 🤷‍♂️")
Color.clear
.frame(height: 1)
.accessibilityHidden(true)
.id(999)
.listRowSeparator(.hidden)
}
@@ -57,7 +64,7 @@ struct CreateWorkoutMainView: View {
// if left or right auto add the other side
// with a recover in between b/c its
// eaiser to delete a recover than add one
if exercise.side != nil && exercise.side!.count > 0 {
if exercise.side?.isEmpty == false {
let exercises = DataStore.shared.allExercise?.filter({
$0.name == exercise.name
})
@@ -79,7 +86,6 @@ struct CreateWorkoutMainView: View {
}
viewModel.increaseRandomNumberForUpdating()
viewModel.objectWillChange.send()
selectedCreateWorkoutSuperSet = nil
})
}
@@ -95,11 +101,21 @@ struct CreateWorkoutMainView: View {
.cornerRadius(Constants.buttonRadius)
.padding()
.frame(maxWidth: .infinity)
.accessibilityLabel("Add superset")
.accessibilityHint("Adds a new superset section to this workout")
Divider()
Button("Done", action: {
Button(action: {
viewModel.uploadWorkout()
}, label: {
if viewModel.isUploading {
ProgressView()
.progressViewStyle(.circular)
.tint(.white)
} else {
Text("Done")
}
})
.frame(maxWidth: .infinity, alignment: .center)
.frame(height: 44)
@@ -108,13 +124,23 @@ struct CreateWorkoutMainView: View {
.cornerRadius(Constants.buttonRadius)
.padding()
.frame(maxWidth: .infinity)
.disabled(viewModel.title.isEmpty)
.disabled(canSubmit == false)
.accessibilityLabel("Upload workout")
.accessibilityHint("Uploads this workout to your account")
}
.frame(height: 44)
Divider()
}
.background(Color(uiColor: .systemGray5))
.alert("Create Workout", isPresented: Binding<Bool>(
get: { viewModel.validationError != nil },
set: { _ in viewModel.validationError = nil }
)) {
Button("OK", role: .cancel) {}
} message: {
Text(viewModel.validationError ?? "")
}
}
}

View File

@@ -10,8 +10,10 @@ import AVKit
struct ExternalWorkoutDetailView: View {
@StateObject var bridgeModule = BridgeModule.shared
@State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!)
@State var smallAVPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!)
@State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null"))
@State var smallAVPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null"))
@State private var currentVideoURL: URL?
@State private var currentSmallVideoURL: URL?
@AppStorage(Constants.extThotStyle) private var extThotStyle: ThotStyle = .never
@AppStorage(Constants.extShowNextVideo) private var extShowNextVideo: Bool = false
@AppStorage(Constants.thotGenderOption) private var thotGenderOption: String = "female"
@@ -49,8 +51,8 @@ struct ExternalWorkoutDetailView: View {
.frame(width: metrics.size.width * 0.2,
height: metrics.size.height * 0.2)
.onAppear{
avPlayer.isMuted = true
avPlayer.play()
smallAVPlayer.isMuted = true
smallAVPlayer.play()
}
}
}
@@ -105,6 +107,10 @@ struct ExternalWorkoutDetailView: View {
avPlayer.play()
smallAVPlayer.play()
}
.onDisappear {
avPlayer.pause()
smallAVPlayer.pause()
}
}
func playVideos() {
@@ -115,8 +121,7 @@ struct ExternalWorkoutDetailView: View {
defaultVideoURLStr: currentExtercise.exercise.videoURL,
exerciseName: currentExtercise.exercise.name,
workout: bridgeModule.currentWorkoutInfo.workout) {
avPlayer = AVPlayer(url: videoURL)
avPlayer.play()
updateMainPlayer(for: videoURL)
}
if let smallVideoURL = VideoURLCreator.videoURL(
@@ -126,11 +131,34 @@ struct ExternalWorkoutDetailView: View {
exerciseName: BridgeModule.shared.currentWorkoutInfo.nextExerciseInfo?.exercise.name,
workout: bridgeModule.currentWorkoutInfo.workout),
extShowNextVideo {
smallAVPlayer = AVPlayer(url: smallVideoURL)
smallAVPlayer.play()
updateSmallPlayer(for: smallVideoURL)
}
}
}
private func updateMainPlayer(for url: URL) {
if currentVideoURL == url {
avPlayer.seek(to: .zero)
avPlayer.play()
return
}
currentVideoURL = url
avPlayer = AVPlayer(url: url)
avPlayer.play()
}
private func updateSmallPlayer(for url: URL) {
if currentSmallVideoURL == url {
smallAVPlayer.seek(to: .zero)
smallAVPlayer.play()
return
}
currentSmallVideoURL = url
smallAVPlayer = AVPlayer(url: url)
smallAVPlayer.play()
}
}
//struct ExternalWorkoutDetailView_Previews: PreviewProvider {

View File

@@ -12,7 +12,7 @@ struct LoginView: View {
@State var password: String = ""
@Environment(\.dismiss) var dismiss
let completion: (() -> Void)
@State var doingNetworkShit: Bool = false
@State var isLoggingIn: Bool = false
@State var errorTitle = ""
@State var errorMessage = ""
@@ -23,13 +23,17 @@ struct LoginView: View {
VStack {
TextField("Email", text: $email)
.textContentType(.username)
.autocapitalization(.none)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.keyboardType(.emailAddress)
.padding()
.background(Color.white)
.cornerRadius(8)
.padding(.horizontal)
.padding(.top, 25)
.foregroundStyle(.black)
.accessibilityLabel("Email")
.submitLabel(.next)
SecureField("Password", text: $password)
.textContentType(.password)
@@ -37,10 +41,13 @@ struct LoginView: View {
.background(Color.white)
.cornerRadius(8)
.padding(.horizontal)
.autocapitalization(.none)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.foregroundStyle(.black)
.accessibilityLabel("Password")
.submitLabel(.go)
if doingNetworkShit {
if isLoggingIn {
ProgressView("Logging In")
.padding()
.foregroundColor(.white)
@@ -58,6 +65,8 @@ struct LoginView: View {
.padding()
.frame(maxWidth: .infinity)
.disabled(password.isEmpty || email.isEmpty)
.accessibilityLabel("Log in")
.accessibilityHint("Logs in using the entered email and password")
}
Spacer()
@@ -68,11 +77,12 @@ struct LoginView: View {
.resizable()
.edgesIgnoringSafeArea(.all)
.scaledToFill()
.accessibilityHidden(true)
)
.alert(errorTitle, isPresented: $hasError, actions: {
}, message: {
Text(errorMessage)
})
}
@@ -81,9 +91,9 @@ struct LoginView: View {
"email": email,
"password": password
]
doingNetworkShit = true
isLoggingIn = true
UserStore.shared.login(postData: postData, completion: { success in
doingNetworkShit = false
isLoggingIn = false
if success {
completion()
dismiss()
@@ -103,4 +113,3 @@ struct LoginView_Previews: PreviewProvider {
})
}
}

View File

@@ -9,6 +9,8 @@ import SwiftUI
struct PlanWorkoutView: View {
@State var selectedDate = Date()
@State private var hasError = false
@State private var errorMessage = ""
let workout: Workout
@Environment(\.dismiss) var dismiss
var addedPlannedWorkout: (() -> Void)?
@@ -48,6 +50,8 @@ struct PlanWorkoutView: View {
.background(.red)
.cornerRadius(Constants.buttonRadius)
.padding()
.accessibilityLabel("Cancel planning")
.accessibilityHint("Closes this screen without planning a workout")
Button(action: {
planWorkout()
@@ -62,11 +66,18 @@ struct PlanWorkoutView: View {
.background(.yellow)
.cornerRadius(Constants.buttonRadius)
.padding()
.accessibilityLabel("Plan workout")
.accessibilityHint("Adds this workout to your selected date")
}
Spacer()
}
.padding()
.alert("Unable to Plan Workout", isPresented: $hasError) {
Button("OK", role: .cancel) {}
} message: {
Text(errorMessage)
}
}
func planWorkout() {
@@ -78,11 +89,16 @@ struct PlanWorkoutView: View {
PlanWorkoutFetchable(postData: postData).fetch(completion: { result in
switch result {
case .success(_):
UserStore.shared.fetchPlannedWorkouts()
dismiss()
addedPlannedWorkout?()
case .failure(_):
fatalError("shit broke")
DispatchQueue.main.async {
UserStore.shared.fetchPlannedWorkouts()
dismiss()
addedPlannedWorkout?()
}
case .failure(let failure):
DispatchQueue.main.async {
errorMessage = failure.localizedDescription
hasError = true
}
}
})
}

View File

@@ -11,7 +11,8 @@ import AVKit
struct ExerciseListView: View {
@AppStorage(Constants.phoneThotStyle) private var phoneThotStyle: ThotStyle = .never
@ObservedObject var bridgeModule = BridgeModule.shared
@State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!)
@State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null"))
@State private var previewVideoURL: URL?
var workout: Workout
@Binding var showExecersizeInfo: Bool
@AppStorage(Constants.thotGenderOption) private var thotGenderOption: String = "female"
@@ -24,15 +25,14 @@ struct ExerciseListView: View {
defaultVideoURLStr: self.videoExercise?.videoURL,
exerciseName: self.videoExercise?.name,
workout: bridgeModule.currentWorkoutInfo.workout) {
avPlayer = AVPlayer(url: videoURL)
avPlayer.isMuted = true
avPlayer.play()
updatePreviewPlayer(for: videoURL)
}
}
}
var body: some View {
if let supersets = workout.supersets {
let supersets = workout.supersets?.sorted(by: { $0.order < $1.order }) ?? []
if supersets.isEmpty == false {
ScrollViewReader { proxy in
List() {
ForEach(supersets.indices, id: \.self) { supersetIndex in
@@ -40,58 +40,13 @@ struct ExerciseListView: View {
Section(content: {
ForEach(superset.exercises.indices, id: \.self) { exerciseIndex in
let supersetExecercise = superset.exercises[exerciseIndex]
let rowID = rowIdentifier(
supersetIndex: supersetIndex,
exerciseIndex: exerciseIndex,
exercise: supersetExecercise
)
VStack {
HStack {
if bridgeModule.isInWorkout &&
supersetIndex == bridgeModule.currentWorkoutInfo.supersetIndex &&
exerciseIndex == bridgeModule.currentWorkoutInfo.exerciseIndex {
Image(systemName: "figure.run")
.foregroundColor(Color("appColor"))
}
Text(supersetExecercise.exercise.extName)
Spacer()
if let reps = supersetExecercise.reps,
reps > 0 {
HStack {
Image(systemName: "number")
.foregroundColor(.white)
.frame(width: 20, alignment: .leading)
Text("\(reps)")
.foregroundColor(.white)
.frame(width: 30, alignment: .trailing)
}
.padding([.top, .bottom], 5)
.padding([.leading], 10)
.padding([.trailing], 15)
.background(.blue)
.cornerRadius(5, corners: [.topLeft, .bottomLeft])
.frame(alignment: .trailing)
}
if let duration = supersetExecercise.duration,
duration > 0 {
HStack {
Image(systemName: "stopwatch")
.foregroundColor(.white)
.frame(width: 20, alignment: .leading)
Text("\(duration)")
.foregroundColor(.white)
.frame(width: 30, alignment: .trailing)
}
.padding([.top, .bottom], 5)
.padding([.leading], 10)
.padding([.trailing], 15)
.background(.green)
.cornerRadius(5, corners: [.topLeft, .bottomLeft])
}
}
.padding(.trailing, -20)
.contentShape(Rectangle())
.onTapGesture {
Button(action: {
if bridgeModule.isInWorkout {
bridgeModule.currentWorkoutInfo.goToExerciseAt(
supersetIndex: supersetIndex,
@@ -99,7 +54,61 @@ struct ExerciseListView: View {
} else {
videoExercise = supersetExecercise.exercise
}
}
}, label: {
HStack {
if bridgeModule.isInWorkout &&
supersetIndex == bridgeModule.currentWorkoutInfo.supersetIndex &&
exerciseIndex == bridgeModule.currentWorkoutInfo.exerciseIndex {
Image(systemName: "figure.run")
.foregroundColor(Color("appColor"))
}
Text(supersetExecercise.exercise.extName)
Spacer()
if let reps = supersetExecercise.reps,
reps > 0 {
HStack {
Image(systemName: "number")
.foregroundColor(.white)
.frame(width: 20, alignment: .leading)
Text("\(reps)")
.foregroundColor(.white)
.frame(width: 30, alignment: .trailing)
}
.padding([.top, .bottom], 5)
.padding([.leading], 10)
.padding([.trailing], 15)
.background(.blue)
.cornerRadius(5, corners: [.topLeft, .bottomLeft])
.frame(alignment: .trailing)
}
if let duration = supersetExecercise.duration,
duration > 0 {
HStack {
Image(systemName: "stopwatch")
.foregroundColor(.white)
.frame(width: 20, alignment: .leading)
Text("\(duration)")
.foregroundColor(.white)
.frame(width: 30, alignment: .trailing)
}
.padding([.top, .bottom], 5)
.padding([.leading], 10)
.padding([.trailing], 15)
.background(.green)
.cornerRadius(5, corners: [.topLeft, .bottomLeft])
}
}
.padding(.trailing, -20)
.contentShape(Rectangle())
})
.buttonStyle(.plain)
.accessibilityLabel("Exercise \(supersetExecercise.exercise.extName)")
.accessibilityHint(bridgeModule.isInWorkout ? "Jump to this exercise in the workout" : "Preview exercise video")
if bridgeModule.isInWorkout &&
supersetIndex == bridgeModule.currentWorkoutInfo.supersetIndex &&
@@ -107,7 +116,7 @@ struct ExerciseListView: View {
showExecersizeInfo {
detailView(forExercise: supersetExecercise)
}
}.id(supersetExecercise.id)
}.id(rowID)
}
}, header: {
HStack {
@@ -131,7 +140,16 @@ struct ExerciseListView: View {
.onChange(of: bridgeModule.currentWorkoutInfo.allSupersetExecerciseIndex, perform: { newValue in
if let newCurrentExercise = bridgeModule.currentWorkoutInfo.currentExercise {
withAnimation {
proxy.scrollTo(newCurrentExercise.id, anchor: .top)
let currentSupersetIndex = bridgeModule.currentWorkoutInfo.supersetIndex
let currentExerciseIndex = bridgeModule.currentWorkoutInfo.exerciseIndex
proxy.scrollTo(
rowIdentifier(
supersetIndex: currentSupersetIndex,
exerciseIndex: currentExerciseIndex,
exercise: newCurrentExercise
),
anchor: .top
)
}
}
})
@@ -142,9 +160,36 @@ struct ExerciseListView: View {
avPlayer.play()
}
}
.onDisappear {
avPlayer.pause()
}
}
}
}
private func rowIdentifier(supersetIndex: Int, exerciseIndex: Int, exercise: SupersetExercise) -> String {
if let uniqueID = exercise.uniqueID, uniqueID.isEmpty == false {
return uniqueID
}
if let id = exercise.id {
return "exercise-\(id)"
}
return "superset-\(supersetIndex)-exercise-\(exerciseIndex)"
}
private func updatePreviewPlayer(for url: URL) {
if previewVideoURL == url {
avPlayer.seek(to: .zero)
avPlayer.isMuted = true
avPlayer.play()
return
}
previewVideoURL = url
avPlayer = AVPlayer(url: url)
avPlayer.isMuted = true
avPlayer.play()
}
func detailView(forExercise supersetExecercise: SupersetExercise) -> some View {
VStack {

View File

@@ -10,7 +10,8 @@ import AVKit
struct WorkoutDetailView: View {
@StateObject var viewModel: WorkoutDetailViewModel
@State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!)
@State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null"))
@State private var currentVideoURL: URL?
@StateObject var bridgeModule = BridgeModule.shared
@Environment(\.dismiss) var dismiss
@@ -31,6 +32,16 @@ struct WorkoutDetailView: View {
switch viewModel.status {
case .loading:
Text("Loading")
case .failed(let errorMessage):
VStack(spacing: 16) {
Text("Unable to load workout")
.font(.headline)
Text(errorMessage)
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding()
case .showWorkout(let workout):
VStack(spacing: 0) {
if bridgeModule.isInWorkout {
@@ -59,9 +70,7 @@ struct WorkoutDetailView: View {
defaultVideoURLStr: currentExtercise.exercise.videoURL,
exerciseName: currentExtercise.exercise.name,
workout: bridgeModule.currentWorkoutInfo.workout) {
avPlayer = AVPlayer(url: otherVideoURL)
avPlayer.isMuted = true
avPlayer.play()
updatePlayer(for: otherVideoURL)
}
}, label: {
Image(systemName: "arrow.triangle.2.circlepath.camera.fill")
@@ -72,6 +81,8 @@ struct WorkoutDetailView: View {
.cornerRadius(Constants.buttonRadius)
.frame(width: 160, height: 120)
.position(x: metrics.size.width - 22, y: metrics.size.height - 30)
.accessibilityLabel("Switch video style")
.accessibilityHint("Toggles between alternate and default exercise videos")
Button(action: {
showExecersizeInfo.toggle()
@@ -84,6 +95,8 @@ struct WorkoutDetailView: View {
.cornerRadius(Constants.buttonRadius)
.frame(width: 120, height: 120)
.position(x: 22, y: metrics.size.height - 30)
.accessibilityLabel(showExecersizeInfo ? "Hide exercise info" : "Show exercise info")
.accessibilityHint("Shows exercise description and target muscles")
}
}
.padding([.top, .bottom])
@@ -109,7 +122,7 @@ struct WorkoutDetailView: View {
CurrentWorkoutElapsedTimeView()
.frame(maxWidth: .infinity, alignment: .center)
Text("\(bridgeModule.currentWorkoutInfo.allSupersetExecerciseIndex+1)/\(bridgeModule.currentWorkoutInfo.workout?.allSupersetExecercise?.count ?? -99)")
Text(progressText)
.font(.title3)
.bold()
.frame(maxWidth: .infinity, alignment: .trailing)
@@ -163,18 +176,7 @@ struct WorkoutDetailView: View {
playVideos()
})
.onAppear{
if let currentExtercise = bridgeModule.currentWorkoutInfo.currentExercise {
if let videoURL = VideoURLCreator.videoURL(
thotStyle: phoneThotStyle,
gender: thotGenderOption,
defaultVideoURLStr: currentExtercise.exercise.videoURL,
exerciseName: currentExtercise.exercise.name,
workout: bridgeModule.currentWorkoutInfo.workout) {
avPlayer = AVPlayer(url: videoURL)
avPlayer.isMuted = true
avPlayer.play()
}
}
playVideos()
bridgeModule.completedWorkout = {
if let workoutData = createWorkoutData() {
@@ -187,6 +189,10 @@ struct WorkoutDetailView: View {
avPlayer.isMuted = true
avPlayer.play()
}
.onDisappear {
avPlayer.pause()
bridgeModule.completedWorkout = nil
}
}
func playVideos() {
@@ -197,16 +203,38 @@ struct WorkoutDetailView: View {
defaultVideoURLStr: currentExtercise.exercise.videoURL,
exerciseName: currentExtercise.exercise.name,
workout: bridgeModule.currentWorkoutInfo.workout) {
avPlayer = AVPlayer(url: videoURL)
avPlayer.isMuted = true
avPlayer.play()
updatePlayer(for: videoURL)
}
}
}
private func updatePlayer(for url: URL) {
if currentVideoURL == url {
avPlayer.seek(to: .zero)
avPlayer.isMuted = true
avPlayer.play()
return
}
currentVideoURL = url
avPlayer = AVPlayer(url: url)
avPlayer.isMuted = true
avPlayer.play()
}
func startWorkout(workout: Workout) {
bridgeModule.start(workout: workout)
}
private var progressText: String {
let totalExercises = bridgeModule.currentWorkoutInfo.workout?.allSupersetExecercise?.count ?? 0
guard totalExercises > 0 else {
return "0/0"
}
let current = min(totalExercises, max(1, bridgeModule.currentWorkoutInfo.allSupersetExecerciseIndex + 1))
return "\(current)/\(totalExercises)"
}
func createWorkoutData() -> [String:Any]? {
guard let workoutid = bridgeModule.currentWorkoutInfo.workout?.id,

View File

@@ -12,6 +12,7 @@ class WorkoutDetailViewModel: ObservableObject {
enum WorkoutDetailViewModelStatus {
case loading
case showWorkout(Workout)
case failed(String)
}
@Published var status: WorkoutDetailViewModelStatus
@@ -31,7 +32,9 @@ class WorkoutDetailViewModel: ObservableObject {
self.status = .showWorkout(model)
}
case .failure(let failure):
fatalError("failed \(failure.localizedDescription)")
DispatchQueue.main.async {
self.status = .failed("Failed to load workout details: \(failure.localizedDescription)")
}
}
})
}

View File

@@ -42,7 +42,7 @@ struct WorkoutHistoryView: View {
HStack {
VStack {
if let date = completedWorkout.workoutStartTime.dateFromServerDate {
Text(DateFormatter().shortMonthSymbols[date.get(.month) - 1])
Text(date.monthString)
Text("\(date.get(.day))")
Text("\(date.get(.hour))")