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

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>127.0.0.1</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict>
</plist>

View File

@@ -19,6 +19,8 @@
1CF65A9D2A452D290042FFBD /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A9C2A452D290042FFBD /* Preview Assets.xcassets */; };
1CF65AA12A452D290042FFBD /* Werkout_watch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 1CF65A932A452D270042FFBD /* Werkout_watch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1CF65AB62A4532940042FFBD /* WatchMainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65AB52A4532940042FFBD /* WatchMainViewModel.swift */; };
D00100012E00000100000001 /* SharedCore in Frameworks */ = {isa = PBXBuildFile; productRef = D00100012E00000100000004 /* SharedCore */; };
D00100012E00000100000002 /* SharedCore in Frameworks */ = {isa = PBXBuildFile; productRef = D00100012E00000100000004 /* SharedCore */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -108,6 +110,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D00100012E00000100000001 /* SharedCore in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -115,6 +118,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D00100012E00000100000002 /* SharedCore in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -200,6 +204,7 @@
);
name = Werkout_ios;
packageProductDependencies = (
D00100012E00000100000004 /* SharedCore */,
);
productName = Werkout_ios;
productReference = 1CF65A222A3972840042FFBD /* Werkout_ios.app */;
@@ -218,6 +223,9 @@
dependencies = (
);
name = "Werkout_watch Watch App";
packageProductDependencies = (
D00100012E00000100000004 /* SharedCore */,
);
productName = "Werkout_watch Watch App";
productReference = 1CF65A932A452D270042FFBD /* Werkout_watch Watch App.app */;
productType = "com.apple.product-type.application";
@@ -250,6 +258,7 @@
);
mainGroup = 1CF65A192A3972840042FFBD;
packageReferences = (
D00100012E00000100000003 /* XCLocalSwiftPackageReference "../SharedCore" */,
);
productRefGroup = 1CF65A232A3972840042FFBD /* Products */;
projectDirPath = "";
@@ -314,6 +323,13 @@
};
/* End PBXTargetDependency section */
/* Begin XCLocalSwiftPackageReference section */
D00100012E00000100000003 /* XCLocalSwiftPackageReference "../SharedCore" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../SharedCore;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCBuildConfiguration section */
1CF65A342A3972850042FFBD /* Debug */ = {
isa = XCBuildConfiguration;
@@ -442,12 +458,14 @@
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Werkout_ios/Preview Content\"";
DEVELOPMENT_TEAM = V3PF3M6B6U;
ENABLE_APP_INTENTS_METADATA_GENERATION = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Werkout_ios/Resources/Werkout-ios-Info.plist";
INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart reate";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart reate";
EXCLUDED_SOURCE_FILE_NAMES = "Werkout_ios/Resources/Werkout-ios-Info.plist";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Werkout-ios-Info.plist";
INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart rate";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart rate";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -486,12 +504,14 @@
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Werkout_ios/Preview Content\"";
DEVELOPMENT_TEAM = V3PF3M6B6U;
ENABLE_APP_INTENTS_METADATA_GENERATION = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Werkout_ios/Resources/Werkout-ios-Info.plist";
INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart reate";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart reate";
EXCLUDED_SOURCE_FILE_NAMES = "Werkout_ios/Resources/Werkout-ios-Info.plist";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Werkout-ios-Info.plist";
INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart rate";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart rate";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -529,12 +549,13 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Werkout_watch Watch App/Preview Content\"";
DEVELOPMENT_TEAM = V3PF3M6B6U;
ENABLE_APP_INTENTS_METADATA_GENERATION = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Werkout-watch-Watch-App-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Werkout;
INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart reate";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart reate";
INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart rate";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart rate";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "com.t-t.Werkout-ios";
LD_RUNPATH_SEARCH_PATHS = (
@@ -564,12 +585,13 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Werkout_watch Watch App/Preview Content\"";
DEVELOPMENT_TEAM = V3PF3M6B6U;
ENABLE_APP_INTENTS_METADATA_GENERATION = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Werkout-watch-Watch-App-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Werkout;
INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart reate";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart reate";
INFOPLIST_KEY_NSHealthShareUsageDescription = "Read your heart rate";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Read your heart rate";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "com.t-t.Werkout-ios";
LD_RUNPATH_SEARCH_PATHS = (
@@ -591,6 +613,14 @@
};
/* End XCBuildConfiguration section */
/* Begin XCSwiftPackageProductDependency section */
D00100012E00000100000004 /* SharedCore */ = {
isa = XCSwiftPackageProductDependency;
package = D00100012E00000100000003 /* XCLocalSwiftPackageReference "../SharedCore" */;
productName = SharedCore;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCConfigurationList section */
1CF65A1D2A3972840042FFBD /* Build configuration list for PBXProject "Werkout_ios" */ = {
isa = XCConfigurationList;

View File

@@ -79,8 +79,9 @@ struct Exercise: Identifiable, Codable, Equatable, Hashable {
}
var extName: String {
if side != nil && side!.count > 0 {
var returnString = name + " - " + side!
if let side = side,
side.isEmpty == false {
var returnString = name + " - " + side
returnString = returnString.replacingOccurrences(of: "_", with: " ")
return returnString.capitalized
}

View File

@@ -19,11 +19,15 @@ struct PlannedWorkout: Codable {
case onDate = "on_date"
case workout
}
private static let plannedDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
var date: Date? {
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd"
df.locale = Locale(identifier: "en_US_POSIX")
return df.date(from: self.onDate)
Self.plannedDateFormatter.date(from: onDate)
}
}

View File

@@ -31,6 +31,37 @@ struct Workout: Codable, Identifiable, Equatable {
case estimatedTime = "estimated_time"
case allSupersetExecercise = "all_superset_exercise"
}
private static let createdAtFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssX"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
init(id: Int,
name: String,
description: String? = nil,
supersets: [Superset]? = nil,
registeredUser: RegisteredUser? = nil,
muscles: [String]? = nil,
equipment: [String]? = nil,
exercise_count: Int? = nil,
createdAt: Date? = nil,
estimatedTime: Double? = nil,
allSupersetExecercise: [SupersetExercise]? = nil) {
self.id = id
self.name = name
self.description = description
self.supersets = supersets
self.registeredUser = registeredUser
self.muscles = muscles
self.equipment = equipment
self.exercise_count = exercise_count
self.createdAt = createdAt
self.estimatedTime = estimatedTime
self.allSupersetExecercise = allSupersetExecercise
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
@@ -46,15 +77,7 @@ struct Workout: Codable, Identifiable, Equatable {
self.exercise_count = try container.decodeIfPresent(Int.self, forKey: .exercise_count)
let createdAtStr = try container.decodeIfPresent(String.self, forKey: .createdAt)
if let createdAtStr = createdAtStr {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
let date = formatter.date(from: createdAtStr)
self.createdAt = date
} else {
self.createdAt = nil
}
self.createdAt = createdAtStr.flatMap { Self.createdAtFormatter.date(from: $0) }
self.estimatedTime = try container.decodeIfPresent(Double.self, forKey: .estimatedTime)

View File

@@ -7,10 +7,12 @@
import AVKit
import AVFoundation
import SharedCore
class AudioEngine {
static let shared = AudioEngine()
private init() { }
private let runtimeReporter = RuntimeReporter.shared
var audioPlayer: AVAudioPlayer?
var avPlayer: AVPlayer?
@@ -24,10 +26,11 @@ class AudioEngine {
options: [.mixWithOthers])
try AVAudioSession.sharedInstance().setActive(true)
avPlayer?.pause()
avPlayer = AVPlayer(playerItem: playerItem)
avPlayer?.play()
} catch {
print("ERROR")
runtimeReporter.recordError("Failed playing remote audio", metadata: ["error": error.localizedDescription])
}
#endif
}
@@ -41,10 +44,11 @@ class AudioEngine {
options: [.mixWithOthers])
try AVAudioSession.sharedInstance().setActive(true)
audioPlayer?.stop()
audioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
audioPlayer?.play()
} catch {
print("ERROR")
runtimeReporter.recordError("Failed playing short beep", metadata: ["error": error.localizedDescription])
}
}
#endif
@@ -59,10 +63,11 @@ class AudioEngine {
options: [.mixWithOthers])
try AVAudioSession.sharedInstance().setActive(true)
audioPlayer?.stop()
audioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
audioPlayer?.play()
} catch {
print("ERROR")
runtimeReporter.recordError("Failed playing long beep", metadata: ["error": error.localizedDescription])
}
}
#endif

View File

@@ -11,10 +11,12 @@ extension BridgeModule {
func startWorkoutTimer() {
currentWorkoutRunTimer?.invalidate()
currentWorkoutRunTimer = nil
currentWorkoutRunTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { timer in
currentWorkoutRunTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self] _ in
guard let self else { return }
self.currentWorkoutRunTimeInSeconds += 1
self.sendCurrentExerciseToWatch()
})
currentWorkoutRunTimer?.tolerance = 0.1
currentWorkoutRunTimer?.fire()
}
@@ -28,6 +30,7 @@ extension BridgeModule {
selector: #selector(self.updateCurrentExerciseTimer),
userInfo: nil,
repeats: true)
self.currentExerciseTimer?.tolerance = 0.1
self.currentExerciseTimer?.fire()
}
}

View File

