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

21
.github/workflows/apple-platform-ci.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Apple Platform CI
on:
push:
branches: ["**"]
pull_request:
jobs:
smoke-and-tests:
runs-on: macos-15
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Xcode Version
run: xcodebuild -version
- name: Run smoke suite
run: ./scripts/smoke/smoke_all.sh

2
SharedCore/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.build/
.swiftpm/

35
SharedCore/Package.swift Normal file
View File

@@ -0,0 +1,35 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "SharedCore",
platforms: [
.iOS(.v16),
.watchOS(.v9),
.tvOS(.v16),
.macOS(.v13)
],
products: [
.library(
name: "SharedCore",
targets: ["SharedCore"]
)
],
targets: [
.target(
name: "SharedCore"
),
.testTarget(
name: "SharedCoreiOSTests",
dependencies: ["SharedCore"]
),
.testTarget(
name: "SharedCoreWatchOSTests",
dependencies: ["SharedCore"]
),
.testTarget(
name: "SharedCoreTVOSTests",
dependencies: ["SharedCore"]
)
]
)

View File

@@ -0,0 +1,6 @@
import Foundation
public enum AppNotifications {
public static let createdNewWorkout = Notification.Name("CreatedNewWorkout")
}

View File

@@ -0,0 +1,40 @@
import Foundation
public struct BoundedFIFOQueue<Element> {
private var storage: [Element] = []
public let maxCount: Int
public init(maxCount: Int) {
self.maxCount = max(1, maxCount)
}
public var count: Int {
storage.count
}
public var isEmpty: Bool {
storage.isEmpty
}
@discardableResult
public mutating func enqueue(_ element: Element) -> Int {
var droppedCount = 0
if storage.count >= maxCount {
droppedCount = storage.count - maxCount + 1
storage.removeFirst(droppedCount)
}
storage.append(element)
return droppedCount
}
public mutating func dequeueAll() -> [Element] {
let elements = storage
storage.removeAll()
return elements
}
public mutating func clear() {
storage.removeAll()
}
}

View File

@@ -0,0 +1,76 @@
import Foundation
import os
public enum RuntimeSeverity: String, Sendable {
case info
case warning
case error
}
public struct RuntimeEvent: Sendable {
public let severity: RuntimeSeverity
public let message: String
public let metadata: [String: String]
public let timestamp: Date
public init(severity: RuntimeSeverity, message: String, metadata: [String: String], timestamp: Date = Date()) {
self.severity = severity
self.message = message
self.metadata = metadata
self.timestamp = timestamp
}
}
public final class RuntimeReporter {
public typealias Sink = @Sendable (RuntimeEvent) -> Void
public static let shared = RuntimeReporter()
private let logger = Logger(subsystem: "com.werkout.sharedcore", category: "runtime")
private let lock = NSLock()
private var sink: Sink?
private init() {}
public func setSink(_ sink: Sink?) {
lock.lock()
self.sink = sink
lock.unlock()
}
public func recordError(_ message: String, metadata: [String: String] = [:]) {
record(.error, message: message, metadata: metadata)
}
public func recordWarning(_ message: String, metadata: [String: String] = [:]) {
record(.warning, message: message, metadata: metadata)
}
public func recordInfo(_ message: String, metadata: [String: String] = [:]) {
record(.info, message: message, metadata: metadata)
}
private func record(_ severity: RuntimeSeverity, message: String, metadata: [String: String]) {
let flattenedMetadata = metadata
.map { "\($0.key)=\($0.value)" }
.sorted()
.joined(separator: ",")
let logMessage = flattenedMetadata.isEmpty ? message : "\(message) | \(flattenedMetadata)"
switch severity {
case .info:
logger.info("\(logMessage, privacy: .public)")
case .warning:
logger.warning("\(logMessage, privacy: .public)")
case .error:
logger.error("\(logMessage, privacy: .public)")
}
lock.lock()
let sink = self.sink
lock.unlock()
sink?(RuntimeEvent(severity: severity, message: message, metadata: metadata))
}
}

View File

