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

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

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

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

2
SharedCore/.gitignore vendored Normal file
View File

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

35
SharedCore/Package.swift Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,10 +20,14 @@ struct PlannedWorkout: Codable {
case workout 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? { var date: Date? {
let df = DateFormatter() Self.plannedDateFormatter.date(from: onDate)
df.dateFormat = "yyyy-MM-dd"
df.locale = Locale(identifier: "en_US_POSIX")
return df.date(from: self.onDate)
} }
} }

View File

@@ -32,6 +32,37 @@ struct Workout: Codable, Identifiable, Equatable {
case allSupersetExecercise = "all_superset_exercise" 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 { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) 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) self.exercise_count = try container.decodeIfPresent(Int.self, forKey: .exercise_count)
let createdAtStr = try container.decodeIfPresent(String.self, forKey: .createdAt) let createdAtStr = try container.decodeIfPresent(String.self, forKey: .createdAt)
if let createdAtStr = createdAtStr { self.createdAt = createdAtStr.flatMap { Self.createdAtFormatter.date(from: $0) }
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.estimatedTime = try container.decodeIfPresent(Double.self, forKey: .estimatedTime) self.estimatedTime = try container.decodeIfPresent(Double.self, forKey: .estimatedTime)

View File

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

View File

@@ -11,10 +11,12 @@ extension BridgeModule {
func startWorkoutTimer() { func startWorkoutTimer() {
currentWorkoutRunTimer?.invalidate() currentWorkoutRunTimer?.invalidate()
currentWorkoutRunTimer = nil 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.currentWorkoutRunTimeInSeconds += 1
self.sendCurrentExerciseToWatch() self.sendCurrentExerciseToWatch()
}) })
currentWorkoutRunTimer?.tolerance = 0.1
currentWorkoutRunTimer?.fire() currentWorkoutRunTimer?.fire()
} }
@@ -28,6 +30,7 @@ extension BridgeModule {
selector: #selector(self.updateCurrentExerciseTimer), selector: #selector(self.updateCurrentExerciseTimer),
userInfo: nil, userInfo: nil,
repeats: true) repeats: true)
self.currentExerciseTimer?.tolerance = 0.1
self.currentExerciseTimer?.fire() self.currentExerciseTimer?.fire()
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,10 @@
// //
import Foundation import Foundation
import SharedCore
private let runtimeReporter = RuntimeReporter.shared
private let requestTimeout: TimeInterval = 30
enum FetchableError: Error { enum FetchableError: Error {
case apiError(Error) case apiError(Error)
@@ -17,6 +21,27 @@ enum FetchableError: Error {
case statusError(Int, String?) 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 { protocol Fetchable {
associatedtype Response: Codable associatedtype Response: Codable
var attachToken: Bool { get } var attachToken: Bool { get }
@@ -36,39 +61,62 @@ extension Fetchable {
} }
var attachToken: Bool { var attachToken: Bool {
return true true
} }
func fetch(completion: @escaping (Result<Response, FetchableError>) -> Void) { func fetch(completion: @escaping (Result<Response, FetchableError>) -> Void) {
let url = URL(string: baseURL+endPoint)! guard let url = URL(string: baseURL + endPoint) else {
completeOnMain(completion, with: .failure(.noData))
return
}
var request = URLRequest(url: url,timeoutInterval: Double.infinity) var request = URLRequest(url: url, timeoutInterval: requestTimeout)
if attachToken { if attachToken {
guard let token = UserStore.shared.token else { 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 return
} }
request.addValue(token, forHTTPHeaderField: "Authorization") request.addValue(token, forHTTPHeaderField: "Authorization")
} }
request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let task = URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
if let error = error { if let error {
completion(.failure(.apiError(error))) runtimeReporter.recordError(
"GET request failed",
metadata: ["url": url.absoluteString, "error": error.localizedDescription]
)
completeOnMain(completion, with: .failure(.apiError(error)))
return 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 return
} }
do { do {
let model = try JSONDecoder().decode(Response.self, from: data) let model = try JSONDecoder().decode(Response.self, from: data)
completion(.success(model)) completeOnMain(completion, with: .success(model))
return
} catch { } catch {
completion(.failure(.decodeError(error))) runtimeReporter.recordError(
return "Failed decoding GET response",
metadata: ["url": url.absoluteString, "error": error.localizedDescription]
)
completeOnMain(completion, with: .failure(.decodeError(error)))
} }
}) })
@@ -77,62 +125,109 @@ extension Fetchable {
} }
extension Postable { extension Postable {
func fetch(completion: @escaping (Result<Response, FetchableError>) -> Void) { func fetch(completion: @escaping (Result<Response, FetchableError>) -> Void) {
guard let postableData = postableData else { guard let postableData else {
completion(.failure(.noPostData)) completeOnMain(completion, with: .failure(.noPostData))
return return
} }
let url = URL(string: baseURL+endPoint)! guard let url = URL(string: baseURL + endPoint) else {
completeOnMain(completion, with: .failure(.noData))
return
}
let postData = try! JSONSerialization.data(withJSONObject:postableData) 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: Double.infinity) var request = URLRequest(url: url, timeoutInterval: requestTimeout)
if attachToken { if attachToken {
guard let token = UserStore.shared.token else { guard let token = UserStore.shared.token else {
completion(.failure(.noPostData)) runtimeReporter.recordWarning("Missing auth token", metadata: ["method": "POST", "endpoint": endPoint])
return completeOnMain(completion, with: .failure(.noToken))
} return
request.addValue(token, forHTTPHeaderField: "Authorization") }
} request.addValue(token, forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type") }
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST" request.httpMethod = "POST"
request.httpBody = postData request.httpBody = postData
let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
if let error = error { if let error {
completion(.failure(.apiError(error))) runtimeReporter.recordError(
"POST request failed",
metadata: ["url": url.absoluteString, "error": error.localizedDescription]
)
completeOnMain(completion, with: .failure(.apiError(error)))
return return
} }
if let httpRespone = response as? HTTPURLResponse { if let httpResponse = response as? HTTPURLResponse,
if httpRespone.statusCode != successStatus { httpResponse.statusCode != successStatus {
var returnStr: String? let responseBody = data.flatMap { String(data: $0, encoding: .utf8) }
if let data = data { handleHTTPFailure(statusCode: httpResponse.statusCode,
returnStr = String(data: data, encoding: .utf8) responseBody: responseBody,
} endpoint: endPoint,
method: "POST")
completion(.failure(.statusError(httpRespone.statusCode, returnStr))) completeOnMain(completion, with: .failure(.statusError(httpResponse.statusCode, responseBody)))
return return
}
} }
guard let data = data else { guard let data else {
completion(.failure(.noData)) runtimeReporter.recordError("POST request returned no data", metadata: ["url": url.absoluteString])
completeOnMain(completion, with: .failure(.noData))
return return
} }
do { do {
let model = try JSONDecoder().decode(Response.self, from: data) let model = try JSONDecoder().decode(Response.self, from: data)
completion(.success(model)) completeOnMain(completion, with: .success(model))
return
} catch { } catch {
completion(.failure(.decodeError(error))) runtimeReporter.recordError(
return "Failed decoding POST response",
metadata: ["url": url.absoluteString, "error": error.localizedDescription]
)
completeOnMain(completion, with: .failure(.decodeError(error)))
} }
}) })
task.resume() task.resume()
} }
} }
private func handleHTTPFailure(statusCode: Int, responseBody: String?, endpoint: String, method: String) {
runtimeReporter.recordError(
"HTTP request failed",
metadata: [
"method": method,
"endpoint": endpoint,
"status_code": "\(statusCode)",
"has_body": responseBody == nil ? "false" : "true"
]
)
UserStore.shared.handleUnauthorizedResponse(statusCode: statusCode, responseBody: responseBody)
}
private func completeOnMain<Response>(
_ completion: @escaping (Result<Response, FetchableError>) -> Void,
with result: Result<Response, FetchableError>
) {
if Thread.isMainThread {
completion(result)
return
}
DispatchQueue.main.async {
completion(result)
}
}

