Stabilize iOS/watchOS/tvOS apps and add cross-platform audit remediation
This commit is contained in:
21
.github/workflows/apple-platform-ci.yml
vendored
Normal file
21
.github/workflows/apple-platform-ci.yml
vendored
Normal 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
2
SharedCore/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
35
SharedCore/Package.swift
Normal file
35
SharedCore/Package.swift
Normal 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"]
|
||||
)
|
||||
]
|
||||
)
|
||||
6
SharedCore/Sources/SharedCore/AppNotifications.swift
Normal file
6
SharedCore/Sources/SharedCore/AppNotifications.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public enum AppNotifications {
|
||||
public static let createdNewWorkout = Notification.Name("CreatedNewWorkout")
|
||||
}
|
||||
|
||||
40
SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift
Normal file
40
SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
76
SharedCore/Sources/SharedCore/RuntimeReporting.swift
Normal file
76
SharedCore/Sources/SharedCore/RuntimeReporting.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
103
SharedCore/Sources/SharedCore/TokenSecurity.swift
Normal file
103
SharedCore/Sources/SharedCore/TokenSecurity.swift
Normal 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
|
||||
}
|
||||
}
|
||||
40
SharedCore/Sources/SharedCore/WatchPayloadValidation.swift
Normal file
40
SharedCore/Sources/SharedCore/WatchPayloadValidation.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
50
SharedCore/Sources/SharedCore/WorkoutValidation.swift
Normal file
50
SharedCore/Sources/SharedCore/WorkoutValidation.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
65
SharedCore/Tests/SharedCoreiOSTests/TokenSecurityTests.swift
Normal file
65
SharedCore/Tests/SharedCoreiOSTests/TokenSecurityTests.swift
Normal 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: "")
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
docs/stabilization_steps_1_5.md
Normal file
35
docs/stabilization_steps_1_5.md
Normal 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
|
||||
```
|
||||
39
docs/step10_reliability_round2.md
Normal file
39
docs/step10_reliability_round2.md
Normal 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
|
||||
|
||||
40
docs/step11_watch_regression_and_architecture.md
Normal file
40
docs/step11_watch_regression_and_architecture.md
Normal 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
|
||||
|
||||
53
docs/step12_hardware_disconnect_pass.md
Normal file
53
docs/step12_hardware_disconnect_pass.md
Normal 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:
|
||||
- `Hollie’s 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 Tartt’s 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.
|
||||
55
docs/step6_audit_round1.md
Normal file
55
docs/step6_audit_round1.md
Normal 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
|
||||
62
docs/step7_ui_accessibility_round1.md
Normal file
62
docs/step7_ui_accessibility_round1.md
Normal 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
|
||||
48
docs/step8_performance_state_round1.md
Normal file
48
docs/step8_performance_state_round1.md
Normal 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
|
||||
39
docs/step9_memory_lifecycle_round1.md
Normal file
39
docs/step9_memory_lifecycle_round1.md
Normal 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
|
||||
28
iphone/Werkout-ios-Info.plist
Normal file
28
iphone/Werkout-ios-Info.plist
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"last_name": "test1_last",
|
||||
"image": "",
|
||||
"nick_name": "NickkkkName",
|
||||
"token": "8f10a5b8c7532f7f8602193767b46a2625a85c52"
|
||||
"token": "REDACTED_TOKEN"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
//
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
23
scripts/ci/scan_tokens.sh
Executable 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."
|
||||
132
scripts/hardware/watch_disconnect_hardware_pass.sh
Executable file
132
scripts/hardware/watch_disconnect_hardware_pass.sh
Executable 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
23
scripts/smoke/build_ios.sh
Executable 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
23
scripts/smoke/build_tvos.sh
Executable 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
23
scripts/smoke/build_watch.sh
Executable 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
21
scripts/smoke/smoke_all.sh
Executable 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)."
|
||||
Reference in New Issue
Block a user