@@ -9,27 +9,63 @@ import Foundation
import WatchConnectivity
import AVFoundation
import HealthKit
import os
import SharedCore
private let watchBridgeLogger = Logger(subsystem: "com.werkout.ios", category: "watch-bridge")
extension BridgeModule: WCSessionDelegate {
private func send<Action: Encodable>(action: Action) {
do {
let data = try JSONEncoder().encode(action)
send(data)
} catch {
watchBridgeLogger.error("Failed to encode watch action: \(error.localizedDescription, privacy: .public)")
}
}
private func sendInExerciseAction(_ model: WatchPackageModel) {
do {
let action = PhoneToWatchActions.inExercise(model)
let payload = try JSONEncoder().encode(action)
guard payload != lastSentInExercisePayload else {
return
}
lastSentInExercisePayload = payload
send(payload)
} catch {
watchBridgeLogger.error("Failed to encode in-exercise watch action: \(error.localizedDescription, privacy: .public)")
}
}
private func flushQueuedWatchMessages() {
let queuedMessages = queuedWatchMessages.dequeueAll()
guard queuedMessages.isEmpty == false else {
return
}
queuedMessages.forEach { send($0) }
}
private func enqueueWatchMessage(_ data: Data) {
let droppedCount = queuedWatchMessages.enqueue(data)
if droppedCount > 0 {
watchBridgeLogger.warning("Dropping oldest queued watch message to enforce queue cap")
}
}
func sendResetToWatch() {
let watchModel = PhoneToWatchActions.reset
let data = try! JSONEncoder().encode(watchModel)
send(data)
// self.session.transferUserInfo(["package": data])
lastSentInExercisePayload = nil
send(action: PhoneToWatchActions.reset)
}
func sendStartWorkoutToWatch() {
let model = PhoneToWatchActions.startWorkout
let data = try! JSONEncoder().encode(model)
send(data)
// self.session.transferUserInfo(["package": data])
lastSentInExercisePayload = nil
send(action: PhoneToWatchActions.startWorkout)
}
func sendWorkoutCompleteToWatch() {
let model = PhoneToWatchActions.endWorkout
let data = try! JSONEncoder().encode(model)
send(data)
// self.session.transferUserInfo(["package": data])
lastSentInExercisePayload = nil
send(action: PhoneToWatchActions.endWorkout)
}
func sendCurrentExerciseToWatch() {
@@ -40,9 +76,7 @@ extension BridgeModule: WCSessionDelegate {
currentExerciseID: currentExercise.id ?? -1,
currentTimeLeft: currentExerciseTimeLeft,
workoutStartDate: workoutStartDate ?? Date())
let model = PhoneToWatchActions.inExercise(watchModel)
let data = try! JSONEncoder().encode(model)
send(data)
sendInExerciseAction(watchModel)
} else {
if let currentExercise = currentWorkoutInfo.currentExercise,
let reps = currentExercise.reps,
@@ -51,33 +85,38 @@ extension BridgeModule: WCSessionDelegate {
// if not a timer we need to set the watch display with number of reps
// if timer it will set when timer updates
let watchModel = WatchPackageModel(currentExerciseName: currentExercise.exercise.name, currentExerciseID: currentExercise.id ?? -1, currentTimeLeft: reps, workoutStartDate: self.workoutStartDate ?? Date())
let model = PhoneToWatchActions.inExercise(watchModel)
let data = try! JSONEncoder().encode(model)
self.send(data)
self.sendInExerciseAction(watchModel)
}
}
}
func session(_ session: WCSession, didReceiveMessageData messageData: Data) {
if let model = try? JSONDecoder().decode(WatchActions.self, from: messageData) {
switch model {
case .nextExercise:
nextExercise()
AudioEngine.shared.playFinished()
case .workoutComplete(let data):
DispatchQueue.main.async {
let model = try! JSONDecoder().decode(WatchFinishWorkoutModel.self, from: data)
self.healthKitUUID = model.healthKitUUID
do {
let model = try WatchPayloadValidation.decode(WatchActions.self, from: messageData)
DispatchQueue.main.async {
switch model {
case .nextExercise:
self.nextExercise()
AudioEngine.shared.playFinished()
case .workoutComplete(let data):
do {
let finishModel = try WatchPayloadValidation.decode(WatchFinishWorkoutModel.self, from: data)
self.healthKitUUID = finishModel.healthKitUUID
} catch {
watchBridgeLogger.error("Rejected watch completion payload: \(error.localizedDescription, privacy: .public)")
}
case .restartExercise:
self.restartExercise()
case .previousExercise:
self.previousExercise()
case .stopWorkout:
self.completeWorkout()
case .pauseWorkout:
self.pauseWorkout()
}
case .restartExercise:
restartExercise()
case .previousExercise:
previousExercise()
case .stopWorkout:
completeWorkout()
case .pauseWorkout:
pauseWorkout()
}
} catch {
watchBridgeLogger.error("Rejected WatchActions payload: \(error.localizedDescription, privacy: .public)")
}
}
@@ -85,25 +124,30 @@ extension BridgeModule: WCSessionDelegate {
func session(_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?) {
switch activationState {
case .notActivated:
print("notActivated")
case .inactive:
print("inactive")
case .activated:
print("activated")
DispatchQueue.main.async {
switch activationState {
case .notActivated:
watchBridgeLogger.info("Watch session notActivated")
case .inactive:
watchBridgeLogger.info("Watch session inactive")
case .activated:
watchBridgeLogger.info("Watch session activated")
self.flushQueuedWatchMessages()
#if os(iOS)
let workoutConfiguration = HKWorkoutConfiguration()
workoutConfiguration.activityType = .functionalStrengthTraining
workoutConfiguration.locationType = .indoor
if WCSession.isSupported(), session.activationState == .activated, session.isWatchAppInstalled {
HKHealthStore().startWatchApp(with: workoutConfiguration, completion: { (success, error) in
print(error.debugDescription)
})
}
let workoutConfiguration = HKWorkoutConfiguration()
workoutConfiguration.activityType = .functionalStrengthTraining
workoutConfiguration.locationType = .indoor
if WCSession.isSupported(), session.activationState == .activated, session.isWatchAppInstalled {
HKHealthStore().startWatchApp(with: workoutConfiguration, completion: { (success, error) in
if let error = error {
watchBridgeLogger.error("Failed to start watch app: \(error.localizedDescription, privacy: .public)")
}
})
}
#endif
@unknown default:
print("default")
@unknown default:
watchBridgeLogger.error("Unknown WCSession activation state")
}
}
}
#if os(iOS)
@@ -116,7 +160,13 @@ extension BridgeModule: WCSessionDelegate {
}
#endif
func send(_ data: Data) {
guard WCSession.isSupported() else {
return
}
guard session.activationState == .activated else {
enqueueWatchMessage(data)
session.activate()
return
}
#if os(iOS)
@@ -128,8 +178,15 @@ extension BridgeModule: WCSessionDelegate {
return
}
#endif
session.sendMessageData(data, replyHandler: nil) { error in
print("Cannot send message: \(String(describing: error))")
if session.isReachable {
session.sendMessageData(data, replyHandler: nil) { error in
watchBridgeLogger.error("Cannot send watch message: \(error.localizedDescription, privacy: .public)")
DispatchQueue.main.async {
self.enqueueWatchMessage(data)
}
}
} else {
session.transferUserInfo(["package": data])
}
}
}

View File

@@ -54,6 +54,9 @@ extension BridgeModule {
}
func completeWorkout() {
if isInWorkout {
sendWorkoutCompleteToWatch()
}
self.currentExerciseTimer?.invalidate()
self.currentExerciseTimer = nil
self.isInWorkout = false
@@ -67,10 +70,7 @@ extension BridgeModule {
}
func start(workout: Workout) {
currentWorkoutInfo.complete = {
self.completeWorkout()
}
lastSentInExercisePayload = nil
currentWorkoutInfo.start(workout: workout)
currentWorkoutRunTimeInSeconds = 0
currentWorkoutRunTimer?.invalidate()
@@ -97,9 +97,6 @@ extension BridgeModule {
func resetCurrentWorkout() {
DispatchQueue.main.async {
if self.isInWorkout {
self.sendWorkoutCompleteToWatch()
}
self.currentWorkoutRunTimeInSeconds = 0
self.currentWorkoutRunTimer?.invalidate()
self.currentWorkoutRunTimer = nil
@@ -109,6 +106,7 @@ extension BridgeModule {
self.currentWorkoutRunTimeInSeconds = -1
self.currentWorkoutInfo.reset()
self.lastSentInExercisePayload = nil
self.isInWorkout = false
self.workoutStartDate = nil

View File

@@ -9,6 +9,7 @@ import Foundation
import WatchConnectivity
import AVFoundation
import HealthKit
import SharedCore
enum WatchActions: Codable {
case nextExercise
@@ -54,4 +55,6 @@ class BridgeModule: NSObject, ObservableObject {
@Published var isPaused = false
let session: WCSession = WCSession.default
var queuedWatchMessages = BoundedFIFOQueue<Data>(maxCount: 100)
var lastSentInExercisePayload: Data?
}

View File

@@ -11,7 +11,6 @@ class CurrentWorkoutInfo {
var supersetIndex: Int = 0
var exerciseIndex: Int = -1
var workout: Workout?
var complete: (() -> Void)?
var currentRound = 1
var allSupersetExecerciseIndex = 0
@@ -21,8 +20,8 @@ class CurrentWorkoutInfo {
}
var numberOfRoundsInCurrentSuperSet: Int {
guard let workout = workout else { return -1 }
guard let supersets = workout.supersets else { return -1 }
let supersets = superset
guard supersets.isEmpty == false else { return -1 }
if supersetIndex >= supersets.count {
return -1
@@ -37,14 +36,15 @@ class CurrentWorkoutInfo {
}
var currentExercise: SupersetExercise? {
guard let supersets = workout?.supersets else { return nil }
let supersets = superset
guard supersets.isEmpty == false else { return nil }
if supersetIndex >= supersets.count { return nil }
let superset = supersets[supersetIndex]
// will be -1 for a moment while going to previous workout / superset
if exerciseIndex < 0 { return nil }
if exerciseIndex > superset.exercises.count { return nil }
if exerciseIndex >= superset.exercises.count { return nil }
let exercise = superset.exercises[exerciseIndex]
return exercise
}
@@ -67,8 +67,8 @@ class CurrentWorkoutInfo {
// this needs to set stuff for iphone
var goToNextExercise: SupersetExercise? {
guard let workout = workout else { return nil }
guard let supersets = workout.supersets else { return nil }
let supersets = superset
guard supersets.isEmpty == false else { return nil }
exerciseIndex += 1
let currentSuperSet = supersets[supersetIndex]
@@ -95,8 +95,8 @@ class CurrentWorkoutInfo {
}
var previousExercise: SupersetExercise? {
guard let workout = workout else { return nil }
guard let supersets = workout.supersets else { return nil }
let supersets = superset
guard supersets.isEmpty == false else { return nil }
exerciseIndex -= 1
if exerciseIndex < 0 {

View File

@@ -7,6 +7,7 @@
import Foundation
import SwiftUI
import SharedCore
class DataStore: ObservableObject {
enum DataStoreStatus {
@@ -15,6 +16,7 @@ class DataStore: ObservableObject {
}
static let shared = DataStore()
private let runtimeReporter = RuntimeReporter.shared
public private(set) var allWorkouts: [Workout]?
public private(set) var allMuscles: [Muscle]?
@@ -23,8 +25,7 @@ class DataStore: ObservableObject {
public private(set) var allNSFWVideos: [NSFWVideo]?
@Published public private(set) var status = DataStoreStatus.idle
private let fetchAllDataQueue = DispatchGroup()
private var pendingFetchCompletions = [() -> Void]()
public func randomVideoFor(gender: String) -> String? {
return allNSFWVideos?.filter({
@@ -52,7 +53,15 @@ class DataStore: ObservableObject {
}
public func fetchAllData(completion: @escaping (() -> Void)) {
if status == .loading {
pendingFetchCompletions.append(completion)
runtimeReporter.recordInfo("fetchAllData called while already loading")
return
}
pendingFetchCompletions = [completion]
status = .loading
let fetchAllDataQueue = DispatchGroup()
fetchAllDataQueue.enter()
fetchAllDataQueue.enter()
@@ -62,7 +71,9 @@ class DataStore: ObservableObject {
fetchAllDataQueue.notify(queue: .main) {
self.status = .idle
completion()
let completions = self.pendingFetchCompletions
self.pendingFetchCompletions.removeAll()
completions.forEach { $0() }
}
AllWorkoutFetchable().fetch(completion: { result in
@@ -70,9 +81,9 @@ class DataStore: ObservableObject {
case .success(let model):
self.allWorkouts = model
case .failure(let error):
print(error)
self.runtimeReporter.recordError("Failed to fetch workouts", metadata: ["error": error.localizedDescription])
}
self.fetchAllDataQueue.leave()
fetchAllDataQueue.leave()
})
AllMusclesFetchable().fetch(completion: { result in
@@ -82,9 +93,9 @@ class DataStore: ObservableObject {
$0.name < $1.name
})
case .failure(let error):
print(error)
self.runtimeReporter.recordError("Failed to fetch muscles", metadata: ["error": error.localizedDescription])
}
self.fetchAllDataQueue.leave()
fetchAllDataQueue.leave()
})
AllEquipmentFetchable().fetch(completion: { result in
@@ -94,9 +105,9 @@ class DataStore: ObservableObject {
$0.name < $1.name
})
case .failure(let error):
print(error)
self.runtimeReporter.recordError("Failed to fetch equipment", metadata: ["error": error.localizedDescription])
}
self.fetchAllDataQueue.leave()
fetchAllDataQueue.leave()
})
AllExerciseFetchable().fetch(completion: { result in
@@ -106,9 +117,9 @@ class DataStore: ObservableObject {
$0.name < $1.name
})
case .failure(let error):
print(error)
self.runtimeReporter.recordError("Failed to fetch exercises", metadata: ["error": error.localizedDescription])
}
self.fetchAllDataQueue.leave()
fetchAllDataQueue.leave()
})
AllNSFWVideosFetchable().fetch(completion: { result in
@@ -116,9 +127,9 @@ class DataStore: ObservableObject {
case .success(let model):
self.allNSFWVideos = model
case .failure(let error):
print(error)
self.runtimeReporter.recordError("Failed to fetch NSFW videos", metadata: ["error": error.localizedDescription])
}
self.fetchAllDataQueue.leave()
fetchAllDataQueue.leave()
})
}

View File

@@ -9,6 +9,67 @@ import Foundation
import UIKit
import SwiftUI
private enum DateFormatterCache {
static let lock = NSLock()
static let serverDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssX"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
static let plannedDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
static let weekDayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "EEE"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
static let monthFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MMM"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
static let dayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "d"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
static func withLock<T>(_ block: () -> T) -> T {
lock.lock()
defer { lock.unlock() }
return block()
}
}
private enum DurationFormatterCache {
static let lock = NSLock()
static let formatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second, .nanosecond]
return formatter
}()
static func string(from seconds: Double, style: DateComponentsFormatter.UnitsStyle) -> String {
lock.lock()
defer { lock.unlock() }
formatter.unitsStyle = style
return formatter.string(from: seconds) ?? ""
}
}
extension Dictionary {
func percentEncoded() -> Data? {
map { key, value in
@@ -34,25 +95,23 @@ extension CharacterSet {
extension Date {
var timeFormatForUpload: String {
let isoFormatter = DateFormatter()
isoFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssX"
return isoFormatter.string(from: self)
DateFormatterCache.withLock {
DateFormatterCache.serverDateFormatter.string(from: self)
}
}
}
extension String {
var dateFromServerDate: Date? {
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd'T'HH:mm:ssX"
df.locale = Locale(identifier: "en_US_POSIX")
return df.date(from: self)
DateFormatterCache.withLock {
DateFormatterCache.serverDateFormatter.date(from: self)
}
}
var plannedDate: Date? {
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd"
df.locale = Locale(identifier: "en_US_POSIX")
return df.date(from: self)
DateFormatterCache.withLock {
DateFormatterCache.plannedDateFormatter.date(from: self)
}
}
}
@@ -66,31 +125,27 @@ extension Date {
}
var formatForPlannedWorkout: String {
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd"
df.locale = Locale(identifier: "en_US_POSIX")
return df.string(from: self)
DateFormatterCache.withLock {
DateFormatterCache.plannedDateFormatter.string(from: self)
}
}
var weekDay: String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "EEE"
let weekDay = dateFormatter.string(from: self)
return weekDay
DateFormatterCache.withLock {
DateFormatterCache.weekDayFormatter.string(from: self)
}
}
var monthString: String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MMM"
let weekDay = dateFormatter.string(from: self)
return weekDay
DateFormatterCache.withLock {
DateFormatterCache.monthFormatter.string(from: self)
}
}
var dateString: String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "d"
let weekDay = dateFormatter.string(from: self)
return weekDay
DateFormatterCache.withLock {
DateFormatterCache.dayFormatter.string(from: self)
}
}
}
@@ -116,10 +171,7 @@ extension Double {
10000.asString(style: .brief) // 2hr 46min 40sec
*/
func asString(style: DateComponentsFormatter.UnitsStyle) -> String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second, .nanosecond]
formatter.unitsStyle = style
return formatter.string(from: self) ?? ""
DurationFormatterCache.string(from: self, style: style)
}
}

View File

@@ -7,6 +7,7 @@
import Foundation
import HealthKit
import SharedCore
struct HealthKitWorkoutData {
var caloriesBurned: Double?
@@ -16,111 +17,155 @@ struct HealthKitWorkoutData {
}
class HealthKitHelper {
// this is dirty and i dont care
var returnCount = 0
private let runtimeReporter = RuntimeReporter.shared
let healthStore = HKHealthStore()
var healthKitWorkoutData = HealthKitWorkoutData(
caloriesBurned: nil,
minHeartRate: nil,
maxHeartRate: nil,
avgHeartRate: nil)
var completion: ((HealthKitWorkoutData?) -> Void)?
func getDetails(forHealthKitUUID uuid: UUID, completion: @escaping ((HealthKitWorkoutData?) -> Void)) {
self.completion = completion
self.returnCount = 0
print("get details \(uuid.uuidString)")
runtimeReporter.recordInfo("Fetching HealthKit workout details", metadata: ["uuid": uuid.uuidString])
let query = HKSampleQuery(sampleType: HKWorkoutType.workoutType(),
predicate: HKQuery.predicateForObject(with: uuid),
limit: HKObjectQueryNoLimit,
limit: 1,
sortDescriptors: nil)
{ (sampleQuery, results, error ) -> Void in
if let queryError = error {
self.shitReturned()
self.shitReturned()
print( "There was an error while reading the samples: \(queryError.localizedDescription)")
self.completion?(nil)
} else {
for samples: HKSample in results! {
let workout: HKWorkout = (samples as! HKWorkout)
self.getTotalBurned(forWorkout: workout)
self.getHeartRateStuff(forWorkout: workout)
print("got workout")
{ [weak self] (_, results, error) -> Void in
guard let self else {
DispatchQueue.main.async {
completion(nil)
}
return
}
if let queryError = error {
self.runtimeReporter.recordError(
"Failed querying HealthKit workout",
metadata: ["error": queryError.localizedDescription]
)
DispatchQueue.main.async {
completion(nil)
}
return
}
guard let workout = results?.compactMap({ $0 as? HKWorkout }).first else {
self.runtimeReporter.recordWarning("No HealthKit workout found for UUID", metadata: ["uuid": uuid.uuidString])
DispatchQueue.main.async {
completion(nil)
}
return
}
self.collectDetails(forWorkout: workout, completion: completion)
}
healthStore.execute(query)
}
func getHeartRateStuff(forWorkout workout: HKWorkout) {
print("get heart")
let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate)
let heartPredicate: NSPredicate? = HKQuery.predicateForSamples(withStart: workout.startDate,
end: workout.endDate,
options: HKQueryOptions.strictEndDate)
let heartQuery = HKStatisticsQuery(quantityType: heartType!,
private func collectDetails(forWorkout workout: HKWorkout, completion: @escaping ((HealthKitWorkoutData?) -> Void)) {
let aggregateQueue = DispatchQueue(label: "com.werkout.healthkit.aggregate")
var workoutData = HealthKitWorkoutData(
caloriesBurned: nil,
minHeartRate: nil,
maxHeartRate: nil,
avgHeartRate: nil
)
let group = DispatchGroup()
group.enter()
getTotalBurned(forWorkout: workout) { calories in
aggregateQueue.async {
workoutData.caloriesBurned = calories
group.leave()
}
}
group.enter()
getHeartRateStuff(forWorkout: workout) { heartRateData in
aggregateQueue.async {
workoutData.minHeartRate = heartRateData?.minHeartRate
workoutData.maxHeartRate = heartRateData?.maxHeartRate
workoutData.avgHeartRate = heartRateData?.avgHeartRate
group.leave()
}
}
group.notify(queue: .main) {
let hasValues = workoutData.caloriesBurned != nil ||
workoutData.minHeartRate != nil ||
workoutData.maxHeartRate != nil ||
workoutData.avgHeartRate != nil
completion(hasValues ? workoutData : nil)
}
}
private func getHeartRateStuff(forWorkout workout: HKWorkout, completion: @escaping ((HealthKitWorkoutData?) -> Void)) {
guard let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) else {
completion(nil)
return
}
let heartPredicate = HKQuery.predicateForSamples(withStart: workout.startDate,
end: workout.endDate,
options: HKQueryOptions.strictEndDate)
let heartQuery = HKStatisticsQuery(quantityType: heartType,
quantitySamplePredicate: heartPredicate,
options: [.discreteAverage, .discreteMin, .discreteMax],
completionHandler: {(query: HKStatisticsQuery, result: HKStatistics?, error: Error?) -> Void in
if let result = result,
let minValue = result.minimumQuantity(),
let maxValue = result.maximumQuantity(),
let avgValue = result.averageQuantity() {
let _minHeartRate = minValue.doubleValue(
for: HKUnit(from: "count/min")
completionHandler: { [weak self] (_, result, error) -> Void in
if let error {
self?.runtimeReporter.recordError(
"Failed querying HealthKit heart rate stats",
metadata: ["error": error.localizedDescription]
)
let _maxHeartRate = maxValue.doubleValue(
for: HKUnit(from: "count/min")
)
let _avgHeartRate = avgValue.doubleValue(
for: HKUnit(from: "count/min")
)
self.healthKitWorkoutData.avgHeartRate = _avgHeartRate
self.healthKitWorkoutData.minHeartRate = _minHeartRate
self.healthKitWorkoutData.maxHeartRate = _maxHeartRate
print("got heart")
completion(nil)
return
}
self.shitReturned()
guard let result,
let minValue = result.minimumQuantity(),
let maxValue = result.maximumQuantity(),
let avgValue = result.averageQuantity() else {
completion(nil)
return
}
let unit = HKUnit(from: "count/min")
let data = HealthKitWorkoutData(
caloriesBurned: nil,
minHeartRate: minValue.doubleValue(for: unit),
maxHeartRate: maxValue.doubleValue(for: unit),
avgHeartRate: avgValue.doubleValue(for: unit)
)
completion(data)
})
healthStore.execute(heartQuery)
}
func getTotalBurned(forWorkout workout: HKWorkout) {
print("get total burned")
let calType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)
let calPredicate: NSPredicate? = HKQuery.predicateForSamples(withStart: workout.startDate,
end: workout.endDate,
options: HKQueryOptions.strictEndDate)
let calQuery = HKStatisticsQuery(quantityType: calType!,
private func getTotalBurned(forWorkout workout: HKWorkout, completion: @escaping ((Double?) -> Void)) {
guard let calType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) else {
completion(nil)
return
}
let calPredicate = HKQuery.predicateForSamples(withStart: workout.startDate,
end: workout.endDate,
options: HKQueryOptions.strictEndDate)
let calQuery = HKStatisticsQuery(quantityType: calType,
quantitySamplePredicate: calPredicate,
options: [.cumulativeSum],
completionHandler: {(query: HKStatisticsQuery, result: HKStatistics?, error: Error?) -> Void in
if let result = result {
self.healthKitWorkoutData.caloriesBurned = result.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie()) ?? -1
print("got total burned")
completionHandler: { [weak self] (_, result, error) -> Void in
if let error {
self?.runtimeReporter.recordError(
"Failed querying HealthKit calories",
metadata: ["error": error.localizedDescription]
)
completion(nil)
return
}
self.shitReturned()
completion(result?.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie()))
})
healthStore.execute(calQuery)
}
func shitReturned() {
DispatchQueue.main.async {
self.returnCount += 1
print("\(self.returnCount)")
if self.returnCount == 2 {
self.completion?(self.healthKitWorkoutData)
}
}
}
}

View File

@@ -8,100 +8,56 @@
import Foundation
class PreviewData {
private class func decodeFromBundle<T: Decodable>(_ fileName: String,
as type: T.Type) -> T? {
guard let filepath = Bundle.main.path(forResource: fileName, ofType: "json"),
let data = try? Data(NSData(contentsOfFile: filepath)) else {
return nil
}
return try? JSONDecoder().decode(T.self, from: data)
}
class func workout() -> Workout {
let filepath = Bundle.main.path(forResource: "WorkoutDetail", ofType: "json")!
let data = try! Data(NSData(contentsOfFile: filepath))
let workout = try! JSONDecoder().decode(Workout.self, from: data)
return workout
if let workout = decodeFromBundle("WorkoutDetail", as: Workout.self) {
return workout
}
if let firstWorkout = allWorkouts().first {
return firstWorkout
}
return Workout(id: -1, name: "Unavailable")
}
class func allWorkouts() -> [Workout] {
if let filepath = Bundle.main.path(forResource: "AllWorkouts", ofType: "json") {
do {
let data = try Data(NSData(contentsOfFile: filepath))
let workout = try JSONDecoder().decode([Workout].self, from: data)
return workout
} catch {
print(error)
fatalError()
}
} else {
fatalError()
}
decodeFromBundle("AllWorkouts", as: [Workout].self) ?? []
}
class func parseExercises() -> [Exercise] {
if let filepath = Bundle.main.path(forResource: "Exercises", ofType: "json") {
do {
let data = try Data(NSData(contentsOfFile: filepath))
let exercises = try JSONDecoder().decode([Exercise].self, from: data)
return exercises
} catch {
print(error)
fatalError()
}
} else {
fatalError()
}
decodeFromBundle("Exercises", as: [Exercise].self) ?? []
}
class func parseEquipment() -> [Equipment] {
if let filepath = Bundle.main.path(forResource: "Equipment", ofType: "json") {
do {
let data = try Data(NSData(contentsOfFile: filepath))
let equipment = try JSONDecoder().decode([Equipment].self, from: data)
return equipment
} catch {
print(error)
fatalError()
}
} else {
fatalError()
}
decodeFromBundle("Equipment", as: [Equipment].self) ?? []
}
class func parseMuscle() -> [Muscle] {
if let filepath = Bundle.main.path(forResource: "AllMuscles", ofType: "json") {
do {
let data = try Data(NSData(contentsOfFile: filepath))
let muscles = try JSONDecoder().decode([Muscle].self, from: data)
return muscles
} catch {
print(error)
fatalError()
}
} else {
fatalError()
}
decodeFromBundle("AllMuscles", as: [Muscle].self) ?? []
}
class func parseRegisterdUser() -> RegisteredUser {
if let filepath = Bundle.main.path(forResource: "RegisteredUser", ofType: "json") {
do {
let data = try Data(NSData(contentsOfFile: filepath))
let muscles = try JSONDecoder().decode(RegisteredUser.self, from: data)
return muscles
} catch {
print(error)
fatalError()
}
} else {
fatalError()
}
decodeFromBundle("RegisteredUser", as: RegisteredUser.self) ??
RegisteredUser(id: -1,
firstName: nil,
lastName: nil,
image: nil,
nickName: nil,
token: nil,
email: nil,
hasNSFWToggle: nil)
}
class func parseCompletedWorkouts() -> [CompletedWorkout] {
if let filepath = Bundle.main.path(forResource: "CompletedWorkouts", ofType: "json") {
do {
let data = try Data(NSData(contentsOfFile: filepath))
let muscles = try JSONDecoder().decode([CompletedWorkout].self, from: data)
return muscles
} catch {
print(error)
fatalError()
}
} else {
fatalError()
}
decodeFromBundle("CompletedWorkouts", as: [CompletedWorkout].self) ?? []
}
}

View File

@@ -7,5 +7,5 @@
"last_name": "test1_last",
"image": "",
"nick_name": "NickkkkName",
"token": "8f10a5b8c7532f7f8602193767b46a2625a85c52"
"token": "REDACTED_TOKEN"
}

View File

@@ -6,6 +6,10 @@
//
import Foundation
import SharedCore
private let runtimeReporter = RuntimeReporter.shared
private let requestTimeout: TimeInterval = 30
enum FetchableError: Error {
case apiError(Error)
@@ -17,6 +21,27 @@ enum FetchableError: Error {
case statusError(Int, String?)
}
extension FetchableError: LocalizedError {
var errorDescription: String? {
switch self {
case .apiError(let error):
return "API error: \(error.localizedDescription)"
case .noData:
return "No response data was returned."
case .decodeError(let error):
return "Failed to decode response: \(error.localizedDescription)"
case .endOfFileError:
return "Unexpected end of file while parsing response."
case .noPostData:
return "Missing POST payload."
case .noToken:
return "Authentication token is missing or expired."
case .statusError(let statusCode, _):
return "Request failed with status code \(statusCode)."
}
}
}
protocol Fetchable {
associatedtype Response: Codable
var attachToken: Bool { get }
@@ -34,105 +59,175 @@ extension Fetchable {
var baseURL: String {
BaseURLs.currentBaseURL
}
var attachToken: Bool {
return true
true
}
func fetch(completion: @escaping (Result<Response, FetchableError>) -> Void) {
let url = URL(string: baseURL+endPoint)!
var request = URLRequest(url: url,timeoutInterval: Double.infinity)
guard let url = URL(string: baseURL + endPoint) else {
completeOnMain(completion, with: .failure(.noData))
return
}
var request = URLRequest(url: url, timeoutInterval: requestTimeout)
if attachToken {
guard let token = UserStore.shared.token else {
completion(.failure(.noPostData))
runtimeReporter.recordWarning("Missing auth token", metadata: ["method": "GET", "endpoint": endPoint])
completeOnMain(completion, with: .failure(.noToken))
return
}
request.addValue(token, forHTTPHeaderField: "Authorization")
}
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let task = URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in
if let error = error {
completion(.failure(.apiError(error)))
let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
if let error {
runtimeReporter.recordError(
"GET request failed",
metadata: ["url": url.absoluteString, "error": error.localizedDescription]
)
completeOnMain(completion, with: .failure(.apiError(error)))
return
}
guard let data = data else {
completion(.failure(.noData))
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
let responseBody = data.flatMap { String(data: $0, encoding: .utf8) }
handleHTTPFailure(statusCode: httpResponse.statusCode,
responseBody: responseBody,
endpoint: endPoint,
method: "GET")
completeOnMain(completion, with: .failure(.statusError(httpResponse.statusCode, responseBody)))
return
}
guard let data else {
runtimeReporter.recordError("GET request returned no data", metadata: ["url": url.absoluteString])
completeOnMain(completion, with: .failure(.noData))
return
}
do {
let model = try JSONDecoder().decode(Response.self, from: data)
completion(.success(model))
return
completeOnMain(completion, with: .success(model))
} catch {
completion(.failure(.decodeError(error)))
return
runtimeReporter.recordError(
"Failed decoding GET response",
metadata: ["url": url.absoluteString, "error": error.localizedDescription]
)
completeOnMain(completion, with: .failure(.decodeError(error)))
}
})
task.resume()
}
}
extension Postable {
func fetch(completion: @escaping (Result<Response, FetchableError>) -> Void) {
guard let postableData = postableData else {
completion(.failure(.noPostData))
func fetch(completion: @escaping (Result<Response, FetchableError>) -> Void) {
guard let postableData else {
completeOnMain(completion, with: .failure(.noPostData))
return
}
let url = URL(string: baseURL+endPoint)!
let postData = try! JSONSerialization.data(withJSONObject:postableData)
var request = URLRequest(url: url,timeoutInterval: Double.infinity)
if attachToken {
guard let token = UserStore.shared.token else {
completion(.failure(.noPostData))
return
}
request.addValue(token, forHTTPHeaderField: "Authorization")
}
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST"
request.httpBody = postData
guard let url = URL(string: baseURL + endPoint) else {
completeOnMain(completion, with: .failure(.noData))
return
}
let postData: Data
do {
postData = try JSONSerialization.data(withJSONObject: postableData)
} catch {
runtimeReporter.recordError(
"Failed encoding POST payload",
metadata: ["url": url.absoluteString, "error": error.localizedDescription]
)
completeOnMain(completion, with: .failure(.apiError(error)))
return
}
var request = URLRequest(url: url, timeoutInterval: requestTimeout)
if attachToken {
guard let token = UserStore.shared.token else {
runtimeReporter.recordWarning("Missing auth token", metadata: ["method": "POST", "endpoint": endPoint])
completeOnMain(completion, with: .failure(.noToken))
return
}
request.addValue(token, forHTTPHeaderField: "Authorization")
}
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST"
request.httpBody = postData
let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
if let error = error {
completion(.failure(.apiError(error)))
if let error {
runtimeReporter.recordError(
"POST request failed",
metadata: ["url": url.absoluteString, "error": error.localizedDescription]
)
completeOnMain(completion, with: .failure(.apiError(error)))
return
}
if let httpRespone = response as? HTTPURLResponse {
if httpRespone.statusCode != successStatus {
var returnStr: String?
if let data = data {
returnStr = String(data: data, encoding: .utf8)
}
completion(.failure(.statusError(httpRespone.statusCode, returnStr)))
return
}
}
guard let data = data else {
completion(.failure(.noData))
if let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode != successStatus {
let responseBody = data.flatMap { String(data: $0, encoding: .utf8) }
handleHTTPFailure(statusCode: httpResponse.statusCode,
responseBody: responseBody,
endpoint: endPoint,
method: "POST")
completeOnMain(completion, with: .failure(.statusError(httpResponse.statusCode, responseBody)))
return
}
guard let data else {
runtimeReporter.recordError("POST request returned no data", metadata: ["url": url.absoluteString])
completeOnMain(completion, with: .failure(.noData))
return
}
do {
let model = try JSONDecoder().decode(Response.self, from: data)
completion(.success(model))
return
completeOnMain(completion, with: .success(model))
} catch {
completion(.failure(.decodeError(error)))
return
runtimeReporter.recordError(
"Failed decoding POST response",
metadata: ["url": url.absoluteString, "error": error.localizedDescription]
)
completeOnMain(completion, with: .failure(.decodeError(error)))
}
})
task.resume()
}
}
private func handleHTTPFailure(statusCode: Int, responseBody: String?, endpoint: String, method: String) {
runtimeReporter.recordError(
"HTTP request failed",
metadata: [
"method": method,
"endpoint": endpoint,
"status_code": "\(statusCode)",
"has_body": responseBody == nil ? "false" : "true"
]
)
UserStore.shared.handleUnauthorizedResponse(statusCode: statusCode, responseBody: responseBody)
}
private func completeOnMain<Response>(
_ completion: @escaping (Result<Response, FetchableError>) -> Void,
with result: Result<Response, FetchableError>
) {
if Thread.isMainThread {
completion(result)
return
}
DispatchQueue.main.async {
completion(result)
}
}

View File

@@ -6,9 +6,11 @@
//
import CoreData
import SharedCore
struct PersistenceController {
static let shared = PersistenceController()
private static let runtimeReporter = RuntimeReporter.shared
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
@@ -20,10 +22,14 @@ struct PersistenceController {
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
runtimeReporter.recordError(
"Failed to save preview context",
metadata: [
"code": "\(nsError.code)",
"domain": nsError.domain
]
)
}
return result
}()
@@ -33,22 +39,17 @@ struct PersistenceController {
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "Werkout_ios")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
Self.runtimeReporter.recordError(
"Failed to load persistent store",
metadata: [
"code": "\(error.code)",
"domain": error.domain
]
)
}
})
container.viewContext.automaticallyMergesChangesFromParent = true

View File

@@ -6,8 +6,19 @@
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>127.0.0.1</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>UIBackgroundModes</key>
<array>

View File

@@ -6,99 +6,198 @@
//
import Foundation
import SharedCore
class UserStore: ObservableObject {
static let userNameKeychainValue = "username"
static let passwordKeychainValue = "password"
static let tokenKeychainValue = "auth_token"
static let userDefaultsRegisteredUserKey = "registeredUserKey"
private static let userDefaultsTokenExpirationKey = "registeredUserTokenExpiration"
static let shared = UserStore()
private let runtimeReporter = RuntimeReporter.shared
private let authRefreshQueue = DispatchQueue(label: "com.werkout.auth.refresh")
private var lastTokenRefreshAttempt = Date.distantPast
private let tokenRotationWindow: TimeInterval = 30 * 60
private let tokenRefreshThrottle: TimeInterval = 5 * 60
@Published public private(set) var registeredUser: RegisteredUser?
var plannedWorkouts = [PlannedWorkout]()
@Published public private(set) var plannedWorkouts = [PlannedWorkout]()
init(registeredUser: RegisteredUser? = nil) {
self.registeredUser = registeredUser
if let data = UserDefaults.standard.data(forKey: UserStore.userDefaultsRegisteredUserKey),
let model = try? JSONDecoder().decode(RegisteredUser.self, from: data) {
self.registeredUser = model
}
self.registeredUser = registeredUser ?? loadPersistedUser()
}
public var token: String? {
guard let token = registeredUser?.token else {
guard let rawToken = normalizedToken(from: registeredUser?.token) else {
return nil
}
return "Token \(token)"
if isTokenExpired(rawToken) {
runtimeReporter.recordWarning("Auth token expired before request", metadata: ["action": "force_logout"])
DispatchQueue.main.async {
self.logout(reason: "token_expired")
}
return nil
}
maybeRefreshTokenIfNearExpiry(rawToken)
return "Token \(rawToken)"
}
func handleUnauthorizedResponse(statusCode: Int, responseBody: String?) {
guard statusCode == 401 || statusCode == 403 else {
return
}
runtimeReporter.recordError(
"Unauthorized response from server",
metadata: [
"status_code": "\(statusCode)",
"has_body": responseBody == nil ? "false" : "true"
]
)
DispatchQueue.main.async {
self.logout(reason: "unauthorized_\(statusCode)")
}
}
private func loadPersistedUser() -> RegisteredUser? {
guard let data = UserDefaults.standard.data(forKey: UserStore.userDefaultsRegisteredUserKey),
let model = try? JSONDecoder().decode(RegisteredUser.self, from: data) else {
return nil
}
if let keychainToken = loadTokenFromKeychain() {
if isTokenExpired(keychainToken) {
runtimeReporter.recordWarning("Persisted token is expired", metadata: ["source": "keychain"])
clearPersistedTokenOnly()
return userByReplacingToken(model, token: nil)
}
persistTokenExpirationMetadata(token: keychainToken)
return userByReplacingToken(model, token: keychainToken)
}
if let legacyToken = normalizedToken(from: model.token) {
migrateLegacyTokenToKeychain(legacyToken)
if isTokenExpired(legacyToken) {
runtimeReporter.recordWarning("Persisted token is expired", metadata: ["source": "legacy_defaults"])
clearPersistedTokenOnly()
return userByReplacingToken(model, token: nil)
}
persistSanitizedModel(userByReplacingToken(model, token: nil))
persistTokenExpirationMetadata(token: legacyToken)
return userByReplacingToken(model, token: legacyToken)
}
persistTokenExpirationMetadata(token: nil)
return userByReplacingToken(model, token: nil)
}
private func persistRegisteredUser(_ model: RegisteredUser) {
let sanitizedToken = normalizedToken(from: model.token)
persistSanitizedModel(userByReplacingToken(model, token: nil))
if let sanitizedToken,
let tokenData = sanitizedToken.data(using: .utf8) {
do {
try KeychainInterface.save(password: tokenData, account: UserStore.tokenKeychainValue)
} catch KeychainInterface.KeychainError.duplicateItem {
try? KeychainInterface.update(password: tokenData, account: UserStore.tokenKeychainValue)
} catch {
runtimeReporter.recordError(
"Failed saving token in keychain",
metadata: ["error": error.localizedDescription]
)
}
} else {
try? KeychainInterface.deletePassword(account: UserStore.tokenKeychainValue)
}
persistTokenExpirationMetadata(token: sanitizedToken)
}
private func persistSanitizedModel(_ model: RegisteredUser) {
if let data = try? JSONEncoder().encode(model) {
UserDefaults.standard.set(data, forKey: UserStore.userDefaultsRegisteredUserKey)
}
}
func login(postData: [String: Any], completion: @escaping (Bool)-> Void) {
LoginFetchable(postData: postData).fetch(completion: { result in
switch result {
case .success(let model):
if let email = postData["email"] as? String,
let password = postData["password"] as? String,
let data = password.data(using: .utf8) {
try? KeychainInterface.save(password: data,
account: email)
}
let sanitizedModel = self.userByReplacingToken(model, token: self.normalizedToken(from: model.token))
DispatchQueue.main.async {
self.registeredUser = model
let data = try! JSONEncoder().encode(model)
UserDefaults.standard.set(data, forKey: UserStore.userDefaultsRegisteredUserKey)
self.registeredUser = sanitizedModel
self.persistRegisteredUser(sanitizedModel)
completion(true)
}
case .failure(let failure):
completion(false)
case .failure(let error):
self.runtimeReporter.recordError("Login failed", metadata: ["error": error.localizedDescription])
DispatchQueue.main.async {
completion(false)
}
}
})
}
public func refreshUserData() {
RefreshUserInfoFetcable().fetch(completion: { result in
switch result {
case .success(let registeredUser):
let sanitizedModel = self.userByReplacingToken(registeredUser, token: self.normalizedToken(from: registeredUser.token))
DispatchQueue.main.async {
if let data = try? JSONEncoder().encode(registeredUser) {
UserDefaults.standard.set(data, forKey: UserStore.userDefaultsRegisteredUserKey)
}
if let data = UserDefaults.standard.data(forKey: UserStore.userDefaultsRegisteredUserKey),
let model = try? JSONDecoder().decode(RegisteredUser.self, from: data) {
self.registeredUser = model
}
self.persistRegisteredUser(sanitizedModel)
self.registeredUser = sanitizedModel
}
case .failure(let failure):
fatalError()
self.runtimeReporter.recordError("Failed refreshing user", metadata: ["error": failure.localizedDescription])
}
})
}
func logout() {
logout(reason: "manual_logout")
}
private func logout(reason: String) {
let email = registeredUser?.email
self.registeredUser = nil
UserDefaults.standard.set(nil, forKey: UserStore.userDefaultsRegisteredUserKey)
UserDefaults.standard.removeObject(forKey: UserStore.userDefaultsRegisteredUserKey)
persistTokenExpirationMetadata(token: nil)
try? KeychainInterface.deletePassword(account: UserStore.tokenKeychainValue)
if let email, email.isEmpty == false {
try? KeychainInterface.deletePassword(account: email)
}
plannedWorkouts.removeAll()
runtimeReporter.recordInfo("User logged out", metadata: ["reason": reason])
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("CreatedNewWorkout"), object: nil, userInfo: nil)
NotificationCenter.default.post(name: AppNotifications.createdNewWorkout, object: nil, userInfo: nil)
}
}
func setFakeUser() {
self.registeredUser = PreviewData.parseRegisterdUser()
}
func fetchPlannedWorkouts() {
PlannedWorkoutFetchable().fetch(completion: { result in
switch result {
case .success(let models):
self.plannedWorkouts = models
case .failure(let failure):
UserStore.shared.logout()
// fatalError("shit broke")
self.runtimeReporter.recordError("Failed fetching planned workouts", metadata: ["error": failure.localizedDescription])
}
})
}
func plannedWorkoutFor(date: Date) -> PlannedWorkout? {
for plannedWorkout in plannedWorkouts {
if let plannedworkoutDate = plannedWorkout.date {
@@ -109,8 +208,90 @@ class UserStore: ObservableObject {
}
return nil
}
func setTreyDevRegisterdUser() {
self.registeredUser = RegisteredUser(id: 1, firstName: "t", lastName: "t", image: nil, nickName: "t", token: "15d7565cde9e8c904ae934f8235f68f6a24b4a03", email: nil, hasNSFWToggle: nil)
self.registeredUser = RegisteredUser(id: 1,
firstName: "t",
lastName: "t",
image: nil,
nickName: "t",
token: nil,
email: nil,
hasNSFWToggle: nil)
}
private func migrateLegacyTokenToKeychain(_ token: String) {
guard let tokenData = token.data(using: .utf8) else {
return
}
do {
try KeychainInterface.save(password: tokenData, account: UserStore.tokenKeychainValue)
} catch KeychainInterface.KeychainError.duplicateItem {
try? KeychainInterface.update(password: tokenData, account: UserStore.tokenKeychainValue)
} catch {
runtimeReporter.recordError("Failed migrating legacy token", metadata: ["error": error.localizedDescription])
}
}
private func loadTokenFromKeychain() -> String? {
guard let tokenData = try? KeychainInterface.readPassword(account: UserStore.tokenKeychainValue),
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return normalizedToken(from: token)
}
private func normalizedToken(from rawToken: String?) -> String? {
TokenSecurity.sanitizeToken(rawToken)
}
private func persistTokenExpirationMetadata(token: String?) {
guard let token,
let expirationDate = TokenSecurity.jwtExpiration(token) else {
UserDefaults.standard.removeObject(forKey: UserStore.userDefaultsTokenExpirationKey)
return
}
UserDefaults.standard.set(expirationDate.timeIntervalSince1970, forKey: UserStore.userDefaultsTokenExpirationKey)
}
private func clearPersistedTokenOnly() {
try? KeychainInterface.deletePassword(account: UserStore.tokenKeychainValue)
persistTokenExpirationMetadata(token: nil)
}
private func maybeRefreshTokenIfNearExpiry(_ token: String) {
guard TokenSecurity.shouldRotate(token, rotationWindow: tokenRotationWindow) else {
return
}
authRefreshQueue.async {
let throttleCutoff = Date().addingTimeInterval(-self.tokenRefreshThrottle)
guard self.lastTokenRefreshAttempt <= throttleCutoff else {
return
}
self.lastTokenRefreshAttempt = Date()
self.runtimeReporter.recordInfo("Token nearing expiry; refreshing user")
DispatchQueue.main.async {
self.refreshUserData()
}
}
}
private func isTokenExpired(_ token: String) -> Bool {
TokenSecurity.isExpired(token)
}
private func userByReplacingToken(_ model: RegisteredUser, token: String?) -> RegisteredUser {
RegisteredUser(id: model.id,
firstName: model.firstName,
lastName: model.lastName,
image: model.image,
nickName: model.nickName,
token: token,
email: model.email,
hasNSFWToggle: model.hasNSFWToggle)
}
}

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

View File

@@ -8,6 +8,7 @@
import SwiftUI
import Combine
import AVKit
import SharedCore
struct Constants {
@@ -24,21 +25,24 @@ struct Werkout_iosApp: App {
let persistenceController = PersistenceController.shared
@State var additionalWindows: [UIWindow] = []
@State private var tabSelection = 1
@Environment(\.scenePhase) private var scenePhase
let pub = NotificationCenter.default.publisher(for: NSNotification.Name("CreatedNewWorkout"))
let pub = NotificationCenter.default.publisher(for: AppNotifications.createdNewWorkout)
private var screenDidConnectPublisher: AnyPublisher<UIScreen, Never> {
NotificationCenter.default
.publisher(for: UIScreen.didConnectNotification)
.compactMap { $0.object as? UIScreen }
.publisher(for: UIScene.willConnectNotification)
.compactMap { ($0.object as? UIWindowScene)?.screen }
.filter { $0 != UIScreen.main }
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
private var screenDidDisconnectPublisher: AnyPublisher<UIScreen, Never> {
NotificationCenter.default
.publisher(for: UIScreen.didDisconnectNotification)
.compactMap { $0.object as? UIScreen }
.publisher(for: UIScene.didDisconnectNotification)
.compactMap { ($0.object as? UIWindowScene)?.screen }
.filter { $0 != UIScreen.main }
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
@@ -74,10 +78,13 @@ struct Werkout_iosApp: App {
}
.accentColor(Color("appColor"))
.onAppear{
UIApplication.shared.isIdleTimerDisabled = true
UIApplication.shared.isIdleTimerDisabled = scenePhase == .active
_ = try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
// UserStore.shared.logout()
}
.onChange(of: scenePhase) { phase in
UIApplication.shared.isIdleTimerDisabled = phase == .active
}
.onReceive(pub) { (output) in
self.tabSelection = 1
}

View File

@@ -32,6 +32,7 @@ struct ActionsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.red)
.foregroundColor(.white)
.accessibilityLabel("Close workout")
if showAddToCalendar {
Button(action: {
@@ -44,6 +45,7 @@ struct ActionsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.blue)
.foregroundColor(.white)
.accessibilityLabel("Plan workout")
}
Button(action: {
@@ -56,6 +58,7 @@ struct ActionsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.green)
.foregroundColor(.white)
.accessibilityLabel("Start workout")
} else {
Button(action: {
showCompleteSheet.toggle()
@@ -67,6 +70,7 @@ struct ActionsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.blue)
.foregroundColor(.white)
.accessibilityLabel("Complete workout")
Button(action: {
AudioEngine.shared.playFinished()
@@ -84,6 +88,7 @@ struct ActionsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(bridgeModule.isPaused ? .mint : .yellow)
.foregroundColor(.white)
.accessibilityLabel(bridgeModule.isPaused ? "Resume workout" : "Pause workout")
Button(action: {
AudioEngine.shared.playFinished()
@@ -96,6 +101,7 @@ struct ActionsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.green)
.foregroundColor(.white)
.accessibilityLabel("Next exercise")
}
}
.alert("Complete Workout", isPresented: $showCompleteSheet) {

View File

@@ -7,7 +7,7 @@
import SwiftUI
struct AddSupersetView: View {
@Binding var createWorkoutSuperSet: CreateWorkoutSuperSet
@ObservedObject var createWorkoutSuperSet: CreateWorkoutSuperSet
var viewModel: WorkoutViewModel
@Binding var selectedCreateWorkoutSuperSet: CreateWorkoutSuperSet?
@@ -18,8 +18,9 @@ struct AddSupersetView: View {
Text(createWorkoutExercise.exercise.name)
.font(.title2)
.frame(maxWidth: .infinity)
if createWorkoutExercise.exercise.side != nil && createWorkoutExercise.exercise.side!.count > 0 {
Text(createWorkoutExercise.exercise.side!)
if let side = createWorkoutExercise.exercise.side,
side.isEmpty == false {
Text(side)
.font(.title3)
.frame(maxWidth: .infinity, alignment: .center)
}

View File

@@ -14,12 +14,13 @@ struct AllExerciseView: View {
@Binding var filteredExercises: [Exercise]
var selectedExercise: ((Exercise) -> Void)
@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)
}
}
}
@@ -38,19 +39,20 @@ struct AllExerciseView: View {
Text(exercise.name)
.frame(maxWidth: .infinity, alignment: .leading)
if exercise.side != nil && !exercise.side!.isEmpty {
Text(exercise.side!)
if let side = exercise.side,
side.isEmpty == false {
Text(side)
.font(.footnote)
.frame(maxWidth: .infinity, alignment: .leading)
}
if exercise.equipmentRequired != nil && !exercise.equipmentRequired!.isEmpty {
if exercise.equipmentRequired?.isEmpty == false {
Text(exercise.spacedEquipmentRequired)
.font(.footnote)
.frame(maxWidth: .infinity, alignment: .leading)
}
if exercise.muscleGroups != nil && !exercise.muscleGroups!.isEmpty {
if exercise.muscleGroups?.isEmpty == false {
Text(exercise.spacedMuscleGroups)
.font(.footnote)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -86,6 +88,23 @@ struct AllExerciseView: 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

@@ -10,6 +10,7 @@ import SwiftUI
struct CompletedWorkoutsView: View {
@State var completedWorkouts: [CompletedWorkout]?
@State var showCompletedWorkouts: Bool = false
@State private var loadError: String?
var body: some View {
VStack(alignment: .leading) {
@@ -41,7 +42,11 @@ struct CompletedWorkoutsView: View {
}
} else {
Text("loading completed workouts")
if let loadError = loadError {
Text(loadError)
} else {
Text("loading completed workouts")
}
}
}
.onAppear{
@@ -58,9 +63,14 @@ struct CompletedWorkoutsView: View {
CompletedWorkoutFetchable().fetch(completion: { result in
switch result {
case .success(let model):
completedWorkouts = model
DispatchQueue.main.async {
completedWorkouts = model
loadError = nil
}
case .failure(let failure):
fatalError(failure.localizedDescription)
DispatchQueue.main.async {
loadError = "Unable to load workout history: \(failure.localizedDescription)"
}
}
})
}

View File

@@ -9,13 +9,13 @@ import SwiftUI
struct CreateWorkoutSupersetView: View {
@Binding var selectedCreateWorkoutSuperSet: CreateWorkoutSuperSet?
@Binding var showAddExercise: Bool
@Binding var superset: CreateWorkoutSuperSet
@ObservedObject var superset: CreateWorkoutSuperSet
@ObservedObject var viewModel: WorkoutViewModel
var body: some View {
Section(content: {
AddSupersetView(
createWorkoutSuperSet: $superset,
createWorkoutSuperSet: superset,
viewModel: viewModel,
selectedCreateWorkoutSuperSet: $selectedCreateWorkoutSuperSet)
}, header: {
@@ -34,23 +34,25 @@ struct CreateWorkoutSupersetView: View {
Spacer()
Button(action: {
selectedCreateWorkoutSuperSet = $superset.wrappedValue
showAddExercise.toggle()
selectedCreateWorkoutSuperSet = superset
showAddExercise = true
}, label: {
Image(systemName: "dumbbell.fill")
.font(.title2)
})
.accessibilityLabel("Add exercise")
.accessibilityHint("Adds an exercise to this superset")
Divider()
Button(action: {
viewModel.delete(superset: $superset.wrappedValue)
//viewModel.increaseRandomNumberForUpdating()
viewModel.objectWillChange.send()
viewModel.delete(superset: superset)
}, label: {
Image(systemName: "trash")
.font(.title2)
})
.accessibilityLabel("Delete superset")
.accessibilityHint("Removes this superset")
}
Divider()
@@ -59,18 +61,14 @@ struct CreateWorkoutSupersetView: View {
HStack {
Text("Rounds: ")
Text("\($superset.wrappedValue.numberOfRounds)")
.foregroundColor($superset.wrappedValue.numberOfRounds > 0 ? Color(uiColor: .label) : .red)
Text("\(superset.numberOfRounds)")
.foregroundColor(superset.numberOfRounds > 0 ? Color(uiColor: .label) : .red)
.bold()
}
}, onIncrement: {
$superset.wrappedValue.increaseNumberOfRounds()
//viewModel.increaseRandomNumberForUpdating()
viewModel.objectWillChange.send()
superset.increaseNumberOfRounds()
}, onDecrement: {
$superset.wrappedValue.decreaseNumberOfRounds()
//viewModel.increaseRandomNumberForUpdating()
viewModel.objectWillChange.send()
superset.decreaseNumberOfRounds()
})
}
}

View File

@@ -13,34 +13,38 @@ struct PlannedWorkoutView: View {
var body: some View {
List {
ForEach(workouts.sorted(by: { $0.onDate < $1.onDate }), id:\.workout.name) { plannedWorkout in
HStack {
VStack(alignment: .leading) {
Text(plannedWorkout.onDate.plannedDate?.weekDay ?? "-")
.font(.title)
ForEach(workouts.sorted(by: { $0.onDate < $1.onDate }), id: \.id) { plannedWorkout in
Button(action: {
selectedPlannedWorkout = plannedWorkout.workout
}, label: {
HStack {
VStack(alignment: .leading) {
Text(plannedWorkout.onDate.plannedDate?.weekDay ?? "-")
.font(.title)
Text(plannedWorkout.onDate.plannedDate?.monthString ?? "-")
.font(.title)
Text(plannedWorkout.onDate.plannedDate?.dateString ?? "-")
.font(.title)
}
Text(plannedWorkout.onDate.plannedDate?.monthString ?? "-")
.font(.title)
Divider()
Text(plannedWorkout.onDate.plannedDate?.dateString ?? "-")
.font(.title)
VStack {
Text(plannedWorkout.workout.name)
.font(.title)
.frame(maxWidth: .infinity, alignment: .leading)
Text(plannedWorkout.workout.description ?? "")
.font(.body)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Divider()
VStack {
Text(plannedWorkout.workout.name)
.font(.title)
.frame(maxWidth: .infinity, alignment: .leading)
Text(plannedWorkout.workout.description ?? "")
.font(.body)
.frame(maxWidth: .infinity, alignment: .leading)
}
.onTapGesture {
selectedPlannedWorkout = plannedWorkout.workout
}
}
})
.buttonStyle(.plain)
.accessibilityLabel("Open planned workout \(plannedWorkout.workout.name)")
.accessibilityHint("Shows workout details")
}
}
}

View File

@@ -21,7 +21,8 @@ class PlayerUIView: UIView {
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
super.init(coder: coder)
self.playerSetup(player: AVPlayer())
}
init(player: AVPlayer) {
@@ -76,11 +77,20 @@ struct PlayerView: UIViewRepresentable {
}
func updateUIView(_ uiView: PlayerUIView, context: UIViewRepresentableContext<PlayerView>) {
if uiView.playerLayer.player !== player {
uiView.playerLayer.player?.pause()
}
uiView.playerLayer.player = player
//Add player observer.
uiView.setObserver()
}
static func dismantleUIView(_ uiView: PlayerUIView, coordinator: ()) {
uiView.playerLayer.player?.pause()
uiView.playerLayer.player = nil
NotificationCenter.default.removeObserver(uiView)
}
}
class VideoURLCreator {

View File

@@ -29,6 +29,8 @@ struct MainWatchView: View {
.lineLimit(10)
.fixedSize(horizontal: false, vertical: true)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(vm.watchPackageModel.currentExerciseName), \(vm.watchPackageModel.currentTimeLeft) seconds remaining")
HStack {
@@ -48,6 +50,8 @@ struct MainWatchView: View {
.foregroundColor(.red)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityElement(children: .combine)
.accessibilityLabel("Heart rate \(heartValue) beats per minute")
}
Button(action: {
@@ -58,10 +62,14 @@ struct MainWatchView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
})
.buttonStyle(BorderedButtonStyle(tint: .green))
.accessibilityLabel("Next exercise")
}
} else {
Text("No Werkout")
Text("🍑")
Text("No active workout")
.font(.headline)
Image(systemName: "figure.strengthtraining.traditional")
.foregroundColor(.secondary)
.accessibilityHidden(true)
}
}
}

View File

@@ -22,6 +22,7 @@ struct WatchControlView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
})
.buttonStyle(BorderedButtonStyle(tint: .red))
.accessibilityLabel("Stop workout")
Button(action: {
vm.restartExercise()
@@ -31,6 +32,7 @@ struct WatchControlView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
})
.buttonStyle(BorderedButtonStyle(tint: .yellow))
.accessibilityLabel("Restart exercise")
Button(action: {
vm.previousExercise()
@@ -40,6 +42,7 @@ struct WatchControlView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
})
.buttonStyle(BorderedButtonStyle(tint: .blue))
.accessibilityLabel("Previous exercise")
}
VStack {
Button(action: {
@@ -56,6 +59,7 @@ struct WatchControlView: View {
}
})
.buttonStyle(BorderedButtonStyle(tint: .blue))
.accessibilityLabel(watchWorkout.isPaused ? "Resume workout" : "Pause workout")
Button(action: {
vm.nextExercise()
@@ -65,6 +69,7 @@ struct WatchControlView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
})
.buttonStyle(BorderedButtonStyle(tint: .green))
.accessibilityLabel("Next exercise")
}
}
}

View File

@@ -7,10 +7,12 @@
import WatchKit
import HealthKit
import os
class WatchDelegate: NSObject, WKApplicationDelegate {
private let logger = Logger(subsystem: "com.werkout.watch", category: "lifecycle")
func applicationDidFinishLaunching() {
autorizeHealthKit()
authorizeHealthKit()
}
func applicationDidBecomeActive() {
@@ -25,17 +27,24 @@ class WatchDelegate: NSObject, WKApplicationDelegate {
// WatchWorkout.shared.startWorkout()
}
func autorizeHealthKit() {
let healthKitTypes: Set = [
HKObjectType.quantityType(forIdentifier: .heartRate)!,
HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!,
HKObjectType.quantityType(forIdentifier: .oxygenSaturation)!,
func authorizeHealthKit() {
guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate),
let activeEnergyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
let oxygenSaturationType = HKObjectType.quantityType(forIdentifier: .oxygenSaturation) else {
logger.error("Missing required HealthKit quantity types during authorization")
return
}
let healthKitTypes: Set<HKObjectType> = [
heartRateType,
activeEnergyType,
oxygenSaturationType,
HKObjectType.activitySummaryType(),
HKQuantityType.workoutType()
]
HKHealthStore().requestAuthorization(toShare: nil, read: healthKitTypes) { (succ, error) in
if !succ {
fatalError("Error requesting authorization from health store: \(String(describing: error)))")
self.logger.error("HealthKit authorization failed: \(String(describing: error), privacy: .public)")
}
}
}

View File

@@ -9,6 +9,8 @@ import Foundation
import WatchConnectivity
import SwiftUI
import HealthKit
import os
import SharedCore
extension WatchMainViewModel: WCSessionDelegate {
func session(_ session: WCSession, didReceiveMessageData messageData: Data) {
@@ -16,27 +18,87 @@ extension WatchMainViewModel: WCSessionDelegate {
}
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
print("activation did complete")
if let error {
logger.error("Watch session activation failed: \(error.localizedDescription, privacy: .public)")
} else {
logger.info("Watch session activation state: \(activationState.rawValue, privacy: .public)")
if activationState == .activated {
DataSender.flushQueuedPayloadsIfPossible(session: session)
}
}
}
}
class DataSender {
private static let logger = Logger(subsystem: "com.werkout.watch", category: "session")
private static let queue = DispatchQueue(label: "com.werkout.watch.sender")
private static var queuedPayloads = BoundedFIFOQueue<Data>(maxCount: 100)
static func send(_ data: Data) {
guard WCSession.default.activationState == .activated else {
if let validationError = WatchPayloadValidation.validate(data) {
logger.error("Dropped invalid watch payload: \(String(describing: validationError), privacy: .public)")
return
}
queue.async {
let session = WCSession.default
guard session.activationState == .activated else {
enqueue(data)
session.activate()
return
}
deliver(data, using: session)
flushQueuedPayloads(using: session)
}
}
static func flushQueuedPayloadsIfPossible(session: WCSession = .default) {
queue.async {
flushQueuedPayloads(using: session)
}
}
private static func flushQueuedPayloads(using session: WCSession) {
guard session.activationState == .activated else {
return
}
guard queuedPayloads.isEmpty == false else {
return
}
let payloads = queuedPayloads.dequeueAll()
payloads.forEach { deliver($0, using: session) }
}
private static func deliver(_ data: Data, using session: WCSession) {
#if os(iOS)
guard WCSession.default.isWatchAppInstalled else {
guard session.isWatchAppInstalled else {
return
}
#else
guard WCSession.default.isCompanionAppInstalled else {
guard session.isCompanionAppInstalled else {
return
}
#endif
WCSession.default.sendMessageData(data, replyHandler: nil)
{ error in
print("Cannot send message: \(String(describing: error))")
if session.isReachable {
session.sendMessageData(data, replyHandler: nil) { error in
logger.error("Cannot send watch message: \(error.localizedDescription, privacy: .public)")
queue.async {
enqueue(data)
}
}
} else {
session.transferUserInfo(["package": data])
}
}
private static func enqueue(_ data: Data) {
let droppedCount = queuedPayloads.enqueue(data)
if droppedCount > 0 {
logger.warning("Dropping oldest queued watch payload to enforce queue cap")
}
}
}

View File

@@ -9,9 +9,12 @@ import Foundation
import WatchConnectivity
import SwiftUI
import HealthKit
import os
import SharedCore
class WatchMainViewModel: NSObject, ObservableObject {
static let shared = WatchMainViewModel()
let logger = Logger(subsystem: "com.werkout.watch", category: "session")
var session: WCSession
@@ -29,46 +32,46 @@ class WatchMainViewModel: NSObject, ObservableObject {
session.activate()
}
private func send(_ action: WatchActions) {
do {
let data = try JSONEncoder().encode(action)
DataSender.send(data)
} catch {
logger.error("Failed to encode watch action: \(error.localizedDescription, privacy: .public)")
}
}
// actions from view
func nextExercise() {
let nextExerciseAction = WatchActions.nextExercise
let data = try! JSONEncoder().encode(nextExerciseAction)
DataSender.send(data)
send(.nextExercise)
WKInterfaceDevice.current().play(.start)
}
func restartExercise() {
let nextExerciseAction = WatchActions.restartExercise
let data = try! JSONEncoder().encode(nextExerciseAction)
DataSender.send(data)
send(.restartExercise)
WKInterfaceDevice.current().play(.start)
}
func previousExercise() {
let nextExerciseAction = WatchActions.previousExercise
let data = try! JSONEncoder().encode(nextExerciseAction)
DataSender.send(data)
send(.previousExercise)
WKInterfaceDevice.current().play(.start)
}
func completeWorkout() {
let nextExerciseAction = WatchActions.stopWorkout
let data = try! JSONEncoder().encode(nextExerciseAction)
DataSender.send(data)
send(.stopWorkout)
WKInterfaceDevice.current().play(.start)
}
func pauseWorkout() {
let nextExerciseAction = WatchActions.pauseWorkout
let data = try! JSONEncoder().encode(nextExerciseAction)
DataSender.send(data)
send(.pauseWorkout)
WKInterfaceDevice.current().play(.start)
WatchWorkout.shared.togglePaused()
}
func dataToAction(messageData: Data) {
if let model = try? JSONDecoder().decode(PhoneToWatchActions.self, from: messageData) {
do {
let model = try WatchPayloadValidation.decode(PhoneToWatchActions.self, from: messageData)
DispatchQueue.main.async {
switch model {
case .inExercise(let newWatchPackageModel):
@@ -87,6 +90,8 @@ class WatchMainViewModel: NSObject, ObservableObject {
WatchWorkout.shared.startWorkout()
}
}
} catch {
logger.error("Rejected PhoneToWatchActions payload: \(error.localizedDescription, privacy: .public)")
}
}
}

View File

@@ -7,13 +7,16 @@
import Foundation
import HealthKit
import os
class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate {
static let shared = WatchWorkout()
let healthStore = HKHealthStore()
var hkWorkoutSession: HKWorkoutSession!
var hkBuilder: HKLiveWorkoutBuilder!
private let logger = Logger(subsystem: "com.werkout.watch", category: "workout")
var hkWorkoutSession: HKWorkoutSession?
var hkBuilder: HKLiveWorkoutBuilder?
var heartRates = [Int]()
private var shouldSendWorkoutDetails = true
@Published var heartValue: Int?
@Published var isInWorkout = false
@@ -21,39 +24,51 @@ class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLive
private override init() {
super.init()
setupCore()
_ = setupCore()
}
func setupCore() {
@discardableResult
func setupCore() -> Bool {
do {
let configuration = HKWorkoutConfiguration()
configuration.activityType = .functionalStrengthTraining
configuration.locationType = .indoor
hkWorkoutSession = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
hkBuilder = hkWorkoutSession.associatedWorkoutBuilder()
hkWorkoutSession.delegate = self
hkBuilder.delegate = self
hkBuilder = hkWorkoutSession?.associatedWorkoutBuilder()
hkWorkoutSession?.delegate = self
hkBuilder?.delegate = self
/// Set the workout builder's data source.
hkBuilder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore,
workoutConfiguration: configuration)
hkBuilder?.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore,
workoutConfiguration: configuration)
return true
} catch {
fatalError()
logger.error("Failed to configure workout session: \(error.localizedDescription, privacy: .public)")
hkWorkoutSession = nil
hkBuilder = nil
return false
}
}
func startWorkout() {
if isInWorkout { return }
guard setupCore(),
let hkWorkoutSession = hkWorkoutSession else {
isInWorkout = false
return
}
isInWorkout = true
setupCore()
shouldSendWorkoutDetails = true
hkWorkoutSession.startActivity(with: Date())
//WKInterfaceDevice.current().play(.start)
}
func stopWorkout(sendDetails: Bool) {
shouldSendWorkoutDetails = sendDetails
// hkWorkoutSession.endCurrentActivity(on: Date())
hkWorkoutSession.end()
hkWorkoutSession?.end()
}
func togglePaused() {
@@ -61,9 +76,19 @@ class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLive
}
func beginDataCollection() {
guard let hkBuilder = hkBuilder else {
DispatchQueue.main.async {
self.isInWorkout = false
}
return
}
hkBuilder.beginCollection(withStart: Date()) { (succ, error) in
if !succ {
fatalError("Error beginning collection from builder: \(String(describing: error)))")
self.logger.error("Error beginning workout collection: \(String(describing: error), privacy: .public)")
DispatchQueue.main.async {
self.isInWorkout = false
}
}
}
DispatchQueue.main.async {
@@ -72,6 +97,15 @@ class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLive
}
func getWorkoutBuilderDetails(completion: @escaping (() -> Void)) {
guard let hkBuilder = hkBuilder else {
DispatchQueue.main.async {
self.heartRates.removeAll()
self.isInWorkout = false
}
completion()
return
}
DispatchQueue.main.async {
self.heartRates.removeAll()
self.isInWorkout = false
@@ -83,19 +117,24 @@ class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLive
return
}
self.hkBuilder.finishWorkout { (workout, error) in
hkBuilder.finishWorkout { (workout, error) in
guard let workout = workout else {
completion()
return
}
// if !sendDetails { return }
DispatchQueue.main.async() {
let watchFinishWorkoutModel = WatchFinishWorkoutModel(healthKitUUID: workout.uuid)
let data = try! JSONEncoder().encode(watchFinishWorkoutModel)
let watchAction = WatchActions.workoutComplete(data)
let watchActionData = try! JSONEncoder().encode(watchAction)
DataSender.send(watchActionData)
if self.shouldSendWorkoutDetails {
do {
let watchFinishWorkoutModel = WatchFinishWorkoutModel(healthKitUUID: workout.uuid)
let data = try JSONEncoder().encode(watchFinishWorkoutModel)
let watchAction = WatchActions.workoutComplete(data)
let watchActionData = try JSONEncoder().encode(watchAction)
DataSender.send(watchActionData)
} catch {
self.logger.error("Failed to send watch completion payload: \(error.localizedDescription, privacy: .public)")
}
}
completion()
}
}
@@ -105,30 +144,30 @@ class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLive
func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
switch toState {
case .notStarted:
print("not started")
logger.info("Workout state notStarted")
case .running:
print("running")
logger.info("Workout state running")
startWorkout()
beginDataCollection()
case .ended:
print("ended")
logger.info("Workout state ended")
getWorkoutBuilderDetails(completion: {
self.setupCore()
})
case .paused:
print("paused")
logger.info("Workout state paused")
case .prepared:
print("prepared")
logger.info("Workout state prepared")
case .stopped:
print("stopped")
logger.info("Workout state stopped")
@unknown default:
fatalError()
logger.error("Unknown workout state: \(toState.rawValue, privacy: .public)")
}
print("[workoutSession] Changed State: \(toState.rawValue)")
logger.info("Workout session changed state: \(toState.rawValue, privacy: .public)")
}
func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
print("[didFailWithError] Workout Builder changed event: \(error.localizedDescription)")
logger.error("Workout session failed: \(error.localizedDescription, privacy: .public)")
// trying to go from ended to something so just end it all
if workoutSession.state == .ended {
getWorkoutBuilderDetails(completion: {
@@ -140,26 +179,30 @@ class WatchWorkout: NSObject, ObservableObject, HKWorkoutSessionDelegate, HKLive
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else {
return
continue
}
switch quantityType {
case HKQuantityType.quantityType(forIdentifier: .heartRate):
DispatchQueue.main.async() {
let statistics = workoutBuilder.statistics(for: quantityType)
guard let statistics = workoutBuilder.statistics(for: quantityType),
let quantity = statistics.mostRecentQuantity() else {
return
}
let heartRateUnit = HKUnit.count().unitDivided(by: HKUnit.minute())
let value = statistics!.mostRecentQuantity()?.doubleValue(for: heartRateUnit)
self.heartValue = Int(Double(round(1 * value!) / 1))
self.heartRates.append(Int(Double(round(1 * value!) / 1)))
print("[workoutBuilder] Heart Rate: \(String(describing: self.heartValue))")
let value = quantity.doubleValue(for: heartRateUnit)
let roundedHeartRate = Int(Double(round(1 * value) / 1))
self.heartValue = roundedHeartRate
self.heartRates.append(roundedHeartRate)
self.logger.debug("Collected heart rate sample: \(roundedHeartRate, privacy: .public)")
}
default:
return
continue
}
}
}
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
guard let workoutEventType = workoutBuilder.workoutEvents.last?.type else { return }
print("[workoutBuilderDidCollectEvent] Workout Builder changed event: \(workoutEventType.rawValue)")
logger.info("Workout builder event: \(workoutEventType.rawValue, privacy: .public)")
}
}