View File

@@ -6,9 +6,11 @@
// //
import CoreData import CoreData
import SharedCore
struct PersistenceController { struct PersistenceController {
static let shared = PersistenceController() static let shared = PersistenceController()
private static let runtimeReporter = RuntimeReporter.shared
static var preview: PersistenceController = { static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true) let result = PersistenceController(inMemory: true)
@@ -20,10 +22,14 @@ struct PersistenceController {
do { do {
try viewContext.save() try viewContext.save()
} catch { } 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 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 return result
}() }()
@@ -33,22 +39,17 @@ struct PersistenceController {
init(inMemory: Bool = false) { init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "Werkout_ios") container = NSPersistentContainer(name: "Werkout_ios")
if inMemory { if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
} }
container.loadPersistentStores(completionHandler: { (storeDescription, error) in container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? { if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately. Self.runtimeReporter.recordError(
// 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. "Failed to load persistent store",
metadata: [
/* "code": "\(error.code)",
Typical reasons for an error here include: "domain": error.domain
* 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)")
} }
}) })
container.viewContext.automaticallyMergesChangesFromParent = true container.viewContext.automaticallyMergesChangesFromParent = true

View File

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

View File

@@ -6,52 +6,143 @@
// //
import Foundation import Foundation
import SharedCore
class UserStore: ObservableObject { class UserStore: ObservableObject {
static let userNameKeychainValue = "username" static let tokenKeychainValue = "auth_token"
static let passwordKeychainValue = "password"
static let userDefaultsRegisteredUserKey = "registeredUserKey" static let userDefaultsRegisteredUserKey = "registeredUserKey"
private static let userDefaultsTokenExpirationKey = "registeredUserTokenExpiration"
static let shared = UserStore() 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? @Published public private(set) var registeredUser: RegisteredUser?
var plannedWorkouts = [PlannedWorkout]() @Published public private(set) var plannedWorkouts = [PlannedWorkout]()
init(registeredUser: RegisteredUser? = nil) { init(registeredUser: RegisteredUser? = nil) {
self.registeredUser = registeredUser self.registeredUser = registeredUser ?? loadPersistedUser()
if let data = UserDefaults.standard.data(forKey: UserStore.userDefaultsRegisteredUserKey),
let model = try? JSONDecoder().decode(RegisteredUser.self, from: data) {
self.registeredUser = model
}
} }
public var token: String? { public var token: String? {
guard let token = registeredUser?.token else { guard let rawToken = normalizedToken(from: registeredUser?.token) else {
return nil 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) { func login(postData: [String: Any], completion: @escaping (Bool)-> Void) {
LoginFetchable(postData: postData).fetch(completion: { result in LoginFetchable(postData: postData).fetch(completion: { result in
switch result { switch result {
case .success(let model): case .success(let model):
if let email = postData["email"] as? String, let sanitizedModel = self.userByReplacingToken(model, token: self.normalizedToken(from: model.token))
let password = postData["password"] as? String,
let data = password.data(using: .utf8) {
try? KeychainInterface.save(password: data,
account: email)
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.registeredUser = model self.registeredUser = sanitizedModel
let data = try! JSONEncoder().encode(model) self.persistRegisteredUser(sanitizedModel)
UserDefaults.standard.set(data, forKey: UserStore.userDefaultsRegisteredUserKey)
completion(true) completion(true)
} }
case .failure(let failure): case .failure(let error):
completion(false) self.runtimeReporter.recordError("Login failed", metadata: ["error": error.localizedDescription])
DispatchQueue.main.async {
completion(false)
}
} }
}) })
} }
@@ -60,26 +151,35 @@ class UserStore: ObservableObject {
RefreshUserInfoFetcable().fetch(completion: { result in RefreshUserInfoFetcable().fetch(completion: { result in
switch result { switch result {
case .success(let registeredUser): case .success(let registeredUser):
let sanitizedModel = self.userByReplacingToken(registeredUser, token: self.normalizedToken(from: registeredUser.token))
DispatchQueue.main.async { DispatchQueue.main.async {
if let data = try? JSONEncoder().encode(registeredUser) { self.persistRegisteredUser(sanitizedModel)
UserDefaults.standard.set(data, forKey: UserStore.userDefaultsRegisteredUserKey) self.registeredUser = sanitizedModel
}
if let data = UserDefaults.standard.data(forKey: UserStore.userDefaultsRegisteredUserKey),
let model = try? JSONDecoder().decode(RegisteredUser.self, from: data) {
self.registeredUser = model
}
} }
case .failure(let failure): case .failure(let failure):
fatalError() self.runtimeReporter.recordError("Failed refreshing user", metadata: ["error": failure.localizedDescription])
} }
}) })
} }
func logout() { func logout() {
logout(reason: "manual_logout")
}
private func logout(reason: String) {
let email = registeredUser?.email
self.registeredUser = nil 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 { DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("CreatedNewWorkout"), object: nil, userInfo: nil) NotificationCenter.default.post(name: AppNotifications.createdNewWorkout, object: nil, userInfo: nil)
} }
} }
@@ -93,8 +193,7 @@ class UserStore: ObservableObject {
case .success(let models): case .success(let models):
self.plannedWorkouts = models self.plannedWorkouts = models
case .failure(let failure): case .failure(let failure):
UserStore.shared.logout() self.runtimeReporter.recordError("Failed fetching planned workouts", metadata: ["error": failure.localizedDescription])
// fatalError("shit broke")
} }
}) })
} }
@@ -111,6 +210,88 @@ class UserStore: ObservableObject {
} }
func setTreyDevRegisterdUser() { func setTreyDevRegisterdUser() {
self.registeredUser = RegisteredUser(id: 1, firstName: "t", lastName: "t", image: nil, nickName: "t", token: "15d7565cde9e8c904ae934f8235f68f6a24b4a03", email: nil, hasNSFWToggle: nil) self.registeredUser = RegisteredUser(id: 1,
firstName: "t",
lastName: "t",
image: nil,
nickName: "t",
token: nil,
email: nil,
hasNSFWToggle: nil)
}
private func migrateLegacyTokenToKeychain(_ token: String) {
guard let tokenData = token.data(using: .utf8) else {
return
}
do {
try KeychainInterface.save(password: tokenData, account: UserStore.tokenKeychainValue)
} catch KeychainInterface.KeychainError.duplicateItem {
try? KeychainInterface.update(password: tokenData, account: UserStore.tokenKeychainValue)
} catch {
runtimeReporter.recordError("Failed migrating legacy token", metadata: ["error": error.localizedDescription])
}
}
private func loadTokenFromKeychain() -> String? {
guard let tokenData = try? KeychainInterface.readPassword(account: UserStore.tokenKeychainValue),
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return normalizedToken(from: token)
}
private func normalizedToken(from rawToken: String?) -> String? {
TokenSecurity.sanitizeToken(rawToken)
}
private func persistTokenExpirationMetadata(token: String?) {
guard let token,
let expirationDate = TokenSecurity.jwtExpiration(token) else {
UserDefaults.standard.removeObject(forKey: UserStore.userDefaultsTokenExpirationKey)
return
}
UserDefaults.standard.set(expirationDate.timeIntervalSince1970, forKey: UserStore.userDefaultsTokenExpirationKey)
}
private func clearPersistedTokenOnly() {
try? KeychainInterface.deletePassword(account: UserStore.tokenKeychainValue)
persistTokenExpirationMetadata(token: nil)
}
private func maybeRefreshTokenIfNearExpiry(_ token: String) {
guard TokenSecurity.shouldRotate(token, rotationWindow: tokenRotationWindow) else {
return
}
authRefreshQueue.async {
let throttleCutoff = Date().addingTimeInterval(-self.tokenRefreshThrottle)
guard self.lastTokenRefreshAttempt <= throttleCutoff else {
return
}
self.lastTokenRefreshAttempt = Date()
self.runtimeReporter.recordInfo("Token nearing expiry; refreshing user")
DispatchQueue.main.async {
self.refreshUserData()
}
}
}
private func isTokenExpired(_ token: String) -> Bool {
TokenSecurity.isExpired(token)
}
private func userByReplacingToken(_ model: RegisteredUser, token: String?) -> RegisteredUser {
RegisteredUser(id: model.id,
firstName: model.firstName,
lastName: model.lastName,
image: model.image,
nickName: model.nickName,
token: token,
email: model.email,
hasNSFWToggle: model.hasNSFWToggle)
} }
} }

View File

@@ -19,7 +19,7 @@ struct AllWorkoutsListView: View {
@Binding var uniqueWorkoutUsers: [RegisteredUser]? @Binding var uniqueWorkoutUsers: [RegisteredUser]?
@State private var filteredRegisterdUser: RegisteredUser? @State private var filteredRegisterdUser: RegisteredUser?
@State var workouts: [Workout] let workouts: [Workout]
let selectedWorkout: ((Workout) -> Void) let selectedWorkout: ((Workout) -> Void)
@State var filteredWorkouts = [Workout]() @State var filteredWorkouts = [Workout]()
var refresh: (() -> Void) var refresh: (() -> Void)
@@ -38,19 +38,23 @@ struct AllWorkoutsListView: View {
uniqueWorkoutUsers: $uniqueWorkoutUsers, uniqueWorkoutUsers: $uniqueWorkoutUsers,
filteredRegisterdUser: $filteredRegisterdUser, filteredRegisterdUser: $filteredRegisterdUser,
filteredWorkouts: $filteredWorkouts, filteredWorkouts: $filteredWorkouts,
workouts: $workouts, workouts: .constant(workouts),
currentSort: $currentSort) currentSort: $currentSort)
} }
ScrollView { ScrollView {
LazyVStack(spacing: 10) { LazyVStack(spacing: 10) {
ForEach(filteredWorkouts, id:\.id) { workout in ForEach(filteredWorkouts, id:\.id) { workout in
WorkoutOverviewView(workout: workout) Button(action: {
.padding([.leading, .trailing], 4) selectedWorkout(workout)
.contentShape(Rectangle()) }, label: {
.onTapGesture { WorkoutOverviewView(workout: workout)
selectedWorkout(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{ .onAppear{
filterWorkouts() filterWorkouts()
} }
.onChange(of: workouts) { _ in
filterWorkouts()
}
} }
func filterWorkouts() { func filterWorkouts() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,8 +10,10 @@ import AVKit
struct ExternalWorkoutDetailView: View { struct ExternalWorkoutDetailView: View {
@StateObject var bridgeModule = BridgeModule.shared @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 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")!) @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.extThotStyle) private var extThotStyle: ThotStyle = .never
@AppStorage(Constants.extShowNextVideo) private var extShowNextVideo: Bool = false @AppStorage(Constants.extShowNextVideo) private var extShowNextVideo: Bool = false
@AppStorage(Constants.thotGenderOption) private var thotGenderOption: String = "female" @AppStorage(Constants.thotGenderOption) private var thotGenderOption: String = "female"
@@ -49,8 +51,8 @@ struct ExternalWorkoutDetailView: View {
.frame(width: metrics.size.width * 0.2, .frame(width: metrics.size.width * 0.2,
height: metrics.size.height * 0.2) height: metrics.size.height * 0.2)
.onAppear{ .onAppear{
avPlayer.isMuted = true smallAVPlayer.isMuted = true
avPlayer.play() smallAVPlayer.play()
} }
} }
} }
@@ -105,6 +107,10 @@ struct ExternalWorkoutDetailView: View {
avPlayer.play() avPlayer.play()
smallAVPlayer.play() smallAVPlayer.play()
} }
.onDisappear {
avPlayer.pause()
smallAVPlayer.pause()
}
} }
func playVideos() { func playVideos() {
@@ -115,8 +121,7 @@ struct ExternalWorkoutDetailView: View {
defaultVideoURLStr: currentExtercise.exercise.videoURL, defaultVideoURLStr: currentExtercise.exercise.videoURL,
exerciseName: currentExtercise.exercise.name, exerciseName: currentExtercise.exercise.name,
workout: bridgeModule.currentWorkoutInfo.workout) { workout: bridgeModule.currentWorkoutInfo.workout) {
avPlayer = AVPlayer(url: videoURL) updateMainPlayer(for: videoURL)
avPlayer.play()
} }
if let smallVideoURL = VideoURLCreator.videoURL( if let smallVideoURL = VideoURLCreator.videoURL(
@@ -126,11 +131,34 @@ struct ExternalWorkoutDetailView: View {
exerciseName: BridgeModule.shared.currentWorkoutInfo.nextExerciseInfo?.exercise.name, exerciseName: BridgeModule.shared.currentWorkoutInfo.nextExerciseInfo?.exercise.name,
workout: bridgeModule.currentWorkoutInfo.workout), workout: bridgeModule.currentWorkoutInfo.workout),
extShowNextVideo { extShowNextVideo {
smallAVPlayer = AVPlayer(url: smallVideoURL) updateSmallPlayer(for: smallVideoURL)
smallAVPlayer.play()
} }
} }
} }
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 { //struct ExternalWorkoutDetailView_Previews: PreviewProvider {

View File

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

View File

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

View File

@@ -11,7 +11,8 @@ import AVKit
struct ExerciseListView: View { struct ExerciseListView: View {
@AppStorage(Constants.phoneThotStyle) private var phoneThotStyle: ThotStyle = .never @AppStorage(Constants.phoneThotStyle) private var phoneThotStyle: ThotStyle = .never
@ObservedObject var bridgeModule = BridgeModule.shared @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 var workout: Workout
@Binding var showExecersizeInfo: Bool @Binding var showExecersizeInfo: Bool
@AppStorage(Constants.thotGenderOption) private var thotGenderOption: String = "female" @AppStorage(Constants.thotGenderOption) private var thotGenderOption: String = "female"
@@ -24,15 +25,14 @@ struct ExerciseListView: View {
defaultVideoURLStr: self.videoExercise?.videoURL, defaultVideoURLStr: self.videoExercise?.videoURL,
exerciseName: self.videoExercise?.name, exerciseName: self.videoExercise?.name,
workout: bridgeModule.currentWorkoutInfo.workout) { workout: bridgeModule.currentWorkoutInfo.workout) {
avPlayer = AVPlayer(url: videoURL) updatePreviewPlayer(for: videoURL)
avPlayer.isMuted = true
avPlayer.play()
} }
} }
} }
var body: some View { 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 ScrollViewReader { proxy in
List() { List() {
ForEach(supersets.indices, id: \.self) { supersetIndex in ForEach(supersets.indices, id: \.self) { supersetIndex in
@@ -40,58 +40,13 @@ struct ExerciseListView: View {
Section(content: { Section(content: {
ForEach(superset.exercises.indices, id: \.self) { exerciseIndex in ForEach(superset.exercises.indices, id: \.self) { exerciseIndex in
let supersetExecercise = superset.exercises[exerciseIndex] let supersetExecercise = superset.exercises[exerciseIndex]
let rowID = rowIdentifier(
supersetIndex: supersetIndex,
exerciseIndex: exerciseIndex,
exercise: supersetExecercise
)
VStack { VStack {
HStack { Button(action: {
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 {
if bridgeModule.isInWorkout { if bridgeModule.isInWorkout {
bridgeModule.currentWorkoutInfo.goToExerciseAt( bridgeModule.currentWorkoutInfo.goToExerciseAt(
supersetIndex: supersetIndex, supersetIndex: supersetIndex,
@@ -99,7 +54,61 @@ struct ExerciseListView: View {
} else { } else {
videoExercise = supersetExecercise.exercise 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 && if bridgeModule.isInWorkout &&
supersetIndex == bridgeModule.currentWorkoutInfo.supersetIndex && supersetIndex == bridgeModule.currentWorkoutInfo.supersetIndex &&
@@ -107,7 +116,7 @@ struct ExerciseListView: View {
showExecersizeInfo { showExecersizeInfo {
detailView(forExercise: supersetExecercise) detailView(forExercise: supersetExecercise)
} }
}.id(supersetExecercise.id) }.id(rowID)
} }
}, header: { }, header: {
HStack { HStack {
@@ -131,7 +140,16 @@ struct ExerciseListView: View {
.onChange(of: bridgeModule.currentWorkoutInfo.allSupersetExecerciseIndex, perform: { newValue in .onChange(of: bridgeModule.currentWorkoutInfo.allSupersetExecerciseIndex, perform: { newValue in
if let newCurrentExercise = bridgeModule.currentWorkoutInfo.currentExercise { if let newCurrentExercise = bridgeModule.currentWorkoutInfo.currentExercise {
withAnimation { 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,10 +160,37 @@ struct ExerciseListView: View {
avPlayer.play() 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 { func detailView(forExercise supersetExecercise: SupersetExercise) -> some View {
VStack { VStack {
Text(supersetExecercise.exercise.description) Text(supersetExecercise.exercise.description)

View File

@@ -10,7 +10,8 @@ import AVKit
struct WorkoutDetailView: View { struct WorkoutDetailView: View {
@StateObject var viewModel: WorkoutDetailViewModel @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 @StateObject var bridgeModule = BridgeModule.shared
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@@ -31,6 +32,16 @@ struct WorkoutDetailView: View {
switch viewModel.status { switch viewModel.status {
case .loading: case .loading:
Text("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): case .showWorkout(let workout):
VStack(spacing: 0) { VStack(spacing: 0) {
if bridgeModule.isInWorkout { if bridgeModule.isInWorkout {
@@ -59,9 +70,7 @@ struct WorkoutDetailView: View {
defaultVideoURLStr: currentExtercise.exercise.videoURL, defaultVideoURLStr: currentExtercise.exercise.videoURL,
exerciseName: currentExtercise.exercise.name, exerciseName: currentExtercise.exercise.name,
workout: bridgeModule.currentWorkoutInfo.workout) { workout: bridgeModule.currentWorkoutInfo.workout) {
avPlayer = AVPlayer(url: otherVideoURL) updatePlayer(for: otherVideoURL)
avPlayer.isMuted = true
avPlayer.play()
} }
}, label: { }, label: {
Image(systemName: "arrow.triangle.2.circlepath.camera.fill") Image(systemName: "arrow.triangle.2.circlepath.camera.fill")
@@ -72,6 +81,8 @@ struct WorkoutDetailView: View {
.cornerRadius(Constants.buttonRadius) .cornerRadius(Constants.buttonRadius)
.frame(width: 160, height: 120) .frame(width: 160, height: 120)
.position(x: metrics.size.width - 22, y: metrics.size.height - 30) .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: { Button(action: {
showExecersizeInfo.toggle() showExecersizeInfo.toggle()
@@ -84,6 +95,8 @@ struct WorkoutDetailView: View {
.cornerRadius(Constants.buttonRadius) .cornerRadius(Constants.buttonRadius)
.frame(width: 120, height: 120) .frame(width: 120, height: 120)
.position(x: 22, y: metrics.size.height - 30) .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]) .padding([.top, .bottom])
@@ -109,7 +122,7 @@ struct WorkoutDetailView: View {
CurrentWorkoutElapsedTimeView() CurrentWorkoutElapsedTimeView()
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
Text("\(bridgeModule.currentWorkoutInfo.allSupersetExecerciseIndex+1)/\(bridgeModule.currentWorkoutInfo.workout?.allSupersetExecercise?.count ?? -99)") Text(progressText)
.font(.title3) .font(.title3)
.bold() .bold()
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
@@ -163,18 +176,7 @@ struct WorkoutDetailView: View {
playVideos() playVideos()
}) })
.onAppear{ .onAppear{
if let currentExtercise = bridgeModule.currentWorkoutInfo.currentExercise { playVideos()
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()
}
}
bridgeModule.completedWorkout = { bridgeModule.completedWorkout = {
if let workoutData = createWorkoutData() { if let workoutData = createWorkoutData() {
@@ -187,6 +189,10 @@ struct WorkoutDetailView: View {
avPlayer.isMuted = true avPlayer.isMuted = true
avPlayer.play() avPlayer.play()
} }
.onDisappear {
avPlayer.pause()
bridgeModule.completedWorkout = nil
}
} }
func playVideos() { func playVideos() {
@@ -197,17 +203,39 @@ struct WorkoutDetailView: View {
defaultVideoURLStr: currentExtercise.exercise.videoURL, defaultVideoURLStr: currentExtercise.exercise.videoURL,
exerciseName: currentExtercise.exercise.name, exerciseName: currentExtercise.exercise.name,
workout: bridgeModule.currentWorkoutInfo.workout) { workout: bridgeModule.currentWorkoutInfo.workout) {
avPlayer = AVPlayer(url: videoURL) updatePlayer(for: videoURL)
avPlayer.isMuted = true
avPlayer.play()
} }
} }
} }
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) { func startWorkout(workout: Workout) {
bridgeModule.start(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]? { func createWorkoutData() -> [String:Any]? {
guard let workoutid = bridgeModule.currentWorkoutInfo.workout?.id, guard let workoutid = bridgeModule.currentWorkoutInfo.workout?.id,
let startTime = bridgeModule.workoutStartDate?.timeFormatForUpload, let startTime = bridgeModule.workoutStartDate?.timeFormatForUpload,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,12 +14,13 @@ struct AllExerciseView: View {
@Binding var filteredExercises: [Exercise] @Binding var filteredExercises: [Exercise]
var selectedExercise: ((Exercise) -> Void) 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? { @State var videoExercise: Exercise? {
didSet { didSet {
if let viddd = self.videoExercise?.videoURL, if let viddd = self.videoExercise?.videoURL,
let url = URL(string: BaseURLs.currentBaseURL + viddd) { 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) Text(exercise.name)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
if exercise.side != nil && !exercise.side!.isEmpty { if let side = exercise.side,
Text(exercise.side!) side.isEmpty == false {
Text(side)
.font(.footnote) .font(.footnote)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
if exercise.equipmentRequired != nil && !exercise.equipmentRequired!.isEmpty { if exercise.equipmentRequired?.isEmpty == false {
Text(exercise.spacedEquipmentRequired) Text(exercise.spacedEquipmentRequired)
.font(.footnote) .font(.footnote)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
if exercise.muscleGroups != nil && !exercise.muscleGroups!.isEmpty { if exercise.muscleGroups?.isEmpty == false {
Text(exercise.spacedMuscleGroups) Text(exercise.spacedMuscleGroups)
.font(.footnote) .font(.footnote)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -86,6 +88,23 @@ struct AllExerciseView: View {
avPlayer.play() avPlayer.play()
} }
} }
.onDisappear {
avPlayer.pause()
}
}
private func updatePlayer(for url: URL) {
if currentVideoURL == url {
avPlayer.seek(to: .zero)
avPlayer.isMuted = true
avPlayer.play()
return
}
currentVideoURL = url
avPlayer = AVPlayer(url: url)
avPlayer.isMuted = true
avPlayer.play()
} }
} }
// //

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ import Foundation
import WatchConnectivity import WatchConnectivity
import SwiftUI import SwiftUI
import HealthKit import HealthKit
import os
import SharedCore
extension WatchMainViewModel: WCSessionDelegate { extension WatchMainViewModel: WCSessionDelegate {
func session(_ session: WCSession, didReceiveMessageData messageData: Data) { func session(_ session: WCSession, didReceiveMessageData messageData: Data) {
@@ -16,27 +18,87 @@ extension WatchMainViewModel: WCSessionDelegate {
} }
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { 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 { 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) { 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 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) #if os(iOS)
guard WCSession.default.isWatchAppInstalled else { guard session.isWatchAppInstalled else {
return return
} }
#else #else
guard WCSession.default.isCompanionAppInstalled else { guard session.isCompanionAppInstalled else {
return return
} }
#endif #endif
WCSession.default.sendMessageData(data, replyHandler: nil)
{ error in if session.isReachable {
print("Cannot send message: \(String(describing: error))") session.sendMessageData(data, replyHandler: nil) { error in
logger.error("Cannot send watch message: \(error.localizedDescription, privacy: .public)")
queue.async {
enqueue(data)
}
}
} else {
session.transferUserInfo(["package": data])
}
}
private static func enqueue(_ data: Data) {
let droppedCount = queuedPayloads.enqueue(data)
if droppedCount > 0 {
logger.warning("Dropping oldest queued watch payload to enforce queue cap")
} }
} }
} }

View File

@@ -9,9 +9,12 @@ import Foundation
import WatchConnectivity import WatchConnectivity
import SwiftUI import SwiftUI
import HealthKit import HealthKit
import os
import SharedCore
class WatchMainViewModel: NSObject, ObservableObject { class WatchMainViewModel: NSObject, ObservableObject {
static let shared = WatchMainViewModel() static let shared = WatchMainViewModel()
let logger = Logger(subsystem: "com.werkout.watch", category: "session")
var session: WCSession var session: WCSession
@@ -30,45 +33,45 @@ class WatchMainViewModel: NSObject, ObservableObject {
} }
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 // actions from view
func nextExercise() { func nextExercise() {
let nextExerciseAction = WatchActions.nextExercise send(.nextExercise)
let data = try! JSONEncoder().encode(nextExerciseAction)
DataSender.send(data)
WKInterfaceDevice.current().play(.start) WKInterfaceDevice.current().play(.start)
} }
func restartExercise() { func restartExercise() {
let nextExerciseAction = WatchActions.restartExercise send(.restartExercise)
let data = try! JSONEncoder().encode(nextExerciseAction)
DataSender.send(data)
WKInterfaceDevice.current().play(.start) WKInterfaceDevice.current().play(.start)
} }
func previousExercise() { func previousExercise() {
let nextExerciseAction = WatchActions.previousExercise send(.previousExercise)
let data = try! JSONEncoder().encode(nextExerciseAction)
DataSender.send(data)
WKInterfaceDevice.current().play(.start) WKInterfaceDevice.current().play(.start)
} }
func completeWorkout() { func completeWorkout() {
let nextExerciseAction = WatchActions.stopWorkout send(.stopWorkout)
let data = try! JSONEncoder().encode(nextExerciseAction)
DataSender.send(data)
WKInterfaceDevice.current().play(.start) WKInterfaceDevice.current().play(.start)
} }
func pauseWorkout() { func pauseWorkout() {
let nextExerciseAction = WatchActions.pauseWorkout send(.pauseWorkout)
let data = try! JSONEncoder().encode(nextExerciseAction)
DataSender.send(data)
WKInterfaceDevice.current().play(.start) WKInterfaceDevice.current().play(.start)
WatchWorkout.shared.togglePaused() WatchWorkout.shared.togglePaused()
} }
func dataToAction(messageData: Data) { 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 { DispatchQueue.main.async {
switch model { switch model {
case .inExercise(let newWatchPackageModel): case .inExercise(let newWatchPackageModel):
@@ -87,6 +90,8 @@ class WatchMainViewModel: NSObject, ObservableObject {
WatchWorkout.shared.startWorkout() WatchWorkout.shared.startWorkout()
} }
} }
} catch {
logger.error("Rejected PhoneToWatchActions payload: \(error.localizedDescription, privacy: .public)")
} }
} }
} }

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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