@@ -0,0 +1,103 @@
import Foundation
public enum TokenSecurity {
public static let defaultRotationWindow: TimeInterval = 60 * 60
private static let authPrefixes = ["token", "bearer"]
// Basic high-entropy hex token detector for accidental commits.
private static let tokenRegex = try? NSRegularExpression(pattern: "\\b[a-fA-F0-9]{32,}\\b")
public static func containsPotentialHardcodedToken(in text: String) -> Bool {
guard let tokenRegex else {
return false
}
let range = NSRange(location: 0, length: text.utf16.count)
return tokenRegex.firstMatch(in: text, options: [], range: range) != nil
}
public static func isRedactedToken(_ token: String?) -> Bool {
guard let token else { return false }
let upper = token.uppercased()
return upper.contains("REDACTED") || upper.contains("YOUR_TOKEN") || upper.contains("PLACEHOLDER")
}
public static func sanitizeToken(_ token: String?) -> String? {
guard let rawToken = token?.trimmingCharacters(in: .whitespacesAndNewlines),
rawToken.isEmpty == false else {
return nil
}
let normalized = normalizeAuthPrefix(rawToken)
guard normalized.isEmpty == false,
isRedactedToken(normalized) == false else {
return nil
}
return normalized
}
public static func jwtExpiration(_ token: String) -> Date? {
let segments = token.split(separator: ".")
guard segments.count == 3 else {
return nil
}
let payloadSegment = String(segments[1])
guard let payloadData = base64URLDecode(payloadSegment),
let object = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
let exp = object["exp"] as? TimeInterval else {
return nil
}
return Date(timeIntervalSince1970: exp)
}
public static func isExpired(_ token: String?, now: Date = Date()) -> Bool {
guard let token = sanitizeToken(token) else {
return true
}
guard let expiration = jwtExpiration(token) else {
// Non-JWT tokens cannot be locally validated for expiry.
return false
}
return expiration <= now
}
public static func shouldRotate(_ token: String?, now: Date = Date(), rotationWindow: TimeInterval = defaultRotationWindow) -> Bool {
guard let token = sanitizeToken(token),
let expiration = jwtExpiration(token) else {
return false
}
return expiration.timeIntervalSince(now) <= rotationWindow
}
private static func base64URLDecode(_ input: String) -> Data? {
var value = input
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let remainder = value.count % 4
if remainder > 0 {
value.append(String(repeating: "=", count: 4 - remainder))
}
return Data(base64Encoded: value)
}
private static func normalizeAuthPrefix(_ token: String) -> String {
let lowercased = token.lowercased()
for prefix in authPrefixes {
if lowercased == prefix {
return ""
}
let prefixed = "\(prefix) "
if lowercased.hasPrefix(prefixed) {
return String(token.dropFirst(prefixed.count)).trimmingCharacters(in: .whitespacesAndNewlines)
}
}
return token
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
public enum WatchPayloadValidationError: Error, Equatable {
case emptyPayload
case payloadTooLarge(actualBytes: Int, maxBytes: Int)
case decodeFailure
}
public enum WatchPayloadValidation {
public static let defaultMaxPayloadBytes = 256 * 1024
public static func validate(_ payload: Data, maxBytes: Int = defaultMaxPayloadBytes) -> WatchPayloadValidationError? {
guard payload.isEmpty == false else {
return .emptyPayload
}
guard payload.count <= maxBytes else {
return .payloadTooLarge(actualBytes: payload.count, maxBytes: maxBytes)
}
return nil
}
public static func decode<T: Decodable>(
_ type: T.Type,
from payload: Data,
maxBytes: Int = defaultMaxPayloadBytes,
decoder: JSONDecoder = JSONDecoder()
) throws -> T {
if let validationError = validate(payload, maxBytes: maxBytes) {
throw validationError
}
do {
return try decoder.decode(T.self, from: payload)
} catch {
throw WatchPayloadValidationError.decodeFailure
}
}
}

View File

@@ -0,0 +1,50 @@
import Foundation
public struct WorkoutValidationIssue: Equatable {
public let code: String
public let message: String
public init(code: String, message: String) {
self.code = code
self.message = message
}
}
public enum WorkoutValidation {
public static func validateSupersets(_ supersets: [[String: Any]]) -> [WorkoutValidationIssue] {
var issues = [WorkoutValidationIssue]()
if supersets.isEmpty {
issues.append(WorkoutValidationIssue(code: "empty_supersets", message: "Workout requires at least one superset."))
return issues
}
for (supersetIndex, superset) in supersets.enumerated() {
let rounds = superset["rounds"] as? Int ?? 0
if rounds <= 0 {
issues.append(WorkoutValidationIssue(code: "invalid_rounds", message: "Superset \(supersetIndex + 1) must have at least one round."))
}
let exercises = superset["exercises"] as? [[String: Any]] ?? []
if exercises.isEmpty {
issues.append(WorkoutValidationIssue(code: "empty_exercises", message: "Superset \(supersetIndex + 1) must contain at least one exercise."))
continue
}
for (exerciseIndex, exercise) in exercises.enumerated() {
let reps = exercise["reps"] as? Int ?? 0
let duration = exercise["duration"] as? Int ?? 0
if reps <= 0 && duration <= 0 {
issues.append(
WorkoutValidationIssue(
code: "invalid_exercise_payload",
message: "Exercise \(exerciseIndex + 1) in superset \(supersetIndex + 1) needs reps or duration."
)
)
}
}
}
return issues
}
}

View File

@@ -0,0 +1,20 @@
import XCTest
@testable import SharedCore
final class RuntimeReporterTests: XCTestCase {
func testReporterInvokesSinkWithMetadata() {
let expectation = expectation(description: "sink called")
RuntimeReporter.shared.setSink { event in
XCTAssertEqual(event.severity, .error)
XCTAssertEqual(event.message, "network failure")
XCTAssertEqual(event.metadata["status"], "500")
expectation.fulfill()
}
RuntimeReporter.shared.recordError("network failure", metadata: ["status": "500"])
waitForExpectations(timeout: 1.0)
RuntimeReporter.shared.setSink(nil)
}
}

View File

@@ -0,0 +1,36 @@
import XCTest
@testable import SharedCore
final class BoundedFIFOQueueTests: XCTestCase {
func testDisconnectReconnectFlushPreservesOrder() {
var queue = BoundedFIFOQueue<Int>(maxCount: 5)
_ = queue.enqueue(10)
_ = queue.enqueue(20)
_ = queue.enqueue(30)
XCTAssertEqual(queue.dequeueAll(), [10, 20, 30])
XCTAssertTrue(queue.isEmpty)
}
func testOverflowDropsOldestPayloads() {
var queue = BoundedFIFOQueue<Int>(maxCount: 3)
XCTAssertEqual(queue.enqueue(1), 0)
XCTAssertEqual(queue.enqueue(2), 0)
XCTAssertEqual(queue.enqueue(3), 0)
XCTAssertEqual(queue.enqueue(4), 1)
XCTAssertEqual(queue.enqueue(5), 1)
XCTAssertEqual(queue.dequeueAll(), [3, 4, 5])
}
func testMaxCountHasLowerBoundOfOne() {
var queue = BoundedFIFOQueue<Int>(maxCount: 0)
_ = queue.enqueue(1)
XCTAssertEqual(queue.enqueue(2), 1)
XCTAssertEqual(queue.dequeueAll(), [2])
}
}

View File

@@ -0,0 +1,47 @@
import XCTest
@testable import SharedCore
final class WatchPayloadValidationTests: XCTestCase {
private struct MockPayload: Codable, Equatable {
let name: String
let count: Int
}
func testValidateRejectsEmptyPayload() {
let error = WatchPayloadValidation.validate(Data())
XCTAssertEqual(error, .emptyPayload)
}
func testValidateRejectsOversizedPayload() {
let payload = Data(repeating: 0, count: 9)
let error = WatchPayloadValidation.validate(payload, maxBytes: 8)
XCTAssertEqual(error, .payloadTooLarge(actualBytes: 9, maxBytes: 8))
}
func testDecodeAcceptsValidPayload() throws {
let payload = try JSONEncoder().encode(MockPayload(name: "set", count: 12))
let decoded = try WatchPayloadValidation.decode(MockPayload.self, from: payload)
XCTAssertEqual(decoded, MockPayload(name: "set", count: 12))
}
func testDecodeRejectsInvalidJSON() throws {
let invalid = Data("not-json".utf8)
XCTAssertThrowsError(try WatchPayloadValidation.decode(MockPayload.self, from: invalid)) { error in
XCTAssertEqual(error as? WatchPayloadValidationError, .decodeFailure)
}
}
func testDecodeRejectsOversizedPayloadBeforeDecoding() {
let payload = Data(repeating: 1, count: 32)
XCTAssertThrowsError(
try WatchPayloadValidation.decode(MockPayload.self, from: payload, maxBytes: 16)
) { error in
XCTAssertEqual(
error as? WatchPayloadValidationError,
.payloadTooLarge(actualBytes: 32, maxBytes: 16)
)
}
}
}

View File

@@ -0,0 +1,39 @@
import XCTest
@testable import SharedCore
final class WorkoutValidationTests: XCTestCase {
func testValidateSupersetsRejectsEmptyPayload() {
let issues = WorkoutValidation.validateSupersets([])
XCTAssertEqual(issues.first?.code, "empty_supersets")
}
func testValidateSupersetsRejectsInvalidRoundsAndExercisePayload() {
let supersets: [[String: Any]] = [
[
"rounds": 0,
"exercises": [
["reps": 0, "duration": 0]
]
]
]
let issues = WorkoutValidation.validateSupersets(supersets)
XCTAssertTrue(issues.contains(where: { $0.code == "invalid_rounds" }))
XCTAssertTrue(issues.contains(where: { $0.code == "invalid_exercise_payload" }))
}
func testValidateSupersetsAcceptsValidPayload() {
let supersets: [[String: Any]] = [
[
"rounds": 3,
"exercises": [
["reps": 12, "duration": 0],
["reps": 0, "duration": 30]
]
]
]
let issues = WorkoutValidation.validateSupersets(supersets)
XCTAssertTrue(issues.isEmpty)
}
}

View File

@@ -0,0 +1,65 @@
import XCTest
@testable import SharedCore
final class TokenSecurityTests: XCTestCase {
func testSanitizeTokenRejectsEmptyAndRedactedValues() {
XCTAssertNil(TokenSecurity.sanitizeToken(nil))
XCTAssertNil(TokenSecurity.sanitizeToken(" "))
XCTAssertNil(TokenSecurity.sanitizeToken("REDACTED_TOKEN"))
XCTAssertEqual(TokenSecurity.sanitizeToken(" abc123 "), "abc123")
}
func testSanitizeTokenNormalizesAuthorizationPrefixes() {
XCTAssertEqual(TokenSecurity.sanitizeToken("Token abc123"), "abc123")
XCTAssertEqual(TokenSecurity.sanitizeToken("bearer xyz789"), "xyz789")
XCTAssertNil(TokenSecurity.sanitizeToken("Token "))
}
func testContainsPotentialHardcodedTokenDetectsLongHexBlob() {
let content = "private let token = \"0123456789abcdef0123456789abcdef\""
XCTAssertTrue(TokenSecurity.containsPotentialHardcodedToken(in: content))
}
func testJWTExpirationAndRotationWindow() throws {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let expiration = now.addingTimeInterval(30 * 60)
let token = try makeJWT(exp: expiration)
XCTAssertEqual(TokenSecurity.jwtExpiration(token), expiration)
XCTAssertFalse(TokenSecurity.isExpired(token, now: now))
XCTAssertTrue(TokenSecurity.shouldRotate(token, now: now, rotationWindow: 60 * 60))
}
func testExpiredJWTReturnsExpired() throws {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let expiration = now.addingTimeInterval(-10)
let token = try makeJWT(exp: expiration)
XCTAssertTrue(TokenSecurity.isExpired(token, now: now))
}
func testMalformedTokenDoesNotCrashAndDoesNotTriggerRotation() {
let malformed = "not-a-jwt"
XCTAssertNil(TokenSecurity.jwtExpiration(malformed))
XCTAssertFalse(TokenSecurity.isExpired(malformed))
XCTAssertFalse(TokenSecurity.shouldRotate(malformed))
}
private func makeJWT(exp: Date) throws -> String {
let header = ["alg": "HS256", "typ": "JWT"]
let payload = ["exp": Int(exp.timeIntervalSince1970)]
let headerData = try JSONSerialization.data(withJSONObject: header)
let payloadData = try JSONSerialization.data(withJSONObject: payload)
return "\(base64URL(headerData)).\(base64URL(payloadData)).signature"
}
private func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}

View File

@@ -31,6 +31,7 @@
1CC7CBCF2C21E42C001614B8 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC7CBCE2C21E42C001614B8 /* DataStore.swift */; };
1CC7CBD12C21E5FA001614B8 /* PlayerUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC7CBD02C21E5FA001614B8 /* PlayerUIView.swift */; };
1CC7CBD32C21E678001614B8 /* ThotStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC7CBD22C21E678001614B8 /* ThotStyle.swift */; };
D00200012E00000100000001 /* SharedCore in Frameworks */ = {isa = PBXBuildFile; productRef = D00200012E00000100000003 /* SharedCore */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -79,6 +80,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D00200012E00000100000001 /* SharedCore in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -177,6 +179,9 @@
dependencies = (
);
name = WekoutThotViewer;
packageProductDependencies = (
D00200012E00000100000003 /* SharedCore */,
);
productName = WekoutThotViewer;
productReference = 1CC0930B2C21DE760004E1E6 /* WekoutThotViewer.app */;
productType = "com.apple.product-type.application";
@@ -205,6 +210,9 @@
Base,
);
mainGroup = 1CC093022C21DE760004E1E6;
packageReferences = (
D00200012E00000100000002 /* XCLocalSwiftPackageReference "../SharedCore" */,
);
productRefGroup = 1CC0930C2C21DE760004E1E6 /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -258,6 +266,13 @@
};
/* End PBXSourcesBuildPhase section */
/* Begin XCLocalSwiftPackageReference section */
D00200012E00000100000002 /* XCLocalSwiftPackageReference "../SharedCore" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../SharedCore;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCBuildConfiguration section */
1CC093172C21DE770004E1E6 /* Debug */ = {
isa = XCBuildConfiguration;
@@ -387,6 +402,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"WekoutThotViewer/Preview Content\"";
DEVELOPMENT_TEAM = V3PF3M6B6U;
ENABLE_APP_INTENTS_METADATA_GENERATION = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = WekoutThotViewer/Info.plist;
@@ -414,6 +430,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"WekoutThotViewer/Preview Content\"";
DEVELOPMENT_TEAM = V3PF3M6B6U;
ENABLE_APP_INTENTS_METADATA_GENERATION = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = WekoutThotViewer/Info.plist;
@@ -434,6 +451,14 @@
};
/* End XCBuildConfiguration section */
/* Begin XCSwiftPackageProductDependency section */
D00200012E00000100000003 /* SharedCore */ = {
isa = XCSwiftPackageProductDependency;
package = D00200012E00000100000002 /* XCLocalSwiftPackageReference "../SharedCore" */;
productName = SharedCore;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCConfigurationList section */
1CC093062C21DE760004E1E6 /* Build configuration list for PBXProject "WekoutThotViewer" */ = {
isa = XCConfigurationList;

View File

@@ -14,17 +14,17 @@ struct ContentView: View {
@State var isUpdating = false
@ObservedObject var dataStore = DataStore.shared
@State var nsfwVideos: [NSFWVideo]?
@State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!)
@State private var showLoginView = false
@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?
let videoEnded = NotificationCenter.default.publisher(for: NSNotification.Name.AVPlayerItemDidPlayToEndTime)
var body: some View {
VStack {
if isUpdating {
if isUpdating {
ProgressView()
.progressViewStyle(.circular)
}
ProgressView()
.progressViewStyle(.circular)
} else {
PlayerView(player: $avPlayer)
.onAppear{
@@ -37,8 +37,18 @@ struct ContentView: View {
}
}
.onAppear(perform: {
maybeUpdateShit()
maybeRefreshData()
})
.sheet(isPresented: $showLoginView) {
LoginView(completion: {
needsUpdating = true
maybeRefreshData()
})
.interactiveDismissDisabled()
}
.onDisappear {
avPlayer.pause()
}
}
func playRandomVideo() {
@@ -48,36 +58,48 @@ struct ContentView: View {
}
func playVideo(url: String) {
let url = URL(string: BaseURLs.currentBaseURL + url)
avPlayer = AVPlayer(url: url!)
guard let videoURL = URL(string: BaseURLs.currentBaseURL + url) else {
return
}
if currentVideoURL == videoURL {
avPlayer.seek(to: .zero)
avPlayer.isMuted = true
avPlayer.play()
return
}
currentVideoURL = videoURL
avPlayer = AVPlayer(url: videoURL)
avPlayer.isMuted = true
avPlayer.play()
}
func maybeUpdateShit() {
UserStore.shared.setTreyDevRegisterdUser()
func maybeRefreshData() {
guard UserStore.shared.token != nil else {
isUpdating = false
showLoginView = true
return
}
if UserStore.shared.token != nil{
if UserStore.shared.plannedWorkouts.isEmpty {
UserStore.shared.fetchPlannedWorkouts()
}
if needsUpdating {
self.isUpdating = true
dataStore.fetchAllData(completion: {
DispatchQueue.main.async {
guard let allNSFWVideos = dataStore.allNSFWVideos else {
return
}
self.nsfwVideos = allNSFWVideos
if UserStore.shared.plannedWorkouts.isEmpty {
UserStore.shared.fetchPlannedWorkouts()
}
if needsUpdating {
self.isUpdating = true
dataStore.fetchAllData(completion: {
DispatchQueue.main.async {
guard let allNSFWVideos = dataStore.allNSFWVideos else {
self.isUpdating = false
playRandomVideo()
return
}
self.nsfwVideos = allNSFWVideos
self.isUpdating = false
})
}
self.needsUpdating = false
playRandomVideo()
}
})
}
}
}

View File

@@ -0,0 +1,35 @@
# Steps 1-5 Stabilization Deliverables
This repo now includes:
1. `SharedCore` Swift package with cross-platform utilities and dedicated test targets:
- `SharedCoreiOSTests`
- `SharedCoreWatchOSTests`
- `SharedCoreTVOSTests`
2. Auth token lifecycle protections in shared iOS/tvOS user/network code:
- token sanitization
- JWT expiry checks
- proactive refresh trigger when near expiry
- forced logout on `401`/`403`
3. Smoke scripts in `scripts/smoke/` for iOS/watchOS/tvOS plus package tests.
4. Runtime logging hooks (structured `os.Logger`) in network/auth/datastore/watch bridge/workout paths.
5. CI workflow `.github/workflows/apple-platform-ci.yml` that runs the smoke suite.
6. Build warning cleanup:
- disabled AppIntents metadata extraction for iOS/watchOS/tvOS targets that do not link `AppIntents`.
## SharedCore wiring
- `SharedCore` is linked as a local Swift package product to:
- `Werkout_ios` (iOS)
- `Werkout_watch Watch App` (watchOS)
- `WekoutThotViewer` (tvOS)
- Shared helpers are actively used in app code:
- `TokenSecurity` now drives token sanitization/expiry/rotation checks in `UserStore`.
- `RuntimeReporter` now handles network/auth/datastore runtime error reporting.
## Local commands
```bash
./scripts/ci/scan_tokens.sh
./scripts/smoke/smoke_all.sh
```

View File

@@ -0,0 +1,39 @@
# Step 10 Reliability Round 2
## Coverage
- Reviewed and patched:
- `iphone/Werkout_ios/BridgeModule+Watch.swift`
- `iphone/Werkout_ios/BridgeModule+WorkoutActions.swift`
- `iphone/Werkout_ios/CurrentWorkoutInfo.swift`
- `iphone/Werkout_watch Watch App/WatchDelegate.swift`
- `iphone/Werkout_ios/UserStore.swift`
- Validation:
- `./scripts/smoke/smoke_all.sh`
- iOS/tvOS analyzer passes
## Fixes
1. Main-thread state safety for watch session callbacks
- Wrapped `didReceiveMessageData` action handling and `activationDidCompleteWith` state transitions on main.
- Prevents shared bridge state (`@Published` workout/watch properties + queued message mutations) from being changed off-main.
2. Removed dead closure path that could retain `BridgeModule`
- Removed unused `CurrentWorkoutInfo.complete` closure and its assignment in `BridgeModule+WorkoutActions.start(workout:)`.
- Reduces lifecycle risk and removes dead behavior.
3. HealthKit authorization crash hardening on watch launch
- Replaced force-unwrapped quantity types with guarded optional binding in `WatchDelegate`.
- Logs and exits cleanly if required HealthKit quantity types are unavailable.
4. Cross-target notification compile stability
- Updated `UserStore.logout` to post `Notification.Name("CreatedNewWorkout")` directly.
- Avoids reliance on an iOS-only extension file when `UserStore` is compiled in tvOS target.
## Validation
- Smoke suite passed:
- token scan
- SharedCore tests
- iOS/watchOS/tvOS builds

View File

@@ -0,0 +1,40 @@
# Step 11 Watch Regression + Architecture Cleanup
## Scope
Executed in requested order:
1. `#2` Focused watch/phone disconnect-reconnect regression coverage.
2. `#3` Architecture cleanup to reduce shared cross-target coupling.
## #2 Regression Work
- Added shared queue primitive:
- `SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift`
- Added regression tests for disconnect/reconnect replay and overflow behavior:
- `SharedCore/Tests/SharedCoreWatchOSTests/BoundedFIFOQueueTests.swift`
- Wired iOS watch bridge queueing to shared queue:
- `iphone/Werkout_ios/BridgeModule.swift`
- `iphone/Werkout_ios/BridgeModule+Watch.swift`
- Wired watch sender queueing to shared queue:
- `iphone/Werkout_watch Watch App/WatchMainViewModel+WCSessionDelegate.swift`
## #3 Architecture Cleanup
- Replaced ad-hoc notification wiring with a shared typed notification constant:
- `SharedCore/Sources/SharedCore/AppNotifications.swift`
- Updated consumers to use shared constant:
- `iphone/Werkout_ios/UserStore.swift`
- `iphone/Werkout_ios/Werkout_iosApp.swift`
- `iphone/Werkout_ios/Views/CreateWorkout/CreateViewModels.swift`
- `iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift`
- Removed iOS-only notification extension that created cross-target coupling:
- `iphone/Werkout_ios/Extensions.swift`
## Validation
- `./scripts/smoke/smoke_all.sh` passed:
- token scan
- SharedCore tests (including new queue tests)
- iOS/watchOS/tvOS builds

View File

@@ -0,0 +1,53 @@
# Step 12 Hardware Disconnect/Reconnect Pass
## Date
- 2026-02-11 (UTC timestamp used during check: `2026-02-11T18:45:42Z`)
## Coverage Attempted
- Device inventory:
- `xcrun xcdevice list`
- iOS destination eligibility:
- `xcodebuild -project iphone/Werkout_ios.xcodeproj -scheme 'Werkout_ios' -showdestinations`
- watchOS destination eligibility:
- `xcodebuild -project iphone/Werkout_ios.xcodeproj -scheme 'Werkout_watch Watch App' -showdestinations`
## Findings
1. Hardware watch pass could not be executed from this environment.
- Evidence:
- watch scheme showed simulator destinations only under available destinations.
- the only physical watch destination was ineligible:
- `Hollies Apple Watch` with error indicating watchOS version mismatch/unknown against deployment target `watchOS 9.4`.
2. Physical iOS devices are present/eligible.
- Evidence:
- available iOS hardware destinations included:
- `Gary Tartts iPad`
- `ihollieb`
- `Peeeeeeeeellll`
3. Physical watch pairing/connectivity state is the blocking dependency for true hardware disconnect/reconnect validation.
## What Was Added For Immediate Re-Run
- Hardware run script:
- `scripts/hardware/watch_disconnect_hardware_pass.sh`
- Script behavior:
- validates eligible physical iOS + watch destinations
- performs hardware-targeted preflight builds (`CODE_SIGNING_ALLOWED=NO`)
- emits manual disconnect/reconnect checklist and pass criteria artifact
## Manual Hardware Scenario (Pending Once Watch Is Eligible)
1. Start iOS + watch apps on physical paired devices.
2. Start workout from iOS.
3. Break transport (Bluetooth off on iPhone or Airplane Mode on watch) for ~30s.
4. While disconnected, trigger multiple state changes on iOS.
5. Reconnect transport and verify watch converges to latest state without crash/replay loop.
6. Repeat for two disconnect/reconnect cycles.
## Current Status
- Blocked on eligible physical watch destination.
- Queue/replay behavior is covered by automated tests in `SharedCore/Tests/SharedCoreWatchOSTests/BoundedFIFOQueueTests.swift`, but physical transport behavior remains unverified until watch eligibility is fixed.
- In this Codex shell, scripted destination probing/building is additionally constrained by sandboxed write restrictions to Xcode/SwiftPM cache paths; manual run on your local terminal is expected once watch hardware is eligible.

View File

@@ -0,0 +1,55 @@
# Step 6 Audit Round 1 (P0/P1)
## Coverage
- Reviewed high-risk auth/session/network/watch files:
- `iphone/Werkout_ios/UserStore.swift`
- `iphone/Werkout_ios/Network/Network.swift`
- `iphone/Werkout_ios/BridgeModule+Watch.swift`
- `iphone/Werkout_watch Watch App/WatchMainViewModel.swift`
- `iphone/Werkout_watch Watch App/WatchMainViewModel+WCSessionDelegate.swift`
- `iphone/Werkout_ios/HealthKitHelper.swift`
- `iphone/Werkout_ios/CurrentWorkoutInfo.swift`
- Ran:
- `./scripts/smoke/smoke_all.sh`
- Added/ran regression tests in `SharedCore` for token lifecycle and watch payload validation.
## Findings And Fixes
1. `P1` Watch command loss during activation
- Evidence: `iphone/Werkout_watch Watch App/WatchMainViewModel+WCSessionDelegate.swift:40`
- Problem: payloads were dropped when `WCSession` was not activated.
- Fix: added bounded queue (`maxQueuedPayloads`), enqueue on inactive session, flush on activation.
2. `P1` Silent/unsafe watch payload decode failures
- Evidence: `iphone/Werkout_ios/BridgeModule+Watch.swift:73`
- Evidence: `iphone/Werkout_watch Watch App/WatchMainViewModel.swift:74`
- Problem: `try?` decode silently ignored malformed payloads.
- Fix: added shared `WatchPayloadValidation` with size checks and structured decode failures; both decode paths now reject+log bad payloads.
3. `P1` Auth token normalization gap for prefixed tokens
- Evidence: `SharedCore/Sources/SharedCore/TokenSecurity.swift:24`
- Problem: `"Token ..."` / `"Bearer ..."` values were not normalized.
- Fix: normalize known auth prefixes and reject bare prefix-only strings.
4. `P1` Network reliability/threading risk
- Evidence: `iphone/Werkout_ios/Network/Network.swift:12`
- Problem: infinite request timeouts and completion handlers returning on background threads.
- Fix: finite timeout (`30s`) and centralized main-thread completion delivery.
5. `P1` HealthKit helper shared mutable-state race
- Evidence: `iphone/Werkout_ios/HealthKitHelper.swift:20`
- Problem: mutable cross-request state (`completion`, counters, shared result object) could race and mis-route results.
- Fix: per-request aggregation via `DispatchGroup`, single UUID query (`limit: 1`), thread-safe aggregation queue, structured runtime logging.
6. `P2` Workout order inconsistency across helpers
- Evidence: `iphone/Werkout_ios/CurrentWorkoutInfo.swift:24`
- Problem: some paths used unsorted `workout.supersets` while others used sorted supersets.
- Fix: unified core navigation/lookup paths on sorted `superset` accessor and corrected bounds check.
## Validation
- Smoke suite passed after fixes:
- token scan
- SharedCore tests (including new regression tests)
- iOS/watchOS/tvOS builds

View File

@@ -0,0 +1,62 @@
# Step 7 UI/State/Accessibility Round 1
## Coverage
- Reviewed and patched high-traffic iOS/watchOS UI paths:
- workout browsing, planned workouts, workout detail, create-workout flow, login, watch main view
- Ran validation:
- `./scripts/smoke/smoke_all.sh`
## Fixes Applied
1. Create workout state consistency and duplicate-submit prevention
- `iphone/Werkout_ios/Views/CreateWorkout/CreateViewModels.swift`
- Added `isUploading` gate, title trimming validation, shared `WorkoutValidation` integration.
2. Weight stepper logic bug
- `iphone/Werkout_ios/Views/CreateWorkout/CreateViewModels.swift`
- Fixed weight decrement mismatch (`-15` to `-5`) to match increment behavior.
3. Create-workout UX cleanup and accessibility
- `iphone/Werkout_ios/Views/CreateWorkout/CreateWorkoutMainView.swift`
- Replaced visible sentinel row with hidden spacer, disabled upload button while uploading, added button labels/hints.
4. Superset editing accessibility/state
- `iphone/Werkout_ios/subview/CreateWorkoutSupersetView.swift`
- Avoided sheet toggle race by setting `showAddExercise = true`; added accessibility labels/hints.
5. Exercise action controls accessibility
- `iphone/Werkout_ios/Views/CreateWorkout/CreateExerciseActionsView.swift`
- Added accessibility labels to steppers and icon-only controls.
6. Workout list/planned list row accessibility
- `iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsListView.swift`
- `iphone/Werkout_ios/subview/PlannedWorkoutView.swift`
- Converted tap-only rows to plain `Button`s for VoiceOver/focus reliability.
7. Workout detail list ordering/scroll stability
- `iphone/Werkout_ios/Views/WorkoutDetail/ExerciseListView.swift`
- Aligned list ordering with sorted superset order and introduced stable row IDs for consistent scroll targeting.
8. Workout detail control accessibility + progress text guard
- `iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift`
- Added accessibility labels to icon-only controls and avoided negative progress display.
9. Login form input/accessibility improvements
- `iphone/Werkout_ios/Views/Login/LoginView.swift`
- Added keyboard/input autocorrection settings and accessibility labels/hints.
10. HealthKit auth safety/logging in all-workouts screen
- `iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift`
- Removed force-unwrapped HK types and added runtime warning on failed authorization.
11. watchOS no-workout screen and accessibility polish
- `iphone/Werkout_watch Watch App/MainWatchView.swift`
- Replaced emoji placeholder with clear status text/icon and added combined accessibility labels.
## Validation
- Smoke suite passed after fixes:
- token scan
- SharedCore tests
- iOS/watchOS/tvOS builds

View File

@@ -0,0 +1,48 @@
# Step 8 Performance/State Round 1
## Coverage
- Reviewed and patched:
- `iphone/Werkout_ios/DataStore.swift`
- `iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift`
- `iphone/Werkout_ios/Views/WorkoutDetail/ExerciseListView.swift`
- `WekoutThotViewer/WekoutThotViewer/ContentView.swift`
- `iphone/Werkout_ios/BridgeModule.swift`
- `iphone/Werkout_ios/BridgeModule+Watch.swift`
- `iphone/Werkout_ios/BridgeModule+Timer.swift`
- `iphone/Werkout_watch Watch App/WatchWorkout.swift`
- Validation run:
- `./scripts/smoke/smoke_all.sh`
## Fixes
1. Coalesced concurrent `fetchAllData` requests
- `DataStore` now queues completion handlers while a fetch is active to prevent overlapping network fan-out and state churn.
2. Reduced AVPlayer churn in iOS workout detail
- Reuses current player for same URL by seeking to start instead of recreating `AVPlayer` each time exercise/video updates.
3. Reduced AVPlayer churn in iOS exercise preview sheet
- Added preview URL tracking; same URL now replays without allocating a new player.
4. Reduced AVPlayer churn in tvOS content loop
- Same URL replay now seeks/replays existing player rather than recreating.
5. Capped queued watch messages on iOS bridge
- Added queue cap to prevent unbounded growth while watch is disconnected.
6. Added queue fallback for send failures
- Failed reachable send now re-queues payload for later delivery.
7. Improved timer power behavior
- Added timer tolerance to workout/exercise timers.
8. Fixed watch heart-rate loop early-return behavior
- Non-heart sample types now `continue` instead of exiting handler early.
## Validation
- Smoke suite passed:
- token scan
- SharedCore tests
- iOS/watchOS/tvOS builds

View File

@@ -0,0 +1,39 @@
# Step 9 Memory/Lifecycle Round 1
## Coverage
- Audited lifecycle cleanup and resource teardown in:
- `iphone/Werkout_ios/subview/PlayerUIView.swift`
- `iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift`
- `iphone/Werkout_ios/Views/WorkoutDetail/ExerciseListView.swift`
- `iphone/Werkout_ios/Views/ExternalWorkoutDetailView.swift`
- `iphone/Werkout_ios/subview/AllExerciseView.swift`
- `iphone/Werkout_ios/Views/CreateWorkout/CreateExerciseActionsView.swift`
- `iphone/Werkout_ios/AudioEngine.swift`
- `WekoutThotViewer/WekoutThotViewer/ContentView.swift`
- Validation:
- `./scripts/smoke/smoke_all.sh`
## Fixes
1. Player view teardown safety
- `PlayerView` now pauses previous players when swapping and performs explicit teardown in `dismantleUIView`.
2. Workout detail closure retention risk
- Clears `BridgeModule.shared.completedWorkout` on `WorkoutDetailView` disappear.
3. Player pause on dismiss across views
- Added `onDisappear` player pause in workout detail exercise list, create-exercise preview, all-exercise preview, external display, and tvOS content view.
4. External display player reuse
- Added URL tracking + replay path to avoid reallocating AVPlayer when URL is unchanged.
5. Audio playback resource churn
- Stops existing players before replacement and logs failures via `RuntimeReporter` instead of `print`.
## Validation
- Smoke suite passed:
- token scan
- SharedCore tests
- iOS/watchOS/tvOS builds

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

23
scripts/ci/scan_tokens.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
PATTERN='(Token[[:space:]]+[A-Za-z0-9._-]{20,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|\b[a-fA-F0-9]{40,}\b)'
MATCHES="$(rg -n --no-heading -S "$PATTERN" \
iphone WekoutThotViewer SharedCore \
--glob '!**/*.xcodeproj/**' \
--glob '!**/Tests/**' \
--glob '!**/*.md' \
--glob '!**/.build/**' || true)"
if [[ -n "$MATCHES" ]]; then
echo "Potential hardcoded token(s) detected:" >&2
echo "$MATCHES" >&2
echo "If a match is intentional, redact it or move it to secure runtime configuration." >&2
exit 1
fi
echo "Token scan passed."

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
TS="$(date -u +"%Y%m%dT%H%M%SZ")"
OUT_DIR="${TMPDIR:-/tmp}/werkout_watch_hardware_pass_${TS}"
mkdir -p "$OUT_DIR"
export SWIFTPM_MODULECACHE_OVERRIDE="${TMPDIR:-/tmp}/werkout_swiftpm_module_cache"
export CLANG_MODULE_CACHE_PATH="${TMPDIR:-/tmp}/werkout_clang_module_cache"
export XDG_CACHE_HOME="${TMPDIR:-/tmp}/werkout_xdg_cache"
mkdir -p "$SWIFTPM_MODULECACHE_OVERRIDE" "$CLANG_MODULE_CACHE_PATH" "$XDG_CACHE_HOME"
PKG_CACHE_DIR="$OUT_DIR/package_cache"
PKG_CLONES_DIR="$OUT_DIR/source_packages"
XCODE_TEMP_HOME="$OUT_DIR/home"
mkdir -p "$PKG_CACHE_DIR" "$PKG_CLONES_DIR" "$XCODE_TEMP_HOME"
IOS_DEST_FILE="$OUT_DIR/ios_showdestinations.txt"
WATCH_DEST_FILE="$OUT_DIR/watch_showdestinations.txt"
HOME="$XCODE_TEMP_HOME" xcodebuild -project iphone/Werkout_ios.xcodeproj \
-scheme 'Werkout_ios' \
-disableAutomaticPackageResolution \
-clonedSourcePackagesDirPath "$PKG_CLONES_DIR" \
-packageCachePath "$PKG_CACHE_DIR" \
-showdestinations > "$IOS_DEST_FILE" 2>&1 || {
echo "Failed to query iOS destinations."
echo "Inspect: $IOS_DEST_FILE"
tail -n 80 "$IOS_DEST_FILE" || true
exit 3
}
HOME="$XCODE_TEMP_HOME" xcodebuild -project iphone/Werkout_ios.xcodeproj \
-scheme 'Werkout_watch Watch App' \
-disableAutomaticPackageResolution \
-clonedSourcePackagesDirPath "$PKG_CLONES_DIR" \
-packageCachePath "$PKG_CACHE_DIR" \
-showdestinations > "$WATCH_DEST_FILE" 2>&1 || {
echo "Failed to query watchOS destinations."
echo "Inspect: $WATCH_DEST_FILE"
tail -n 80 "$WATCH_DEST_FILE" || true
exit 3
}
IOS_ELIGIBLE_LINE="$(awk '/Available destinations/{flag=1;next}/Ineligible destinations/{flag=0}flag' "$IOS_DEST_FILE" \
| rg "platform:iOS, arch:arm64, id:" \
| rg -v "placeholder" \
| head -n 1 || true)"
WATCH_ELIGIBLE_LINE="$(awk '/Available destinations/{flag=1;next}/Ineligible destinations/{flag=0}flag' "$WATCH_DEST_FILE" \
| rg "platform:watchOS, arch:" \
| rg -v "undefined_arch|placeholder" \
| head -n 1 || true)"
if [[ -z "$IOS_ELIGIBLE_LINE" ]]; then
echo "No eligible physical iOS destination found."
echo "Inspect: $IOS_DEST_FILE"
exit 2
fi
if [[ -z "$WATCH_ELIGIBLE_LINE" ]]; then
echo "No eligible physical watchOS destination found."
echo "Inspect: $WATCH_DEST_FILE"
exit 2
fi
IOS_ID="$(echo "$IOS_ELIGIBLE_LINE" | sed -E 's/.*id:([^,]+),.*/\1/')"
WATCH_ID="$(echo "$WATCH_ELIGIBLE_LINE" | sed -E 's/.*id:([^,]+),.*/\1/')"
echo "Using iOS destination: $IOS_ELIGIBLE_LINE"
echo "Using watchOS destination: $WATCH_ELIGIBLE_LINE"
echo
echo "Preflight compile for selected hardware destinations..."
set -o pipefail
HOME="$XCODE_TEMP_HOME" xcodebuild -project iphone/Werkout_ios.xcodeproj \
-scheme 'Werkout_ios' \
-configuration Debug \
-destination "id=$IOS_ID" \
-disableAutomaticPackageResolution \
-clonedSourcePackagesDirPath "$PKG_CLONES_DIR" \
-packageCachePath "$PKG_CACHE_DIR" \
CODE_SIGNING_ALLOWED=NO \
build > "$OUT_DIR/ios_hardware_build.log" 2>&1
HOME="$XCODE_TEMP_HOME" xcodebuild -project iphone/Werkout_ios.xcodeproj \
-scheme 'Werkout_watch Watch App' \
-configuration Debug \
-destination "id=$WATCH_ID" \
-disableAutomaticPackageResolution \
-clonedSourcePackagesDirPath "$PKG_CLONES_DIR" \
-packageCachePath "$PKG_CACHE_DIR" \
CODE_SIGNING_ALLOWED=NO \
build > "$OUT_DIR/watch_hardware_build.log" 2>&1
cat > "$OUT_DIR/manual_disconnect_reconnect_checklist.md" <<'EOF'
# Manual Hardware Disconnect/Reconnect Pass
1. Launch iOS app on the selected physical iPhone/iPad.
2. Launch watch app on the paired physical Apple Watch.
3. Start a workout from iOS and confirm watch receives first exercise.
4. Disconnect watch from phone transport:
- Disable Bluetooth on iPhone for 30 seconds, or
- Enable Airplane Mode on watch for 30 seconds.
5. While disconnected, trigger 5+ state changes on phone:
- Next/previous exercise
- Pause/resume
- Complete workout
6. Reconnect transport.
7. Verify on watch:
- Latest state is applied.
- No crash.
- No infinite stale replay loop.
8. Repeat with two cycles of disconnect/reconnect in same workout.
Pass criteria:
- Watch converges to current exercise/time state after each reconnect.
- Queue replay does not exceed recent max-capped payload behavior.
- Completion payload arrives exactly once.
Log capture suggestion:
log stream --style compact --predicate '(subsystem == "com.werkout.ios" || subsystem == "com.werkout.watch")'
EOF
echo
echo "Hardware preflight complete."
echo "Artifacts: $OUT_DIR"
echo "Runbook: $OUT_DIR/manual_disconnect_reconnect_checklist.md"

23
scripts/smoke/build_ios.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
LOG_FILE="${TMPDIR:-/tmp}/werkout_smoke_ios.log"
set -o pipefail
xcodebuild -project iphone/Werkout_ios.xcodeproj \
-scheme 'Werkout_ios' \
-configuration Debug \
-destination 'generic/platform=iOS' \
-derivedDataPath /tmp/werkout_smoke_ios_dd \
CODE_SIGNING_ALLOWED=NO \
build 2>&1 | tee "$LOG_FILE"
FILTERED_ISSUES="$(rg -n "warning:|error:" "$LOG_FILE" | rg -v "Metadata extraction skipped. No AppIntents.framework dependency found." || true)"
if [[ -n "$FILTERED_ISSUES" ]]; then
echo "iOS build produced warnings/errors. See $LOG_FILE" >&2
echo "$FILTERED_ISSUES" | sed -n '1,120p' >&2
exit 1
fi

23
scripts/smoke/build_tvos.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
LOG_FILE="${TMPDIR:-/tmp}/werkout_smoke_tv.log"
set -o pipefail
xcodebuild -project WekoutThotViewer/WekoutThotViewer.xcodeproj \
-scheme WekoutThotViewer \
-configuration Debug \
-destination 'generic/platform=tvOS' \
-derivedDataPath /tmp/werkout_smoke_tv_dd \
CODE_SIGNING_ALLOWED=NO \
build 2>&1 | tee "$LOG_FILE"
FILTERED_ISSUES="$(rg -n "warning:|error:" "$LOG_FILE" | rg -v "Metadata extraction skipped. No AppIntents.framework dependency found." || true)"
if [[ -n "$FILTERED_ISSUES" ]]; then
echo "tvOS build produced warnings/errors. See $LOG_FILE" >&2
echo "$FILTERED_ISSUES" | sed -n '1,120p' >&2
exit 1
fi

23
scripts/smoke/build_watch.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
LOG_FILE="${TMPDIR:-/tmp}/werkout_smoke_watch.log"
set -o pipefail
xcodebuild -project iphone/Werkout_ios.xcodeproj \
-scheme 'Werkout_watch Watch App' \
-configuration Debug \
-destination 'generic/platform=watchOS' \
-derivedDataPath /tmp/werkout_smoke_watch_dd \
CODE_SIGNING_ALLOWED=NO \
build 2>&1 | tee "$LOG_FILE"
FILTERED_ISSUES="$(rg -n "warning:|error:" "$LOG_FILE" | rg -v "Metadata extraction skipped. No AppIntents.framework dependency found." || true)"
if [[ -n "$FILTERED_ISSUES" ]]; then
echo "watchOS build produced warnings/errors. See $LOG_FILE" >&2
echo "$FILTERED_ISSUES" | sed -n '1,120p' >&2
exit 1
fi

21
scripts/smoke/smoke_all.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
./scripts/ci/scan_tokens.sh
cd SharedCore
export SWIFTPM_MODULECACHE_OVERRIDE="${TMPDIR:-/tmp}/werkout_swiftpm_module_cache"
export CLANG_MODULE_CACHE_PATH="${TMPDIR:-/tmp}/werkout_clang_module_cache"
export XDG_CACHE_HOME="${TMPDIR:-/tmp}/werkout_xdg_cache"
mkdir -p "$SWIFTPM_MODULECACHE_OVERRIDE" "$CLANG_MODULE_CACHE_PATH" "$XDG_CACHE_HOME"
swift test --disable-sandbox --scratch-path "${TMPDIR:-/tmp}/werkout_sharedcore_scratch"
cd "$ROOT_DIR"
./scripts/smoke/build_ios.sh
./scripts/smoke/build_watch.sh
./scripts/smoke/build_tvos.sh
echo "Smoke suite passed (token scan + SharedCore tests + iOS/watchOS/tvOS builds)."