Compare commits
19 Commits
watch_fix_
...
redesign
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d39dcb66f | ||
|
|
921829f2c3 | ||
|
|
acce712261 | ||
|
|
e40275e694 | ||
|
|
523f791e6e | ||
|
|
424afc00f6 | ||
|
|
3a23e6a223 | ||
|
|
8cafb39a65 | ||
|
|
ca25d61019 | ||
|
|
fb22f47a82 | ||
|
|
85ef3d58c6 | ||
|
|
b268a271fd | ||
|
|
5145896f7a | ||
|
|
4b6352b8fd | ||
|
|
63b7a2daa5 | ||
|
|
abb9feed14 | ||
|
|
2d5d55c272 | ||
|
|
bfb373c8af | ||
|
|
cedab87193 |
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(du -sh:*)",
|
||||||
|
"Bash(./scripts/smoke/build_ios.sh:*)",
|
||||||
|
"Bash(find .:*)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(tail:*)",
|
||||||
|
"Bash(head:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
21
.github/workflows/apple-platform-ci.yml
vendored
Normal file
21
.github/workflows/apple-platform-ci.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: Apple Platform CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
smoke-and-tests:
|
||||||
|
runs-on: macos-15
|
||||||
|
timeout-minutes: 45
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Xcode Version
|
||||||
|
run: xcodebuild -version
|
||||||
|
|
||||||
|
- name: Run smoke suite
|
||||||
|
run: ./scripts/smoke/smoke_all.sh
|
||||||
83
CLAUDE.md
Normal file
83
CLAUDE.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
|
||||||
|
**Full smoke suite** (token scan + SharedCore tests + all platform builds):
|
||||||
|
```bash
|
||||||
|
./scripts/smoke/smoke_all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**SharedCore unit tests only:**
|
||||||
|
```bash
|
||||||
|
cd SharedCore && swift test --disable-sandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
**Individual platform builds** (no code signing):
|
||||||
|
```bash
|
||||||
|
./scripts/smoke/build_ios.sh
|
||||||
|
./scripts/smoke/build_watch.sh
|
||||||
|
./scripts/smoke/build_tvos.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build iOS via xcodebuild directly:**
|
||||||
|
```bash
|
||||||
|
xcodebuild -project iphone/Werkout_ios.xcodeproj \
|
||||||
|
-scheme 'Werkout_ios' -configuration Debug \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
CODE_SIGNING_ALLOWED=NO build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schemes:** `Werkout_ios` (iOS), `Werkout_watch Watch App` (watchOS), `WekoutThotViewer` (tvOS)
|
||||||
|
|
||||||
|
**Workspace:** `Werkout.xcworkspace` contains the iOS/watchOS project and tvOS project.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Multi-Platform Structure
|
||||||
|
|
||||||
|
- **`iphone/`** — Main iOS app and watchOS companion app (same Xcode project: `Werkout_ios.xcodeproj`)
|
||||||
|
- **`SharedCore/`** — Swift Package (v5.9) with cross-platform code: data models, validation, token security, runtime reporting, utilities. Has per-platform test targets (`SharedCoreiOSTests`, `SharedCoreWatchOSTests`, `SharedCoreTVOSTests`).
|
||||||
|
- **`WekoutThotViewer/`** — Standalone tvOS app (separate Xcode project)
|
||||||
|
|
||||||
|
Platform minimums: iOS 16+, watchOS 9+, tvOS 16+, macOS 13+.
|
||||||
|
|
||||||
|
### iOS App (`iphone/Werkout_ios/`)
|
||||||
|
|
||||||
|
SwiftUI app with MVVM pattern. Three-tab navigation: All Workouts, Create Workout, Account.
|
||||||
|
|
||||||
|
**Core singletons:**
|
||||||
|
- `UserStore` — Auth state, token lifecycle (Keychain storage with legacy migration), login/logout, planned workouts
|
||||||
|
- `DataStore` — Cached workouts/exercises/muscles/equipment, parallel fetching via DispatchGroup
|
||||||
|
- `BridgeModule` — iPhone↔Watch communication via WCSession, workout timers, external display (AirPlay) support
|
||||||
|
|
||||||
|
**Networking layer** (`Network/`):
|
||||||
|
- Protocol-based: `Fetchable` (GET) and `Postable` (POST) protocols in `Network.swift`
|
||||||
|
- Concrete implementations in `Fetchables.swift` (e.g. `AllWorkoutFetchable`, `LoginFetchable`, `CreateWorkoutFetchable`)
|
||||||
|
- Auto-attaches auth token; 30-second timeout; error handling via `FetchableError` enum
|
||||||
|
|
||||||
|
**Data persistence:**
|
||||||
|
- CoreData (`Werkout_ios.xcdatamodeld`, managed by `Persistence.swift`)
|
||||||
|
- Keychain for token storage (`Keychain.swift`)
|
||||||
|
- UserDefaults for preferences and token metadata
|
||||||
|
|
||||||
|
**Key integrations:** HealthKit (calories, heart rate, workout sessions), AVFoundation (audio/video playback), WatchConnectivity
|
||||||
|
|
||||||
|
### watchOS App (`iphone/Werkout_watch Watch App/`)
|
||||||
|
|
||||||
|
Companion app with HealthKit workout session management (`WatchWorkout`), timer display, exercise controls. Communicates with iPhone via `BridgeModule` using action-based message passing (`WatchActions`, `PhoneToWatchActions`).
|
||||||
|
|
||||||
|
### View Organization
|
||||||
|
|
||||||
|
Views live in `iphone/Werkout_ios/Views/` grouped by feature (AllWorkouts, CreateWorkout, Login, WorkoutDetail, etc.). Shared subviews are in `iphone/Werkout_ios/subview/`.
|
||||||
|
|
||||||
|
### Data Flow Patterns
|
||||||
|
|
||||||
|
- Workout creation: `WorkoutViewModel` validates → `CreateWorkoutFetchable` POSTs → `AppNotifications.createdNewWorkout` triggers refresh
|
||||||
|
- Auth: `LoginFetchable` → `UserStore` stores token in Keychain → all requests auto-attach token → 401/403 triggers logout → proactive refresh 30min before expiry
|
||||||
|
- Watch sync: `BridgeModule` queues messages via `BoundedFIFOQueue` (max 100) over WCSession
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
GitHub Actions (`.github/workflows/apple-platform-ci.yml`) runs on macOS 15 for all pushes/PRs. Executes `smoke_all.sh` which runs token security scan, SharedCore tests, and builds all three platforms. Build scripts fail on any warnings or errors.
|
||||||
2
SharedCore/.gitignore
vendored
Normal file
2
SharedCore/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
36
SharedCore/Package.swift
Normal file
36
SharedCore/Package.swift
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// swift-tools-version: 6.2
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "SharedCore",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v26),
|
||||||
|
.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"]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
swiftLanguageModes: [.v5]
|
||||||
|
)
|
||||||
7
SharedCore/Sources/SharedCore/AppNotifications.swift
Normal file
7
SharedCore/Sources/SharedCore/AppNotifications.swift
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum AppNotifications {
|
||||||
|
public static let createdNewWorkout = Notification.Name("CreatedNewWorkout")
|
||||||
|
public static let userLoggedOut = Notification.Name("UserLoggedOut")
|
||||||
|
}
|
||||||
|
|
||||||
40
SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift
Normal file
40
SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct BoundedFIFOQueue<Element> {
|
||||||
|
private var storage: [Element] = []
|
||||||
|
public let maxCount: Int
|
||||||
|
|
||||||
|
public init(maxCount: Int) {
|
||||||
|
self.maxCount = max(1, maxCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var count: Int {
|
||||||
|
storage.count
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isEmpty: Bool {
|
||||||
|
storage.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public mutating func enqueue(_ element: Element) -> Int {
|
||||||
|
var droppedCount = 0
|
||||||
|
if storage.count >= maxCount {
|
||||||
|
droppedCount = storage.count - maxCount + 1
|
||||||
|
storage.removeFirst(droppedCount)
|
||||||
|
}
|
||||||
|
storage.append(element)
|
||||||
|
return droppedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func dequeueAll() -> [Element] {
|
||||||
|
let elements = storage
|
||||||
|
storage.removeAll()
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func clear() {
|
||||||
|
storage.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
76
SharedCore/Sources/SharedCore/RuntimeReporting.swift
Normal file
76
SharedCore/Sources/SharedCore/RuntimeReporting.swift
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
public enum RuntimeSeverity: String, Sendable {
|
||||||
|
case info
|
||||||
|
case warning
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RuntimeEvent: Sendable {
|
||||||
|
public let severity: RuntimeSeverity
|
||||||
|
public let message: String
|
||||||
|
public let metadata: [String: String]
|
||||||
|
public let timestamp: Date
|
||||||
|
|
||||||
|
public init(severity: RuntimeSeverity, message: String, metadata: [String: String], timestamp: Date = Date()) {
|
||||||
|
self.severity = severity
|
||||||
|
self.message = message
|
||||||
|
self.metadata = metadata
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class RuntimeReporter {
|
||||||
|
public typealias Sink = @Sendable (RuntimeEvent) -> Void
|
||||||
|
|
||||||
|
public static let shared = RuntimeReporter()
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.werkout.sharedcore", category: "runtime")
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var sink: Sink?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
public func setSink(_ sink: Sink?) {
|
||||||
|
lock.lock()
|
||||||
|
self.sink = sink
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func recordError(_ message: String, metadata: [String: String] = [:]) {
|
||||||
|
record(.error, message: message, metadata: metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func recordWarning(_ message: String, metadata: [String: String] = [:]) {
|
||||||
|
record(.warning, message: message, metadata: metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func recordInfo(_ message: String, metadata: [String: String] = [:]) {
|
||||||
|
record(.info, message: message, metadata: metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func record(_ severity: RuntimeSeverity, message: String, metadata: [String: String]) {
|
||||||
|
let flattenedMetadata = metadata
|
||||||
|
.map { "\($0.key)=\($0.value)" }
|
||||||
|
.sorted()
|
||||||
|
.joined(separator: ",")
|
||||||
|
|
||||||
|
let logMessage = flattenedMetadata.isEmpty ? message : "\(message) | \(flattenedMetadata)"
|
||||||
|
|
||||||
|
switch severity {
|
||||||
|
case .info:
|
||||||
|
logger.info("\(logMessage, privacy: .public)")
|
||||||
|
case .warning:
|
||||||
|
logger.warning("\(logMessage, privacy: .public)")
|
||||||
|
case .error:
|
||||||
|
logger.error("\(logMessage, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
lock.lock()
|
||||||
|
let sink = self.sink
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
sink?(RuntimeEvent(severity: severity, message: message, metadata: metadata))
|
||||||
|
}
|
||||||
|
}
|
||||||
103
SharedCore/Sources/SharedCore/TokenSecurity.swift
Normal file
103
SharedCore/Sources/SharedCore/TokenSecurity.swift
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum TokenSecurity {
|
||||||
|
public static let defaultRotationWindow: TimeInterval = 60 * 60
|
||||||
|
private static let authPrefixes = ["token", "bearer"]
|
||||||
|
|
||||||
|
// Basic high-entropy hex token detector for accidental commits.
|
||||||
|
private static let tokenRegex = try? NSRegularExpression(pattern: "\\b[a-fA-F0-9]{32,}\\b")
|
||||||
|
|
||||||
|
public static func containsPotentialHardcodedToken(in text: String) -> Bool {
|
||||||
|
guard let tokenRegex else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let range = NSRange(location: 0, length: text.utf16.count)
|
||||||
|
return tokenRegex.firstMatch(in: text, options: [], range: range) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func isRedactedToken(_ token: String?) -> Bool {
|
||||||
|
guard let token else { return false }
|
||||||
|
let upper = token.uppercased()
|
||||||
|
return upper.contains("REDACTED") || upper.contains("YOUR_TOKEN") || upper.contains("PLACEHOLDER")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func sanitizeToken(_ token: String?) -> String? {
|
||||||
|
guard let rawToken = token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
rawToken.isEmpty == false else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = normalizeAuthPrefix(rawToken)
|
||||||
|
guard normalized.isEmpty == false,
|
||||||
|
isRedactedToken(normalized) == false else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func jwtExpiration(_ token: String) -> Date? {
|
||||||
|
let segments = token.split(separator: ".")
|
||||||
|
guard segments.count == 3 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let payloadSegment = String(segments[1])
|
||||||
|
guard let payloadData = base64URLDecode(payloadSegment),
|
||||||
|
let object = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
|
||||||
|
let exp = object["exp"] as? TimeInterval else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return Date(timeIntervalSince1970: exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func isExpired(_ token: String?, now: Date = Date()) -> Bool {
|
||||||
|
guard let token = sanitizeToken(token) else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let expiration = jwtExpiration(token) else {
|
||||||
|
// Non-JWT tokens cannot be locally validated for expiry.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return expiration <= now
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func shouldRotate(_ token: String?, now: Date = Date(), rotationWindow: TimeInterval = defaultRotationWindow) -> Bool {
|
||||||
|
guard let token = sanitizeToken(token),
|
||||||
|
let expiration = jwtExpiration(token) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return expiration.timeIntervalSince(now) <= rotationWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func base64URLDecode(_ input: String) -> Data? {
|
||||||
|
var value = input
|
||||||
|
.replacingOccurrences(of: "-", with: "+")
|
||||||
|
.replacingOccurrences(of: "_", with: "/")
|
||||||
|
|
||||||
|
let remainder = value.count % 4
|
||||||
|
if remainder > 0 {
|
||||||
|
value.append(String(repeating: "=", count: 4 - remainder))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Data(base64Encoded: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeAuthPrefix(_ token: String) -> String {
|
||||||
|
let lowercased = token.lowercased()
|
||||||
|
for prefix in authPrefixes {
|
||||||
|
if lowercased == prefix {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefixed = "\(prefix) "
|
||||||
|
if lowercased.hasPrefix(prefixed) {
|
||||||
|
return String(token.dropFirst(prefixed.count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
40
SharedCore/Sources/SharedCore/WatchPayloadValidation.swift
Normal file
40
SharedCore/Sources/SharedCore/WatchPayloadValidation.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum WatchPayloadValidationError: Error, Equatable {
|
||||||
|
case emptyPayload
|
||||||
|
case payloadTooLarge(actualBytes: Int, maxBytes: Int)
|
||||||
|
case decodeFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum WatchPayloadValidation {
|
||||||
|
public static let defaultMaxPayloadBytes = 256 * 1024
|
||||||
|
|
||||||
|
public static func validate(_ payload: Data, maxBytes: Int = defaultMaxPayloadBytes) -> WatchPayloadValidationError? {
|
||||||
|
guard payload.isEmpty == false else {
|
||||||
|
return .emptyPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
guard payload.count <= maxBytes else {
|
||||||
|
return .payloadTooLarge(actualBytes: payload.count, maxBytes: maxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decode<T: Decodable>(
|
||||||
|
_ type: T.Type,
|
||||||
|
from payload: Data,
|
||||||
|
maxBytes: Int = defaultMaxPayloadBytes,
|
||||||
|
decoder: JSONDecoder = JSONDecoder()
|
||||||
|
) throws -> T {
|
||||||
|
if let validationError = validate(payload, maxBytes: maxBytes) {
|
||||||
|
throw validationError
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try decoder.decode(T.self, from: payload)
|
||||||
|
} catch {
|
||||||
|
throw WatchPayloadValidationError.decodeFailure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
SharedCore/Sources/SharedCore/WorkoutValidation.swift
Normal file
50
SharedCore/Sources/SharedCore/WorkoutValidation.swift
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct WorkoutValidationIssue: Equatable {
|
||||||
|
public let code: String
|
||||||
|
public let message: String
|
||||||
|
|
||||||
|
public init(code: String, message: String) {
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum WorkoutValidation {
|
||||||
|
public static func validateSupersets(_ supersets: [[String: Any]]) -> [WorkoutValidationIssue] {
|
||||||
|
var issues = [WorkoutValidationIssue]()
|
||||||
|
|
||||||
|
if supersets.isEmpty {
|
||||||
|
issues.append(WorkoutValidationIssue(code: "empty_supersets", message: "Workout requires at least one superset."))
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
for (supersetIndex, superset) in supersets.enumerated() {
|
||||||
|
let rounds = superset["rounds"] as? Int ?? 0
|
||||||
|
if rounds <= 0 {
|
||||||
|
issues.append(WorkoutValidationIssue(code: "invalid_rounds", message: "Superset \(supersetIndex + 1) must have at least one round."))
|
||||||
|
}
|
||||||
|
|
||||||
|
let exercises = superset["exercises"] as? [[String: Any]] ?? []
|
||||||
|
if exercises.isEmpty {
|
||||||
|
issues.append(WorkoutValidationIssue(code: "empty_exercises", message: "Superset \(supersetIndex + 1) must contain at least one exercise."))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (exerciseIndex, exercise) in exercises.enumerated() {
|
||||||
|
let reps = exercise["reps"] as? Int ?? 0
|
||||||
|
let duration = exercise["duration"] as? Int ?? 0
|
||||||
|
if reps <= 0 && duration <= 0 {
|
||||||
|
issues.append(
|
||||||
|
WorkoutValidationIssue(
|
||||||
|
code: "invalid_exercise_payload",
|
||||||
|
message: "Exercise \(exerciseIndex + 1) in superset \(supersetIndex + 1) needs reps or duration."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import SharedCore
|
||||||
|
|
||||||
|
final class RuntimeReporterTests: XCTestCase {
|
||||||
|
func testReporterInvokesSinkWithMetadata() {
|
||||||
|
let expectation = expectation(description: "sink called")
|
||||||
|
|
||||||
|
RuntimeReporter.shared.setSink { event in
|
||||||
|
XCTAssertEqual(event.severity, .error)
|
||||||
|
XCTAssertEqual(event.message, "network failure")
|
||||||
|
XCTAssertEqual(event.metadata["status"], "500")
|
||||||
|
expectation.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
RuntimeReporter.shared.recordError("network failure", metadata: ["status": "500"])
|
||||||
|
|
||||||
|
waitForExpectations(timeout: 1.0)
|
||||||
|
RuntimeReporter.shared.setSink(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import SharedCore
|
||||||
|
|
||||||
|
final class BoundedFIFOQueueTests: XCTestCase {
|
||||||
|
func testDisconnectReconnectFlushPreservesOrder() {
|
||||||
|
var queue = BoundedFIFOQueue<Int>(maxCount: 5)
|
||||||
|
|
||||||
|
_ = queue.enqueue(10)
|
||||||
|
_ = queue.enqueue(20)
|
||||||
|
_ = queue.enqueue(30)
|
||||||
|
|
||||||
|
XCTAssertEqual(queue.dequeueAll(), [10, 20, 30])
|
||||||
|
XCTAssertTrue(queue.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOverflowDropsOldestPayloads() {
|
||||||
|
var queue = BoundedFIFOQueue<Int>(maxCount: 3)
|
||||||
|
|
||||||
|
XCTAssertEqual(queue.enqueue(1), 0)
|
||||||
|
XCTAssertEqual(queue.enqueue(2), 0)
|
||||||
|
XCTAssertEqual(queue.enqueue(3), 0)
|
||||||
|
XCTAssertEqual(queue.enqueue(4), 1)
|
||||||
|
XCTAssertEqual(queue.enqueue(5), 1)
|
||||||
|
|
||||||
|
XCTAssertEqual(queue.dequeueAll(), [3, 4, 5])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMaxCountHasLowerBoundOfOne() {
|
||||||
|
var queue = BoundedFIFOQueue<Int>(maxCount: 0)
|
||||||
|
|
||||||
|
_ = queue.enqueue(1)
|
||||||
|
XCTAssertEqual(queue.enqueue(2), 1)
|
||||||
|
XCTAssertEqual(queue.dequeueAll(), [2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import SharedCore
|
||||||
|
|
||||||
|
final class WatchPayloadValidationTests: XCTestCase {
|
||||||
|
private struct MockPayload: Codable, Equatable {
|
||||||
|
let name: String
|
||||||
|
let count: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValidateRejectsEmptyPayload() {
|
||||||
|
let error = WatchPayloadValidation.validate(Data())
|
||||||
|
XCTAssertEqual(error, .emptyPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValidateRejectsOversizedPayload() {
|
||||||
|
let payload = Data(repeating: 0, count: 9)
|
||||||
|
let error = WatchPayloadValidation.validate(payload, maxBytes: 8)
|
||||||
|
XCTAssertEqual(error, .payloadTooLarge(actualBytes: 9, maxBytes: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeAcceptsValidPayload() throws {
|
||||||
|
let payload = try JSONEncoder().encode(MockPayload(name: "set", count: 12))
|
||||||
|
let decoded = try WatchPayloadValidation.decode(MockPayload.self, from: payload)
|
||||||
|
XCTAssertEqual(decoded, MockPayload(name: "set", count: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeRejectsInvalidJSON() throws {
|
||||||
|
let invalid = Data("not-json".utf8)
|
||||||
|
|
||||||
|
XCTAssertThrowsError(try WatchPayloadValidation.decode(MockPayload.self, from: invalid)) { error in
|
||||||
|
XCTAssertEqual(error as? WatchPayloadValidationError, .decodeFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecodeRejectsOversizedPayloadBeforeDecoding() {
|
||||||
|
let payload = Data(repeating: 1, count: 32)
|
||||||
|
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try WatchPayloadValidation.decode(MockPayload.self, from: payload, maxBytes: 16)
|
||||||
|
) { error in
|
||||||
|
XCTAssertEqual(
|
||||||
|
error as? WatchPayloadValidationError,
|
||||||
|
.payloadTooLarge(actualBytes: 32, maxBytes: 16)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import SharedCore
|
||||||
|
|
||||||
|
final class WorkoutValidationTests: XCTestCase {
|
||||||
|
func testValidateSupersetsRejectsEmptyPayload() {
|
||||||
|
let issues = WorkoutValidation.validateSupersets([])
|
||||||
|
XCTAssertEqual(issues.first?.code, "empty_supersets")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValidateSupersetsRejectsInvalidRoundsAndExercisePayload() {
|
||||||
|
let supersets: [[String: Any]] = [
|
||||||
|
[
|
||||||
|
"rounds": 0,
|
||||||
|
"exercises": [
|
||||||
|
["reps": 0, "duration": 0]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
let issues = WorkoutValidation.validateSupersets(supersets)
|
||||||
|
XCTAssertTrue(issues.contains(where: { $0.code == "invalid_rounds" }))
|
||||||
|
XCTAssertTrue(issues.contains(where: { $0.code == "invalid_exercise_payload" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValidateSupersetsAcceptsValidPayload() {
|
||||||
|
let supersets: [[String: Any]] = [
|
||||||
|
[
|
||||||
|
"rounds": 3,
|
||||||
|
"exercises": [
|
||||||
|
["reps": 12, "duration": 0],
|
||||||
|
["reps": 0, "duration": 30]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
let issues = WorkoutValidation.validateSupersets(supersets)
|
||||||
|
XCTAssertTrue(issues.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
SharedCore/Tests/SharedCoreiOSTests/TokenSecurityTests.swift
Normal file
65
SharedCore/Tests/SharedCoreiOSTests/TokenSecurityTests.swift
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import SharedCore
|
||||||
|
|
||||||
|
final class TokenSecurityTests: XCTestCase {
|
||||||
|
func testSanitizeTokenRejectsEmptyAndRedactedValues() {
|
||||||
|
XCTAssertNil(TokenSecurity.sanitizeToken(nil))
|
||||||
|
XCTAssertNil(TokenSecurity.sanitizeToken(" "))
|
||||||
|
XCTAssertNil(TokenSecurity.sanitizeToken("REDACTED_TOKEN"))
|
||||||
|
XCTAssertEqual(TokenSecurity.sanitizeToken(" abc123 "), "abc123")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSanitizeTokenNormalizesAuthorizationPrefixes() {
|
||||||
|
XCTAssertEqual(TokenSecurity.sanitizeToken("Token abc123"), "abc123")
|
||||||
|
XCTAssertEqual(TokenSecurity.sanitizeToken("bearer xyz789"), "xyz789")
|
||||||
|
XCTAssertNil(TokenSecurity.sanitizeToken("Token "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testContainsPotentialHardcodedTokenDetectsLongHexBlob() {
|
||||||
|
let content = "private let token = \"0123456789abcdef0123456789abcdef\""
|
||||||
|
XCTAssertTrue(TokenSecurity.containsPotentialHardcodedToken(in: content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testJWTExpirationAndRotationWindow() throws {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
let expiration = now.addingTimeInterval(30 * 60)
|
||||||
|
|
||||||
|
let token = try makeJWT(exp: expiration)
|
||||||
|
|
||||||
|
XCTAssertEqual(TokenSecurity.jwtExpiration(token), expiration)
|
||||||
|
XCTAssertFalse(TokenSecurity.isExpired(token, now: now))
|
||||||
|
XCTAssertTrue(TokenSecurity.shouldRotate(token, now: now, rotationWindow: 60 * 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExpiredJWTReturnsExpired() throws {
|
||||||
|
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
let expiration = now.addingTimeInterval(-10)
|
||||||
|
let token = try makeJWT(exp: expiration)
|
||||||
|
|
||||||
|
XCTAssertTrue(TokenSecurity.isExpired(token, now: now))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMalformedTokenDoesNotCrashAndDoesNotTriggerRotation() {
|
||||||
|
let malformed = "not-a-jwt"
|
||||||
|
XCTAssertNil(TokenSecurity.jwtExpiration(malformed))
|
||||||
|
XCTAssertFalse(TokenSecurity.isExpired(malformed))
|
||||||
|
XCTAssertFalse(TokenSecurity.shouldRotate(malformed))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeJWT(exp: Date) throws -> String {
|
||||||
|
let header = ["alg": "HS256", "typ": "JWT"]
|
||||||
|
let payload = ["exp": Int(exp.timeIntervalSince1970)]
|
||||||
|
|
||||||
|
let headerData = try JSONSerialization.data(withJSONObject: header)
|
||||||
|
let payloadData = try JSONSerialization.data(withJSONObject: payload)
|
||||||
|
|
||||||
|
return "\(base64URL(headerData)).\(base64URL(payloadData)).signature"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func base64URL(_ data: Data) -> String {
|
||||||
|
data.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
1CC7CBCF2C21E42C001614B8 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC7CBCE2C21E42C001614B8 /* DataStore.swift */; };
|
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;
|
||||||
|
|||||||
@@ -14,20 +14,21 @@ 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: BaseURLs.currentBaseURL + "/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{
|
||||||
|
avPlayer.isMuted = true
|
||||||
avPlayer.play()
|
avPlayer.play()
|
||||||
}
|
}
|
||||||
.onReceive(videoEnded){ _ in
|
.onReceive(videoEnded){ _ in
|
||||||
@@ -36,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() {
|
||||||
@@ -47,35 +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.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()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
docs/stabilization_steps_1_5.md
Normal file
35
docs/stabilization_steps_1_5.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Steps 1-5 Stabilization Deliverables
|
||||||
|
|
||||||
|
This repo now includes:
|
||||||
|
|
||||||
|
1. `SharedCore` Swift package with cross-platform utilities and dedicated test targets:
|
||||||
|
- `SharedCoreiOSTests`
|
||||||
|
- `SharedCoreWatchOSTests`
|
||||||
|
- `SharedCoreTVOSTests`
|
||||||
|
2. Auth token lifecycle protections in shared iOS/tvOS user/network code:
|
||||||
|
- token sanitization
|
||||||
|
- JWT expiry checks
|
||||||
|
- proactive refresh trigger when near expiry
|
||||||
|
- forced logout on `401`/`403`
|
||||||
|
3. Smoke scripts in `scripts/smoke/` for iOS/watchOS/tvOS plus package tests.
|
||||||
|
4. Runtime logging hooks (structured `os.Logger`) in network/auth/datastore/watch bridge/workout paths.
|
||||||
|
5. CI workflow `.github/workflows/apple-platform-ci.yml` that runs the smoke suite.
|
||||||
|
6. Build warning cleanup:
|
||||||
|
- disabled AppIntents metadata extraction for iOS/watchOS/tvOS targets that do not link `AppIntents`.
|
||||||
|
|
||||||
|
## SharedCore wiring
|
||||||
|
|
||||||
|
- `SharedCore` is linked as a local Swift package product to:
|
||||||
|
- `Werkout_ios` (iOS)
|
||||||
|
- `Werkout_watch Watch App` (watchOS)
|
||||||
|
- `WekoutThotViewer` (tvOS)
|
||||||
|
- Shared helpers are actively used in app code:
|
||||||
|
- `TokenSecurity` now drives token sanitization/expiry/rotation checks in `UserStore`.
|
||||||
|
- `RuntimeReporter` now handles network/auth/datastore runtime error reporting.
|
||||||
|
|
||||||
|
## Local commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/ci/scan_tokens.sh
|
||||||
|
./scripts/smoke/smoke_all.sh
|
||||||
|
```
|
||||||
39
docs/step10_reliability_round2.md
Normal file
39
docs/step10_reliability_round2.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Step 10 Reliability Round 2
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
- Reviewed and patched:
|
||||||
|
- `iphone/Werkout_ios/BridgeModule+Watch.swift`
|
||||||
|
- `iphone/Werkout_ios/BridgeModule+WorkoutActions.swift`
|
||||||
|
- `iphone/Werkout_ios/CurrentWorkoutInfo.swift`
|
||||||
|
- `iphone/Werkout_watch Watch App/WatchDelegate.swift`
|
||||||
|
- `iphone/Werkout_ios/UserStore.swift`
|
||||||
|
- Validation:
|
||||||
|
- `./scripts/smoke/smoke_all.sh`
|
||||||
|
- iOS/tvOS analyzer passes
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
1. Main-thread state safety for watch session callbacks
|
||||||
|
- Wrapped `didReceiveMessageData` action handling and `activationDidCompleteWith` state transitions on main.
|
||||||
|
- Prevents shared bridge state (`@Published` workout/watch properties + queued message mutations) from being changed off-main.
|
||||||
|
|
||||||
|
2. Removed dead closure path that could retain `BridgeModule`
|
||||||
|
- Removed unused `CurrentWorkoutInfo.complete` closure and its assignment in `BridgeModule+WorkoutActions.start(workout:)`.
|
||||||
|
- Reduces lifecycle risk and removes dead behavior.
|
||||||
|
|
||||||
|
3. HealthKit authorization crash hardening on watch launch
|
||||||
|
- Replaced force-unwrapped quantity types with guarded optional binding in `WatchDelegate`.
|
||||||
|
- Logs and exits cleanly if required HealthKit quantity types are unavailable.
|
||||||
|
|
||||||
|
4. Cross-target notification compile stability
|
||||||
|
- Updated `UserStore.logout` to post `Notification.Name("CreatedNewWorkout")` directly.
|
||||||
|
- Avoids reliance on an iOS-only extension file when `UserStore` is compiled in tvOS target.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Smoke suite passed:
|
||||||
|
- token scan
|
||||||
|
- SharedCore tests
|
||||||
|
- iOS/watchOS/tvOS builds
|
||||||
|
|
||||||
40
docs/step11_watch_regression_and_architecture.md
Normal file
40
docs/step11_watch_regression_and_architecture.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Step 11 Watch Regression + Architecture Cleanup
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Executed in requested order:
|
||||||
|
|
||||||
|
1. `#2` Focused watch/phone disconnect-reconnect regression coverage.
|
||||||
|
2. `#3` Architecture cleanup to reduce shared cross-target coupling.
|
||||||
|
|
||||||
|
## #2 Regression Work
|
||||||
|
|
||||||
|
- Added shared queue primitive:
|
||||||
|
- `SharedCore/Sources/SharedCore/BoundedFIFOQueue.swift`
|
||||||
|
- Added regression tests for disconnect/reconnect replay and overflow behavior:
|
||||||
|
- `SharedCore/Tests/SharedCoreWatchOSTests/BoundedFIFOQueueTests.swift`
|
||||||
|
- Wired iOS watch bridge queueing to shared queue:
|
||||||
|
- `iphone/Werkout_ios/BridgeModule.swift`
|
||||||
|
- `iphone/Werkout_ios/BridgeModule+Watch.swift`
|
||||||
|
- Wired watch sender queueing to shared queue:
|
||||||
|
- `iphone/Werkout_watch Watch App/WatchMainViewModel+WCSessionDelegate.swift`
|
||||||
|
|
||||||
|
## #3 Architecture Cleanup
|
||||||
|
|
||||||
|
- Replaced ad-hoc notification wiring with a shared typed notification constant:
|
||||||
|
- `SharedCore/Sources/SharedCore/AppNotifications.swift`
|
||||||
|
- Updated consumers to use shared constant:
|
||||||
|
- `iphone/Werkout_ios/UserStore.swift`
|
||||||
|
- `iphone/Werkout_ios/Werkout_iosApp.swift`
|
||||||
|
- `iphone/Werkout_ios/Views/CreateWorkout/CreateViewModels.swift`
|
||||||
|
- `iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift`
|
||||||
|
- Removed iOS-only notification extension that created cross-target coupling:
|
||||||
|
- `iphone/Werkout_ios/Extensions.swift`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `./scripts/smoke/smoke_all.sh` passed:
|
||||||
|
- token scan
|
||||||
|
- SharedCore tests (including new queue tests)
|
||||||
|
- iOS/watchOS/tvOS builds
|
||||||
|
|
||||||
53
docs/step12_hardware_disconnect_pass.md
Normal file
53
docs/step12_hardware_disconnect_pass.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Step 12 Hardware Disconnect/Reconnect Pass
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
- 2026-02-11 (UTC timestamp used during check: `2026-02-11T18:45:42Z`)
|
||||||
|
|
||||||
|
## Coverage Attempted
|
||||||
|
|
||||||
|
- Device inventory:
|
||||||
|
- `xcrun xcdevice list`
|
||||||
|
- iOS destination eligibility:
|
||||||
|
- `xcodebuild -project iphone/Werkout_ios.xcodeproj -scheme 'Werkout_ios' -showdestinations`
|
||||||
|
- watchOS destination eligibility:
|
||||||
|
- `xcodebuild -project iphone/Werkout_ios.xcodeproj -scheme 'Werkout_watch Watch App' -showdestinations`
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
1. Hardware watch pass could not be executed from this environment.
|
||||||
|
- Evidence:
|
||||||
|
- watch scheme showed simulator destinations only under available destinations.
|
||||||
|
- the only physical watch destination was ineligible:
|
||||||
|
- `Hollie’s Apple Watch` with error indicating watchOS version mismatch/unknown against deployment target `watchOS 9.4`.
|
||||||
|
2. Physical iOS devices are present/eligible.
|
||||||
|
- Evidence:
|
||||||
|
- available iOS hardware destinations included:
|
||||||
|
- `Gary Tartt’s iPad`
|
||||||
|
- `ihollieb`
|
||||||
|
- `Peeeeeeeeellll`
|
||||||
|
3. Physical watch pairing/connectivity state is the blocking dependency for true hardware disconnect/reconnect validation.
|
||||||
|
|
||||||
|
## What Was Added For Immediate Re-Run
|
||||||
|
|
||||||
|
- Hardware run script:
|
||||||
|
- `scripts/hardware/watch_disconnect_hardware_pass.sh`
|
||||||
|
- Script behavior:
|
||||||
|
- validates eligible physical iOS + watch destinations
|
||||||
|
- performs hardware-targeted preflight builds (`CODE_SIGNING_ALLOWED=NO`)
|
||||||
|
- emits manual disconnect/reconnect checklist and pass criteria artifact
|
||||||
|
|
||||||
|
## Manual Hardware Scenario (Pending Once Watch Is Eligible)
|
||||||
|
|
||||||
|
1. Start iOS + watch apps on physical paired devices.
|
||||||
|
2. Start workout from iOS.
|
||||||
|
3. Break transport (Bluetooth off on iPhone or Airplane Mode on watch) for ~30s.
|
||||||
|
4. While disconnected, trigger multiple state changes on iOS.
|
||||||
|
5. Reconnect transport and verify watch converges to latest state without crash/replay loop.
|
||||||
|
6. Repeat for two disconnect/reconnect cycles.
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
- Blocked on eligible physical watch destination.
|
||||||
|
- Queue/replay behavior is covered by automated tests in `SharedCore/Tests/SharedCoreWatchOSTests/BoundedFIFOQueueTests.swift`, but physical transport behavior remains unverified until watch eligibility is fixed.
|
||||||
|
- In this Codex shell, scripted destination probing/building is additionally constrained by sandboxed write restrictions to Xcode/SwiftPM cache paths; manual run on your local terminal is expected once watch hardware is eligible.
|
||||||
55
docs/step6_audit_round1.md
Normal file
55
docs/step6_audit_round1.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Step 6 Audit Round 1 (P0/P1)
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
- Reviewed high-risk auth/session/network/watch files:
|
||||||
|
- `iphone/Werkout_ios/UserStore.swift`
|
||||||
|
- `iphone/Werkout_ios/Network/Network.swift`
|
||||||
|
- `iphone/Werkout_ios/BridgeModule+Watch.swift`
|
||||||
|
- `iphone/Werkout_watch Watch App/WatchMainViewModel.swift`
|
||||||
|
- `iphone/Werkout_watch Watch App/WatchMainViewModel+WCSessionDelegate.swift`
|
||||||
|
- `iphone/Werkout_ios/HealthKitHelper.swift`
|
||||||
|
- `iphone/Werkout_ios/CurrentWorkoutInfo.swift`
|
||||||
|
- Ran:
|
||||||
|
- `./scripts/smoke/smoke_all.sh`
|
||||||
|
- Added/ran regression tests in `SharedCore` for token lifecycle and watch payload validation.
|
||||||
|
|
||||||
|
## Findings And Fixes
|
||||||
|
|
||||||
|
1. `P1` Watch command loss during activation
|
||||||
|
- Evidence: `iphone/Werkout_watch Watch App/WatchMainViewModel+WCSessionDelegate.swift:40`
|
||||||
|
- Problem: payloads were dropped when `WCSession` was not activated.
|
||||||
|
- Fix: added bounded queue (`maxQueuedPayloads`), enqueue on inactive session, flush on activation.
|
||||||
|
|
||||||
|
2. `P1` Silent/unsafe watch payload decode failures
|
||||||
|
- Evidence: `iphone/Werkout_ios/BridgeModule+Watch.swift:73`
|
||||||
|
- Evidence: `iphone/Werkout_watch Watch App/WatchMainViewModel.swift:74`
|
||||||
|
- Problem: `try?` decode silently ignored malformed payloads.
|
||||||
|
- Fix: added shared `WatchPayloadValidation` with size checks and structured decode failures; both decode paths now reject+log bad payloads.
|
||||||
|
|
||||||
|
3. `P1` Auth token normalization gap for prefixed tokens
|
||||||
|
- Evidence: `SharedCore/Sources/SharedCore/TokenSecurity.swift:24`
|
||||||
|
- Problem: `"Token ..."` / `"Bearer ..."` values were not normalized.
|
||||||
|
- Fix: normalize known auth prefixes and reject bare prefix-only strings.
|
||||||
|
|
||||||
|
4. `P1` Network reliability/threading risk
|
||||||
|
- Evidence: `iphone/Werkout_ios/Network/Network.swift:12`
|
||||||
|
- Problem: infinite request timeouts and completion handlers returning on background threads.
|
||||||
|
- Fix: finite timeout (`30s`) and centralized main-thread completion delivery.
|
||||||
|
|
||||||
|
5. `P1` HealthKit helper shared mutable-state race
|
||||||
|
- Evidence: `iphone/Werkout_ios/HealthKitHelper.swift:20`
|
||||||
|
- Problem: mutable cross-request state (`completion`, counters, shared result object) could race and mis-route results.
|
||||||
|
- Fix: per-request aggregation via `DispatchGroup`, single UUID query (`limit: 1`), thread-safe aggregation queue, structured runtime logging.
|
||||||
|
|
||||||
|
6. `P2` Workout order inconsistency across helpers
|
||||||
|
- Evidence: `iphone/Werkout_ios/CurrentWorkoutInfo.swift:24`
|
||||||
|
- Problem: some paths used unsorted `workout.supersets` while others used sorted supersets.
|
||||||
|
- Fix: unified core navigation/lookup paths on sorted `superset` accessor and corrected bounds check.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Smoke suite passed after fixes:
|
||||||
|
- token scan
|
||||||
|
- SharedCore tests (including new regression tests)
|
||||||
|
- iOS/watchOS/tvOS builds
|
||||||
62
docs/step7_ui_accessibility_round1.md
Normal file
62
docs/step7_ui_accessibility_round1.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Step 7 UI/State/Accessibility Round 1
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
- Reviewed and patched high-traffic iOS/watchOS UI paths:
|
||||||
|
- workout browsing, planned workouts, workout detail, create-workout flow, login, watch main view
|
||||||
|
- Ran validation:
|
||||||
|
- `./scripts/smoke/smoke_all.sh`
|
||||||
|
|
||||||
|
## Fixes Applied
|
||||||
|
|
||||||
|
1. Create workout state consistency and duplicate-submit prevention
|
||||||
|
- `iphone/Werkout_ios/Views/CreateWorkout/CreateViewModels.swift`
|
||||||
|
- Added `isUploading` gate, title trimming validation, shared `WorkoutValidation` integration.
|
||||||
|
|
||||||
|
2. Weight stepper logic bug
|
||||||
|
- `iphone/Werkout_ios/Views/CreateWorkout/CreateViewModels.swift`
|
||||||
|
- Fixed weight decrement mismatch (`-15` to `-5`) to match increment behavior.
|
||||||
|
|
||||||
|
3. Create-workout UX cleanup and accessibility
|
||||||
|
- `iphone/Werkout_ios/Views/CreateWorkout/CreateWorkoutMainView.swift`
|
||||||
|
- Replaced visible sentinel row with hidden spacer, disabled upload button while uploading, added button labels/hints.
|
||||||
|
|
||||||
|
4. Superset editing accessibility/state
|
||||||
|
- `iphone/Werkout_ios/subview/CreateWorkoutSupersetView.swift`
|
||||||
|
- Avoided sheet toggle race by setting `showAddExercise = true`; added accessibility labels/hints.
|
||||||
|
|
||||||
|
5. Exercise action controls accessibility
|
||||||
|
- `iphone/Werkout_ios/Views/CreateWorkout/CreateExerciseActionsView.swift`
|
||||||
|
- Added accessibility labels to steppers and icon-only controls.
|
||||||
|
|
||||||
|
6. Workout list/planned list row accessibility
|
||||||
|
- `iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsListView.swift`
|
||||||
|
- `iphone/Werkout_ios/subview/PlannedWorkoutView.swift`
|
||||||
|
- Converted tap-only rows to plain `Button`s for VoiceOver/focus reliability.
|
||||||
|
|
||||||
|
7. Workout detail list ordering/scroll stability
|
||||||
|
- `iphone/Werkout_ios/Views/WorkoutDetail/ExerciseListView.swift`
|
||||||
|
- Aligned list ordering with sorted superset order and introduced stable row IDs for consistent scroll targeting.
|
||||||
|
|
||||||
|
8. Workout detail control accessibility + progress text guard
|
||||||
|
- `iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift`
|
||||||
|
- Added accessibility labels to icon-only controls and avoided negative progress display.
|
||||||
|
|
||||||
|
9. Login form input/accessibility improvements
|
||||||
|
- `iphone/Werkout_ios/Views/Login/LoginView.swift`
|
||||||
|
- Added keyboard/input autocorrection settings and accessibility labels/hints.
|
||||||
|
|
||||||
|
10. HealthKit auth safety/logging in all-workouts screen
|
||||||
|
- `iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift`
|
||||||
|
- Removed force-unwrapped HK types and added runtime warning on failed authorization.
|
||||||
|
|
||||||
|
11. watchOS no-workout screen and accessibility polish
|
||||||
|
- `iphone/Werkout_watch Watch App/MainWatchView.swift`
|
||||||
|
- Replaced emoji placeholder with clear status text/icon and added combined accessibility labels.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Smoke suite passed after fixes:
|
||||||
|
- token scan
|
||||||
|
- SharedCore tests
|
||||||
|
- iOS/watchOS/tvOS builds
|
||||||
48
docs/step8_performance_state_round1.md
Normal file
48
docs/step8_performance_state_round1.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Step 8 Performance/State Round 1
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
- Reviewed and patched:
|
||||||
|
- `iphone/Werkout_ios/DataStore.swift`
|
||||||
|
- `iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift`
|
||||||
|
- `iphone/Werkout_ios/Views/WorkoutDetail/ExerciseListView.swift`
|
||||||
|
- `WekoutThotViewer/WekoutThotViewer/ContentView.swift`
|
||||||
|
- `iphone/Werkout_ios/BridgeModule.swift`
|
||||||
|
- `iphone/Werkout_ios/BridgeModule+Watch.swift`
|
||||||
|
- `iphone/Werkout_ios/BridgeModule+Timer.swift`
|
||||||
|
- `iphone/Werkout_watch Watch App/WatchWorkout.swift`
|
||||||
|
- Validation run:
|
||||||
|
- `./scripts/smoke/smoke_all.sh`
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
1. Coalesced concurrent `fetchAllData` requests
|
||||||
|
- `DataStore` now queues completion handlers while a fetch is active to prevent overlapping network fan-out and state churn.
|
||||||
|
|
||||||
|
2. Reduced AVPlayer churn in iOS workout detail
|
||||||
|
- Reuses current player for same URL by seeking to start instead of recreating `AVPlayer` each time exercise/video updates.
|
||||||
|
|
||||||
|
3. Reduced AVPlayer churn in iOS exercise preview sheet
|
||||||
|
- Added preview URL tracking; same URL now replays without allocating a new player.
|
||||||
|
|
||||||
|
4. Reduced AVPlayer churn in tvOS content loop
|
||||||
|
- Same URL replay now seeks/replays existing player rather than recreating.
|
||||||
|
|
||||||
|
5. Capped queued watch messages on iOS bridge
|
||||||
|
- Added queue cap to prevent unbounded growth while watch is disconnected.
|
||||||
|
|
||||||
|
6. Added queue fallback for send failures
|
||||||
|
- Failed reachable send now re-queues payload for later delivery.
|
||||||
|
|
||||||
|
7. Improved timer power behavior
|
||||||
|
- Added timer tolerance to workout/exercise timers.
|
||||||
|
|
||||||
|
8. Fixed watch heart-rate loop early-return behavior
|
||||||
|
- Non-heart sample types now `continue` instead of exiting handler early.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Smoke suite passed:
|
||||||
|
- token scan
|
||||||
|
- SharedCore tests
|
||||||
|
- iOS/watchOS/tvOS builds
|
||||||
39
docs/step9_memory_lifecycle_round1.md
Normal file
39
docs/step9_memory_lifecycle_round1.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Step 9 Memory/Lifecycle Round 1
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
- Audited lifecycle cleanup and resource teardown in:
|
||||||
|
- `iphone/Werkout_ios/subview/PlayerUIView.swift`
|
||||||
|
- `iphone/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift`
|
||||||
|
- `iphone/Werkout_ios/Views/WorkoutDetail/ExerciseListView.swift`
|
||||||
|
- `iphone/Werkout_ios/Views/ExternalWorkoutDetailView.swift`
|
||||||
|
- `iphone/Werkout_ios/subview/AllExerciseView.swift`
|
||||||
|
- `iphone/Werkout_ios/Views/CreateWorkout/CreateExerciseActionsView.swift`
|
||||||
|
- `iphone/Werkout_ios/AudioEngine.swift`
|
||||||
|
- `WekoutThotViewer/WekoutThotViewer/ContentView.swift`
|
||||||
|
- Validation:
|
||||||
|
- `./scripts/smoke/smoke_all.sh`
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
1. Player view teardown safety
|
||||||
|
- `PlayerView` now pauses previous players when swapping and performs explicit teardown in `dismantleUIView`.
|
||||||
|
|
||||||
|
2. Workout detail closure retention risk
|
||||||
|
- Clears `BridgeModule.shared.completedWorkout` on `WorkoutDetailView` disappear.
|
||||||
|
|
||||||
|
3. Player pause on dismiss across views
|
||||||
|
- Added `onDisappear` player pause in workout detail exercise list, create-exercise preview, all-exercise preview, external display, and tvOS content view.
|
||||||
|
|
||||||
|
4. External display player reuse
|
||||||
|
- Added URL tracking + replay path to avoid reallocating AVPlayer when URL is unchanged.
|
||||||
|
|
||||||
|
5. Audio playback resource churn
|
||||||
|
- Stops existing players before replacement and logs failures via `RuntimeReporter` instead of `print`.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Smoke suite passed:
|
||||||
|
- token scan
|
||||||
|
- SharedCore tests
|
||||||
|
- iOS/watchOS/tvOS builds
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -3,131 +3,24 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 56;
|
objectVersion = 70;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1C0494832C23C56E003D18BB /* WatchMainViewModel+WCSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0494822C23C56E003D18BB /* WatchMainViewModel+WCSessionDelegate.swift */; };
|
1C0494832C23C56E003D18BB /* WatchMainViewModel+WCSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0494822C23C56E003D18BB /* WatchMainViewModel+WCSessionDelegate.swift */; };
|
||||||
1C0494872C23E7BD003D18BB /* BridgeModule+Watch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0494862C23E7BD003D18BB /* BridgeModule+Watch.swift */; };
|
|
||||||
1C0494882C23E7C5003D18BB /* BridgeModule+Watch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0494862C23E7BD003D18BB /* BridgeModule+Watch.swift */; };
|
|
||||||
1C04948A2C25CB4F003D18BB /* BridgeModule+WorkoutActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0494892C25CB4F003D18BB /* BridgeModule+WorkoutActions.swift */; };
|
|
||||||
1C04948C2C25CB80003D18BB /* AudioEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C04948B2C25CB80003D18BB /* AudioEngine.swift */; };
|
|
||||||
1C04948D2C25CC93003D18BB /* AudioEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C04948B2C25CB80003D18BB /* AudioEngine.swift */; };
|
|
||||||
1C04948E2C25CD3D003D18BB /* BridgeModule+WorkoutActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0494892C25CB4F003D18BB /* BridgeModule+WorkoutActions.swift */; };
|
|
||||||
1C0494932C25CEF0003D18BB /* BridgeModule+Timer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0494922C25CEF0003D18BB /* BridgeModule+Timer.swift */; };
|
|
||||||
1C0494942C25CEF4003D18BB /* BridgeModule+Timer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0494922C25CEF0003D18BB /* BridgeModule+Timer.swift */; };
|
|
||||||
1C1A3C722C3373E10010CDD5 /* WatchDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1A3C712C3373E10010CDD5 /* WatchDelegate.swift */; };
|
1C1A3C722C3373E10010CDD5 /* WatchDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1A3C712C3373E10010CDD5 /* WatchDelegate.swift */; };
|
||||||
1C1A3C742C3376150010CDD5 /* WatchWorkout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1A3C732C3376150010CDD5 /* WatchWorkout.swift */; };
|
1C1A3C742C3376150010CDD5 /* WatchWorkout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1A3C732C3376150010CDD5 /* WatchWorkout.swift */; };
|
||||||
1C31C8842A53AE3E00350540 /* short_beep.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 1C31C8822A53AE3E00350540 /* short_beep.m4a */; };
|
|
||||||
1C31C8852A53AE3E00350540 /* long_beep.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 1C31C8832A53AE3E00350540 /* long_beep.m4a */; };
|
|
||||||
1C31C8872A55B2CC00350540 /* PlayerUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C31C8862A55B2CC00350540 /* PlayerUIView.swift */; };
|
|
||||||
1C485C832A489B9C00A6F896 /* CompletedWorkouts.json in Resources */ = {isa = PBXBuildFile; fileRef = 1C485C822A489B9C00A6F896 /* CompletedWorkouts.json */; };
|
|
||||||
1C485C872A4915C400A6F896 /* CreateWorkoutItemPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C485C862A4915C400A6F896 /* CreateWorkoutItemPickerView.swift */; };
|
|
||||||
1C485C8A2A492BB400A6F896 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C485C892A492BB400A6F896 /* LoginView.swift */; };
|
|
||||||
1C485C8C2A49D65600A6F896 /* WorkoutHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C485C8B2A49D65600A6F896 /* WorkoutHistoryView.swift */; };
|
|
||||||
1C485C8D2A49D95700A6F896 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A822A42347D0042FFBD /* Extensions.swift */; };
|
|
||||||
1C4AFF152A60F25F0027710B /* ThotStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4AFF142A60F25E0027710B /* ThotStyle.swift */; };
|
|
||||||
1C4AFF162A60F27E0027710B /* ThotStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4AFF142A60F25E0027710B /* ThotStyle.swift */; };
|
|
||||||
1C4AFF182A65CD290027710B /* Superset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4AFF172A65CD290027710B /* Superset.swift */; };
|
|
||||||
1C4AFF192A65CD6F0027710B /* Superset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4AFF172A65CD290027710B /* Superset.swift */; };
|
|
||||||
1C4AFF1B2A65FB190027710B /* CurrentWorkoutInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4AFF1A2A65FB190027710B /* CurrentWorkoutInfo.swift */; };
|
|
||||||
1C4AFF1C2A65FB2B0027710B /* CurrentWorkoutInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4AFF1A2A65FB190027710B /* CurrentWorkoutInfo.swift */; };
|
|
||||||
1C4AFF1E2A7579410027710B /* NSFWVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4AFF1D2A7579410027710B /* NSFWVideo.swift */; };
|
|
||||||
1C4AFF202A8800860027710B /* AudioQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4AFF1F2A8800860027710B /* AudioQueue.swift */; };
|
|
||||||
1C4AFF212A8801090027710B /* AudioQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4AFF1F2A8800860027710B /* AudioQueue.swift */; };
|
|
||||||
1C4AFF222A885EBD0027710B /* PreviewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A512A3A90A00042FFBD /* PreviewData.swift */; };
|
|
||||||
1C5190C22A57CA5F00885849 /* OvalTextFieldStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5190C12A57CA5F00885849 /* OvalTextFieldStyle.swift */; };
|
|
||||||
1C5190C42A589CAC00885849 /* InfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5190C32A589CAC00885849 /* InfoView.swift */; };
|
|
||||||
1C5190C62A589CC100885849 /* ActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5190C52A589CC100885849 /* ActionsView.swift */; };
|
|
||||||
1C5190C82A589CDA00885849 /* CurrentWorkoutElapsedTimeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5190C72A589CDA00885849 /* CurrentWorkoutElapsedTimeView.swift */; };
|
|
||||||
1C5190CA2A589CEC00885849 /* ExerciseListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5190C92A589CEC00885849 /* ExerciseListView.swift */; };
|
|
||||||
1C5190CC2A589D0000885849 /* CountdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5190CB2A589D0000885849 /* CountdownView.swift */; };
|
|
||||||
1C5190CE2A589D4100885849 /* AllWorkoutPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5190CD2A589D4100885849 /* AllWorkoutPickerView.swift */; };
|
|
||||||
1C5190D02A589D5F00885849 /* AllWorkoutsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5190CF2A589D5F00885849 /* AllWorkoutsListView.swift */; };
|
|
||||||
1C5190D22A59ACA400885849 /* WatchControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5190D12A59ACA400885849 /* WatchControlView.swift */; };
|
1C5190D22A59ACA400885849 /* WatchControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5190D12A59ACA400885849 /* WatchControlView.swift */; };
|
||||||
1C5190D42A59AEDE00885849 /* MainWatchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5190D32A59AEDE00885849 /* MainWatchView.swift */; };
|
1C5190D42A59AEDE00885849 /* MainWatchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5190D32A59AEDE00885849 /* MainWatchView.swift */; };
|
||||||
1C6BF28F2A56602B00450FD7 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C6BF28E2A56602B00450FD7 /* Keychain.swift */; };
|
|
||||||
1CAF4D8A2A5132F900B00E50 /* PlannedWorkout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CAF4D892A5132F900B00E50 /* PlannedWorkout.swift */; };
|
|
||||||
1CAF4D8C2A51339200B00E50 /* PlannedWorkouts.json in Resources */ = {isa = PBXBuildFile; fileRef = 1CAF4D8B2A51339200B00E50 /* PlannedWorkouts.json */; };
|
|
||||||
1CAF4D952A52180600B00E50 /* PlanWorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CAF4D942A52180600B00E50 /* PlanWorkoutView.swift */; };
|
|
||||||
1CC092ED2C1FAC730004E1E6 /* Logoutview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC092EC2C1FAC730004E1E6 /* Logoutview.swift */; };
|
|
||||||
1CC092EF2C1FACFC0004E1E6 /* NameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC092EE2C1FACFC0004E1E6 /* NameView.swift */; };
|
|
||||||
1CC092F12C1FAD1E0004E1E6 /* CompletedWorkoutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC092F02C1FAD1E0004E1E6 /* CompletedWorkoutsView.swift */; };
|
|
||||||
1CC092F32C1FADDA0004E1E6 /* ThotPreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC092F22C1FADDA0004E1E6 /* ThotPreferenceView.swift */; };
|
|
||||||
1CC092F52C1FAE7B0004E1E6 /* ShowNextUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC092F42C1FAE7B0004E1E6 /* ShowNextUpView.swift */; };
|
|
||||||
1CC092F72C1FAFD50004E1E6 /* AllMusclesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC092F62C1FAFD50004E1E6 /* AllMusclesView.swift */; };
|
|
||||||
1CC092F92C1FB1420004E1E6 /* AllExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC092F82C1FB1420004E1E6 /* AllExerciseView.swift */; };
|
|
||||||
1CC092FB2C1FB3320004E1E6 /* AllEquipmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC092FA2C1FB3320004E1E6 /* AllEquipmentView.swift */; };
|
|
||||||
1CC092FD2C20B0A30004E1E6 /* ExtCountdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC092FC2C20B0A30004E1E6 /* ExtCountdownView.swift */; };
|
|
||||||
1CC092FF2C20B0C80004E1E6 /* ExtExerciseList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC092FE2C20B0C80004E1E6 /* ExtExerciseList.swift */; };
|
|
||||||
1CC093012C20B0E90004E1E6 /* TitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC093002C20B0E90004E1E6 /* TitleView.swift */; };
|
|
||||||
1CC7CBD52C221159001614B8 /* CaloriesBurnedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC7CBD42C221159001614B8 /* CaloriesBurnedView.swift */; };
|
|
||||||
1CC7CBD72C2211F0001614B8 /* RateWorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC7CBD62C2211F0001614B8 /* RateWorkoutView.swift */; };
|
|
||||||
1CC7CBD92C221286001614B8 /* WorkoutInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC7CBD82C221286001614B8 /* WorkoutInfoView.swift */; };
|
|
||||||
1CC7CBDB2C221666001614B8 /* PlannedWorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC7CBDA2C221666001614B8 /* PlannedWorkoutView.swift */; };
|
|
||||||
1CD0C6632A5AF62900970E52 /* WorkoutOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD0C6622A5AF62900970E52 /* WorkoutOverviewView.swift */; };
|
|
||||||
1CD0C6672A5CA19600970E52 /* BaseURLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD0C6662A5CA19600970E52 /* BaseURLs.swift */; };
|
|
||||||
1CD0C6682A5CA1A200970E52 /* BaseURLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD0C6662A5CA19600970E52 /* BaseURLs.swift */; };
|
|
||||||
1CD0C66C2A5E4EA100970E52 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1CD0C66B2A5E4EA100970E52 /* LaunchScreen.storyboard */; };
|
1CD0C66C2A5E4EA100970E52 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1CD0C66B2A5E4EA100970E52 /* LaunchScreen.storyboard */; };
|
||||||
1CEF74AB2A89937800C1AE6A /* HealthKitHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CEF74AA2A89937800C1AE6A /* HealthKitHelper.swift */; };
|
|
||||||
1CF65A262A3972840042FFBD /* Werkout_iosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A252A3972840042FFBD /* Werkout_iosApp.swift */; };
|
|
||||||
1CF65A282A3972840042FFBD /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A272A3972840042FFBD /* Persistence.swift */; };
|
|
||||||
1CF65A2B2A3972840042FFBD /* Werkout_ios.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A292A3972840042FFBD /* Werkout_ios.xcdatamodeld */; };
|
|
||||||
1CF65A2D2A3972840042FFBD /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A2C2A3972840042FFBD /* MainView.swift */; };
|
|
||||||
1CF65A2F2A3972850042FFBD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A2E2A3972850042FFBD /* Assets.xcassets */; };
|
|
||||||
1CF65A332A3972850042FFBD /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A322A3972850042FFBD /* Preview Assets.xcassets */; };
|
|
||||||
1CF65A3C2A3972CE0042FFBD /* ExternalWorkoutDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A3B2A3972CE0042FFBD /* ExternalWorkoutDetailView.swift */; };
|
|
||||||
1CF65A432A39FB410042FFBD /* Workout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A422A39FB410042FFBD /* Workout.swift */; };
|
|
||||||
1CF65A452A39FB550042FFBD /* Exercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A442A39FB550042FFBD /* Exercise.swift */; };
|
|
||||||
1CF65A472A39FB6C0042FFBD /* RegisteredUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A462A39FB6C0042FFBD /* RegisteredUser.swift */; };
|
|
||||||
1CF65A4A2A39FBB10042FFBD /* WorkoutDetail.json in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A492A39FBB10042FFBD /* WorkoutDetail.json */; };
|
|
||||||
1CF65A4C2A39FDA20042FFBD /* WorkoutDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A4B2A39FDA20042FFBD /* WorkoutDetailView.swift */; };
|
|
||||||
1CF65A4E2A39FF200042FFBD /* WorkoutDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A4D2A39FF200042FFBD /* WorkoutDetailViewModel.swift */; };
|
|
||||||
1CF65A502A3A1EA90042FFBD /* BridgeModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A4F2A3A1EA90042FFBD /* BridgeModule.swift */; };
|
|
||||||
1CF65A522A3A90A00042FFBD /* PreviewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A512A3A90A00042FFBD /* PreviewData.swift */; };
|
|
||||||
1CF65A542A3A9AF30042FFBD /* Straight_Leg_Sit_Up.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A532A3A9A990042FFBD /* Straight_Leg_Sit_Up.mp4 */; };
|
|
||||||
1CF65A572A3BF3830042FFBD /* AllMuscles.json in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A562A3BF3830042FFBD /* AllMuscles.json */; };
|
|
||||||
1CF65A592A3BF4B60042FFBD /* Muscle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A582A3BF4B60042FFBD /* Muscle.swift */; };
|
|
||||||
1CF65A5B2A3BF4BE0042FFBD /* Equipment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A5A2A3BF4BE0042FFBD /* Equipment.swift */; };
|
|
||||||
1CF65A5F2A3BF5A60042FFBD /* Equipment.json in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A5E2A3BF5A60042FFBD /* Equipment.json */; };
|
|
||||||
1CF65A612A3BF6020042FFBD /* AddExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A602A3BF6020042FFBD /* AddExerciseView.swift */; };
|
|
||||||
1CF65A632A3BF6A30042FFBD /* AllWorkoutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A622A3BF6A30042FFBD /* AllWorkoutsView.swift */; };
|
|
||||||
1CF65A652A3BF6BE0042FFBD /* AllWorkouts.json in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A642A3BF6BE0042FFBD /* AllWorkouts.json */; };
|
|
||||||
1CF65A672A3BFF840042FFBD /* Exercises.json in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A662A3BFF840042FFBD /* Exercises.json */; };
|
|
||||||
1CF65A692A3C018F0042FFBD /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A682A3C018F0042FFBD /* AccountView.swift */; };
|
|
||||||
1CF65A6B2A3C1EAC0042FFBD /* CreateWorkoutMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A6A2A3C1EAC0042FFBD /* CreateWorkoutMainView.swift */; };
|
|
||||||
1CF65A6E2A3F60480042FFBD /* CreateViewModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A6D2A3F60480042FFBD /* CreateViewModels.swift */; };
|
|
||||||
1CF65A732A3F60D20042FFBD /* CreateExerciseActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A722A3F60D20042FFBD /* CreateExerciseActionsView.swift */; };
|
|
||||||
1CF65A7B2A3F83440042FFBD /* CreateWorkoutSupersetActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A7A2A3F83440042FFBD /* CreateWorkoutSupersetActionsView.swift */; };
|
|
||||||
1CF65A7D2A41275D0042FFBD /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A7C2A41275D0042FFBD /* Network.swift */; };
|
|
||||||
1CF65A7F2A4129320042FFBD /* Fetchables.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A7E2A4129320042FFBD /* Fetchables.swift */; };
|
|
||||||
1CF65A812A412AA30042FFBD /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A802A412AA30042FFBD /* DataStore.swift */; };
|
|
||||||
1CF65A832A42347D0042FFBD /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A822A42347D0042FFBD /* Extensions.swift */; };
|
|
||||||
1CF65A852A43E8060042FFBD /* CompletedWorkout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A842A43E8060042FFBD /* CompletedWorkout.swift */; };
|
|
||||||
1CF65A872A4400E10042FFBD /* ToDo in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A862A4400E10042FFBD /* ToDo */; };
|
|
||||||
1CF65A8E2A44B78B0042FFBD /* CompletedWorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A8D2A44B78B0042FFBD /* CompletedWorkoutView.swift */; };
|
|
||||||
1CF65A962A452D270042FFBD /* Werkout_watchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A952A452D270042FFBD /* Werkout_watchApp.swift */; };
|
1CF65A962A452D270042FFBD /* Werkout_watchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A952A452D270042FFBD /* Werkout_watchApp.swift */; };
|
||||||
1CF65A982A452D270042FFBD /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A972A452D270042FFBD /* ContentView.swift */; };
|
1CF65A982A452D270042FFBD /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A972A452D270042FFBD /* ContentView.swift */; };
|
||||||
1CF65A9A2A452D290042FFBD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A992A452D290042FFBD /* Assets.xcassets */; };
|
1CF65A9A2A452D290042FFBD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A992A452D290042FFBD /* Assets.xcassets */; };
|
||||||
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, ); }; };
|
||||||
1CF65AA52A452D9C0042FFBD /* CompletedWorkout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A842A43E8060042FFBD /* CompletedWorkout.swift */; };
|
|
||||||
1CF65AA62A452D9C0042FFBD /* Equipment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A5A2A3BF4BE0042FFBD /* Equipment.swift */; };
|
|
||||||
1CF65AA72A452D9C0042FFBD /* Muscle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A582A3BF4B60042FFBD /* Muscle.swift */; };
|
|
||||||
1CF65AA82A452D9C0042FFBD /* Exercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A442A39FB550042FFBD /* Exercise.swift */; };
|
|
||||||
1CF65AA92A452D9C0042FFBD /* Workout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A422A39FB410042FFBD /* Workout.swift */; };
|
|
||||||
1CF65AAA2A452D9C0042FFBD /* RegisteredUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A462A39FB6C0042FFBD /* RegisteredUser.swift */; };
|
|
||||||
1CF65AAC2A452DF50042FFBD /* WorkoutDetail.json in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A492A39FBB10042FFBD /* WorkoutDetail.json */; };
|
|
||||||
1CF65AAD2A452DF50042FFBD /* Exercises.json in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A662A3BFF840042FFBD /* Exercises.json */; };
|
|
||||||
1CF65AAE2A452DF50042FFBD /* AllWorkouts.json in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A642A3BF6BE0042FFBD /* AllWorkouts.json */; };
|
|
||||||
1CF65AAF2A452DF50042FFBD /* AllMuscles.json in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A562A3BF3830042FFBD /* AllMuscles.json */; };
|
|
||||||
1CF65AB02A452DF50042FFBD /* Equipment.json in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65A5E2A3BF5A60042FFBD /* Equipment.json */; };
|
|
||||||
1CF65AB12A452E1A0042FFBD /* BridgeModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A4F2A3A1EA90042FFBD /* BridgeModule.swift */; };
|
|
||||||
1CF65AB32A452F360042FFBD /* WatchPackageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65AB22A452F360042FFBD /* WatchPackageModel.swift */; };
|
|
||||||
1CF65AB42A4530200042FFBD /* WatchPackageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65AB22A452F360042FFBD /* WatchPackageModel.swift */; };
|
|
||||||
1CF65AB62A4532940042FFBD /* WatchMainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65AB52A4532940042FFBD /* WatchMainViewModel.swift */; };
|
1CF65AB62A4532940042FFBD /* WatchMainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65AB52A4532940042FFBD /* WatchMainViewModel.swift */; };
|
||||||
1CF65ABA2A4894430042FFBD /* UserStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65AB92A4894430042FFBD /* UserStore.swift */; };
|
D00100012E00000100000001 /* SharedCore in Frameworks */ = {isa = PBXBuildFile; productRef = D00100012E00000100000004 /* SharedCore */; };
|
||||||
1CF65ABC2A4897E20042FFBD /* RegisteredUser.json in Resources */ = {isa = PBXBuildFile; fileRef = 1CF65ABB2A4897E20042FFBD /* RegisteredUser.json */; };
|
D00100012E00000100000002 /* SharedCore in Frameworks */ = {isa = PBXBuildFile; productRef = D00100012E00000100000004 /* SharedCore */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -156,117 +49,68 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
1C0494822C23C56E003D18BB /* WatchMainViewModel+WCSessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WatchMainViewModel+WCSessionDelegate.swift"; sourceTree = "<group>"; };
|
1C0494822C23C56E003D18BB /* WatchMainViewModel+WCSessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WatchMainViewModel+WCSessionDelegate.swift"; sourceTree = "<group>"; };
|
||||||
1C0494862C23E7BD003D18BB /* BridgeModule+Watch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BridgeModule+Watch.swift"; sourceTree = "<group>"; };
|
|
||||||
1C0494892C25CB4F003D18BB /* BridgeModule+WorkoutActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BridgeModule+WorkoutActions.swift"; sourceTree = "<group>"; };
|
|
||||||
1C04948B2C25CB80003D18BB /* AudioEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngine.swift; sourceTree = "<group>"; };
|
|
||||||
1C0494922C25CEF0003D18BB /* BridgeModule+Timer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BridgeModule+Timer.swift"; sourceTree = "<group>"; };
|
|
||||||
1C1A3C712C3373E10010CDD5 /* WatchDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchDelegate.swift; sourceTree = "<group>"; };
|
1C1A3C712C3373E10010CDD5 /* WatchDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchDelegate.swift; sourceTree = "<group>"; };
|
||||||
1C1A3C732C3376150010CDD5 /* WatchWorkout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchWorkout.swift; sourceTree = "<group>"; };
|
1C1A3C732C3376150010CDD5 /* WatchWorkout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchWorkout.swift; sourceTree = "<group>"; };
|
||||||
1C31C8822A53AE3E00350540 /* short_beep.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = short_beep.m4a; sourceTree = "<group>"; };
|
|
||||||
1C31C8832A53AE3E00350540 /* long_beep.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = long_beep.m4a; sourceTree = "<group>"; };
|
|
||||||
1C31C8862A55B2CC00350540 /* PlayerUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerUIView.swift; sourceTree = "<group>"; };
|
|
||||||
1C485C822A489B9C00A6F896 /* CompletedWorkouts.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = CompletedWorkouts.json; sourceTree = "<group>"; };
|
|
||||||
1C485C862A4915C400A6F896 /* CreateWorkoutItemPickerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateWorkoutItemPickerView.swift; sourceTree = "<group>"; };
|
|
||||||
1C485C892A492BB400A6F896 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
|
||||||
1C485C8B2A49D65600A6F896 /* WorkoutHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutHistoryView.swift; sourceTree = "<group>"; };
|
|
||||||
1C4AFF142A60F25E0027710B /* ThotStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThotStyle.swift; sourceTree = "<group>"; };
|
|
||||||
1C4AFF172A65CD290027710B /* Superset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Superset.swift; sourceTree = "<group>"; };
|
|
||||||
1C4AFF1A2A65FB190027710B /* CurrentWorkoutInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWorkoutInfo.swift; sourceTree = "<group>"; };
|
|
||||||
1C4AFF1D2A7579410027710B /* NSFWVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSFWVideo.swift; sourceTree = "<group>"; };
|
|
||||||
1C4AFF1F2A8800860027710B /* AudioQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioQueue.swift; sourceTree = "<group>"; };
|
|
||||||
1C5190C12A57CA5F00885849 /* OvalTextFieldStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OvalTextFieldStyle.swift; sourceTree = "<group>"; };
|
|
||||||
1C5190C32A589CAC00885849 /* InfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoView.swift; sourceTree = "<group>"; };
|
|
||||||
1C5190C52A589CC100885849 /* ActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionsView.swift; sourceTree = "<group>"; };
|
|
||||||
1C5190C72A589CDA00885849 /* CurrentWorkoutElapsedTimeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWorkoutElapsedTimeView.swift; sourceTree = "<group>"; };
|
|
||||||
1C5190C92A589CEC00885849 /* ExerciseListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExerciseListView.swift; sourceTree = "<group>"; };
|
|
||||||
1C5190CB2A589D0000885849 /* CountdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountdownView.swift; sourceTree = "<group>"; };
|
|
||||||
1C5190CD2A589D4100885849 /* AllWorkoutPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWorkoutPickerView.swift; sourceTree = "<group>"; };
|
|
||||||
1C5190CF2A589D5F00885849 /* AllWorkoutsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWorkoutsListView.swift; sourceTree = "<group>"; };
|
|
||||||
1C5190D12A59ACA400885849 /* WatchControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchControlView.swift; sourceTree = "<group>"; };
|
1C5190D12A59ACA400885849 /* WatchControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchControlView.swift; sourceTree = "<group>"; };
|
||||||
1C5190D32A59AEDE00885849 /* MainWatchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWatchView.swift; sourceTree = "<group>"; };
|
1C5190D32A59AEDE00885849 /* MainWatchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWatchView.swift; sourceTree = "<group>"; };
|
||||||
1C6BF28E2A56602B00450FD7 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = "<group>"; };
|
|
||||||
1C6D0A3C2A4BEC9700D98B06 /* AVKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS9.4.sdk/System/Library/Frameworks/AVKit.framework; sourceTree = DEVELOPER_DIR; };
|
1C6D0A3C2A4BEC9700D98B06 /* AVKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS9.4.sdk/System/Library/Frameworks/AVKit.framework; sourceTree = DEVELOPER_DIR; };
|
||||||
1C6D0A3D2A4BEC9700D98B06 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS9.4.sdk/System/Library/Frameworks/AVFoundation.framework; sourceTree = DEVELOPER_DIR; };
|
1C6D0A3D2A4BEC9700D98B06 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS9.4.sdk/System/Library/Frameworks/AVFoundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||||
1C6D0A402A4BECA400D98B06 /* AVKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.4.sdk/System/Library/Frameworks/AVKit.framework; sourceTree = DEVELOPER_DIR; };
|
1C6D0A402A4BECA400D98B06 /* AVKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.4.sdk/System/Library/Frameworks/AVKit.framework; sourceTree = DEVELOPER_DIR; };
|
||||||
1CAF4D892A5132F900B00E50 /* PlannedWorkout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlannedWorkout.swift; sourceTree = "<group>"; };
|
|
||||||
1CAF4D8B2A51339200B00E50 /* PlannedWorkouts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = PlannedWorkouts.json; sourceTree = "<group>"; };
|
|
||||||
1CAF4D942A52180600B00E50 /* PlanWorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlanWorkoutView.swift; sourceTree = "<group>"; };
|
|
||||||
1CC092EC2C1FAC730004E1E6 /* Logoutview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logoutview.swift; sourceTree = "<group>"; };
|
|
||||||
1CC092EE2C1FACFC0004E1E6 /* NameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameView.swift; sourceTree = "<group>"; };
|
|
||||||
1CC092F02C1FAD1E0004E1E6 /* CompletedWorkoutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedWorkoutsView.swift; sourceTree = "<group>"; };
|
|
||||||
1CC092F22C1FADDA0004E1E6 /* ThotPreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThotPreferenceView.swift; sourceTree = "<group>"; };
|
|
||||||
1CC092F42C1FAE7B0004E1E6 /* ShowNextUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowNextUpView.swift; sourceTree = "<group>"; };
|
|
||||||
1CC092F62C1FAFD50004E1E6 /* AllMusclesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllMusclesView.swift; sourceTree = "<group>"; };
|
|
||||||
1CC092F82C1FB1420004E1E6 /* AllExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllExerciseView.swift; sourceTree = "<group>"; };
|
|
||||||
1CC092FA2C1FB3320004E1E6 /* AllEquipmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllEquipmentView.swift; sourceTree = "<group>"; };
|
|
||||||
1CC092FC2C20B0A30004E1E6 /* ExtCountdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtCountdownView.swift; sourceTree = "<group>"; };
|
|
||||||
1CC092FE2C20B0C80004E1E6 /* ExtExerciseList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtExerciseList.swift; sourceTree = "<group>"; };
|
|
||||||
1CC093002C20B0E90004E1E6 /* TitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleView.swift; sourceTree = "<group>"; };
|
|
||||||
1CC7CBD42C221159001614B8 /* CaloriesBurnedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaloriesBurnedView.swift; sourceTree = "<group>"; };
|
|
||||||
1CC7CBD62C2211F0001614B8 /* RateWorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateWorkoutView.swift; sourceTree = "<group>"; };
|
|
||||||
1CC7CBD82C221286001614B8 /* WorkoutInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutInfoView.swift; sourceTree = "<group>"; };
|
|
||||||
1CC7CBDA2C221666001614B8 /* PlannedWorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlannedWorkoutView.swift; sourceTree = "<group>"; };
|
|
||||||
1CD0C6622A5AF62900970E52 /* WorkoutOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutOverviewView.swift; sourceTree = "<group>"; };
|
|
||||||
1CD0C6662A5CA19600970E52 /* BaseURLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseURLs.swift; sourceTree = "<group>"; };
|
|
||||||
1CD0C66B2A5E4EA100970E52 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
|
1CD0C66B2A5E4EA100970E52 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
1CEF74AA2A89937800C1AE6A /* HealthKitHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitHelper.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A222A3972840042FFBD /* Werkout_ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Werkout_ios.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
1CF65A222A3972840042FFBD /* Werkout_ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Werkout_ios.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
1CF65A252A3972840042FFBD /* Werkout_iosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Werkout_iosApp.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A272A3972840042FFBD /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A2A2A3972840042FFBD /* Werkout_ios.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Werkout_ios.xcdatamodel; sourceTree = "<group>"; };
|
|
||||||
1CF65A2C2A3972840042FFBD /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A2E2A3972850042FFBD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
|
||||||
1CF65A302A3972850042FFBD /* Werkout_ios.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Werkout_ios.entitlements; sourceTree = "<group>"; };
|
|
||||||
1CF65A322A3972850042FFBD /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
|
||||||
1CF65A3B2A3972CE0042FFBD /* ExternalWorkoutDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalWorkoutDetailView.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A422A39FB410042FFBD /* Workout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workout.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A442A39FB550042FFBD /* Exercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exercise.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A462A39FB6C0042FFBD /* RegisteredUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisteredUser.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A492A39FBB10042FFBD /* WorkoutDetail.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = WorkoutDetail.json; sourceTree = "<group>"; };
|
|
||||||
1CF65A4B2A39FDA20042FFBD /* WorkoutDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutDetailView.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A4D2A39FF200042FFBD /* WorkoutDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutDetailViewModel.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A4F2A3A1EA90042FFBD /* BridgeModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgeModule.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A512A3A90A00042FFBD /* PreviewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewData.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A532A3A9A990042FFBD /* Straight_Leg_Sit_Up.mp4 */ = {isa = PBXFileReference; lastKnownFileType = file; path = Straight_Leg_Sit_Up.mp4; sourceTree = "<group>"; };
|
|
||||||
1CF65A552A3AA6800042FFBD /* Werkout-ios-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Werkout-ios-Info.plist"; sourceTree = SOURCE_ROOT; };
|
|
||||||
1CF65A562A3BF3830042FFBD /* AllMuscles.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = AllMuscles.json; sourceTree = "<group>"; };
|
|
||||||
1CF65A582A3BF4B60042FFBD /* Muscle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Muscle.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A5A2A3BF4BE0042FFBD /* Equipment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Equipment.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A5E2A3BF5A60042FFBD /* Equipment.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Equipment.json; sourceTree = "<group>"; };
|
|
||||||
1CF65A602A3BF6020042FFBD /* AddExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseView.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A622A3BF6A30042FFBD /* AllWorkoutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWorkoutsView.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A642A3BF6BE0042FFBD /* AllWorkouts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = AllWorkouts.json; sourceTree = "<group>"; };
|
|
||||||
1CF65A662A3BFF840042FFBD /* Exercises.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Exercises.json; sourceTree = "<group>"; };
|
|
||||||
1CF65A682A3C018F0042FFBD /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A6A2A3C1EAC0042FFBD /* CreateWorkoutMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateWorkoutMainView.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A6D2A3F60480042FFBD /* CreateViewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateViewModels.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A722A3F60D20042FFBD /* CreateExerciseActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateExerciseActionsView.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A7A2A3F83440042FFBD /* CreateWorkoutSupersetActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateWorkoutSupersetActionsView.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A7C2A41275D0042FFBD /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A7E2A4129320042FFBD /* Fetchables.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fetchables.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A802A412AA30042FFBD /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A822A42347D0042FFBD /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A842A43E8060042FFBD /* CompletedWorkout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedWorkout.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A862A4400E10042FFBD /* ToDo */ = {isa = PBXFileReference; lastKnownFileType = text; path = ToDo; sourceTree = "<group>"; };
|
|
||||||
1CF65A8D2A44B78B0042FFBD /* CompletedWorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedWorkoutView.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65A932A452D270042FFBD /* Werkout_watch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Werkout_watch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
1CF65A932A452D270042FFBD /* Werkout_watch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Werkout_watch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
1CF65A952A452D270042FFBD /* Werkout_watchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Werkout_watchApp.swift; sourceTree = "<group>"; };
|
1CF65A952A452D270042FFBD /* Werkout_watchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Werkout_watchApp.swift; sourceTree = "<group>"; };
|
||||||
1CF65A972A452D270042FFBD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
1CF65A972A452D270042FFBD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
1CF65A992A452D290042FFBD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
1CF65A992A452D290042FFBD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
1CF65A9C2A452D290042FFBD /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
1CF65A9C2A452D290042FFBD /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||||
1CF65AB22A452F360042FFBD /* WatchPackageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchPackageModel.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65AB52A4532940042FFBD /* WatchMainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchMainViewModel.swift; sourceTree = "<group>"; };
|
1CF65AB52A4532940042FFBD /* WatchMainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchMainViewModel.swift; sourceTree = "<group>"; };
|
||||||
1CF65AB72A4534DC0042FFBD /* Werkout_watch Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Werkout_watch Watch App.entitlements"; sourceTree = "<group>"; };
|
1CF65AB72A4534DC0042FFBD /* Werkout_watch Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Werkout_watch Watch App.entitlements"; sourceTree = "<group>"; };
|
||||||
1CF65AB82A45387B0042FFBD /* Werkout-watch-Watch-App-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Werkout-watch-Watch-App-Info.plist"; sourceTree = SOURCE_ROOT; };
|
1CF65AB82A45387B0042FFBD /* Werkout-watch-Watch-App-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Werkout-watch-Watch-App-Info.plist"; sourceTree = SOURCE_ROOT; };
|
||||||
1CF65AB92A4894430042FFBD /* UserStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserStore.swift; sourceTree = "<group>"; };
|
|
||||||
1CF65ABB2A4897E20042FFBD /* RegisteredUser.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = RegisteredUser.json; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
1CE887EE2DDC27DB008E9727 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
APIModels/AudioQueue.swift,
|
||||||
|
APIModels/CompletedWorkout.swift,
|
||||||
|
APIModels/Equipment.swift,
|
||||||
|
APIModels/Exercise.swift,
|
||||||
|
APIModels/Muscle.swift,
|
||||||
|
APIModels/RegisteredUser.swift,
|
||||||
|
APIModels/Superset.swift,
|
||||||
|
APIModels/Workout.swift,
|
||||||
|
AudioEngine.swift,
|
||||||
|
BaseURLs.swift,
|
||||||
|
BridgeModule.swift,
|
||||||
|
"BridgeModule+Timer.swift",
|
||||||
|
"BridgeModule+Watch.swift",
|
||||||
|
"BridgeModule+WorkoutActions.swift",
|
||||||
|
CurrentWorkoutInfo.swift,
|
||||||
|
Extensions.swift,
|
||||||
|
JSON/AllMuscles.json,
|
||||||
|
JSON/AllWorkouts.json,
|
||||||
|
JSON/Equipment.json,
|
||||||
|
JSON/Exercises.json,
|
||||||
|
JSON/PreviewData.swift,
|
||||||
|
JSON/WorkoutDetail.json,
|
||||||
|
ThotStyle.swift,
|
||||||
|
WatchPackageModel.swift,
|
||||||
|
);
|
||||||
|
target = 1CF65A922A452D270042FFBD /* Werkout_watch Watch App */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
1CE8877B2DDC27DB008E9727 /* Werkout_ios */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (1CE887EE2DDC27DB008E9727 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Werkout_ios; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
1CF65A1F2A3972840042FFBD /* Frameworks */ = {
|
1CF65A1F2A3972840042FFBD /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
D00100012E00000100000001 /* SharedCore in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -274,20 +118,13 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
D00100012E00000100000002 /* SharedCore in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
1C485C882A492BAA00A6F896 /* Login */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1C485C892A492BB400A6F896 /* LoginView.swift */,
|
|
||||||
);
|
|
||||||
path = Login;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1C6D0A3B2A4BEC9600D98B06 /* Frameworks */ = {
|
1C6D0A3B2A4BEC9600D98B06 /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -298,40 +135,11 @@
|
|||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
1CC092EB2C1FAC2A0004E1E6 /* subview */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1C5190C52A589CC100885849 /* ActionsView.swift */,
|
|
||||||
1CC092FA2C1FB3320004E1E6 /* AllEquipmentView.swift */,
|
|
||||||
1CC092F82C1FB1420004E1E6 /* AllExerciseView.swift */,
|
|
||||||
1CC092F62C1FAFD50004E1E6 /* AllMusclesView.swift */,
|
|
||||||
1C5190CD2A589D4100885849 /* AllWorkoutPickerView.swift */,
|
|
||||||
1CC7CBD42C221159001614B8 /* CaloriesBurnedView.swift */,
|
|
||||||
1CC092F02C1FAD1E0004E1E6 /* CompletedWorkoutsView.swift */,
|
|
||||||
1C5190CB2A589D0000885849 /* CountdownView.swift */,
|
|
||||||
1CC092FC2C20B0A30004E1E6 /* ExtCountdownView.swift */,
|
|
||||||
1CC092FE2C20B0C80004E1E6 /* ExtExerciseList.swift */,
|
|
||||||
1C5190C32A589CAC00885849 /* InfoView.swift */,
|
|
||||||
1CC092EC2C1FAC730004E1E6 /* Logoutview.swift */,
|
|
||||||
1CC092EE2C1FACFC0004E1E6 /* NameView.swift */,
|
|
||||||
1C5190C12A57CA5F00885849 /* OvalTextFieldStyle.swift */,
|
|
||||||
1CC7CBDA2C221666001614B8 /* PlannedWorkoutView.swift */,
|
|
||||||
1C31C8862A55B2CC00350540 /* PlayerUIView.swift */,
|
|
||||||
1CC7CBD62C2211F0001614B8 /* RateWorkoutView.swift */,
|
|
||||||
1CC092F42C1FAE7B0004E1E6 /* ShowNextUpView.swift */,
|
|
||||||
1CC092F22C1FADDA0004E1E6 /* ThotPreferenceView.swift */,
|
|
||||||
1CC093002C20B0E90004E1E6 /* TitleView.swift */,
|
|
||||||
1CC7CBD82C221286001614B8 /* WorkoutInfoView.swift */,
|
|
||||||
1CD0C6622A5AF62900970E52 /* WorkoutOverviewView.swift */,
|
|
||||||
);
|
|
||||||
path = subview;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1CF65A192A3972840042FFBD = {
|
1CF65A192A3972840042FFBD = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
1CD0C66B2A5E4EA100970E52 /* LaunchScreen.storyboard */,
|
1CD0C66B2A5E4EA100970E52 /* LaunchScreen.storyboard */,
|
||||||
1CF65A242A3972840042FFBD /* Werkout_ios */,
|
1CE8877B2DDC27DB008E9727 /* Werkout_ios */,
|
||||||
1CF65A942A452D270042FFBD /* Werkout_watch Watch App */,
|
1CF65A942A452D270042FFBD /* Werkout_watch Watch App */,
|
||||||
1CF65A232A3972840042FFBD /* Products */,
|
1CF65A232A3972840042FFBD /* Products */,
|
||||||
1C6D0A3B2A4BEC9600D98B06 /* Frameworks */,
|
1C6D0A3B2A4BEC9600D98B06 /* Frameworks */,
|
||||||
@@ -347,167 +155,6 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
1CF65A242A3972840042FFBD /* Werkout_ios */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1C31C8832A53AE3E00350540 /* long_beep.m4a */,
|
|
||||||
1C31C8822A53AE3E00350540 /* short_beep.m4a */,
|
|
||||||
1CF65A552A3AA6800042FFBD /* Werkout-ios-Info.plist */,
|
|
||||||
1CF65A482A39FB910042FFBD /* JSON */,
|
|
||||||
1CF65A252A3972840042FFBD /* Werkout_iosApp.swift */,
|
|
||||||
1CF65AB22A452F360042FFBD /* WatchPackageModel.swift */,
|
|
||||||
1CF65A822A42347D0042FFBD /* Extensions.swift */,
|
|
||||||
1CF65A272A3972840042FFBD /* Persistence.swift */,
|
|
||||||
1C04948B2C25CB80003D18BB /* AudioEngine.swift */,
|
|
||||||
1CD0C6662A5CA19600970E52 /* BaseURLs.swift */,
|
|
||||||
1C4AFF1A2A65FB190027710B /* CurrentWorkoutInfo.swift */,
|
|
||||||
1CF65A4F2A3A1EA90042FFBD /* BridgeModule.swift */,
|
|
||||||
1C0494922C25CEF0003D18BB /* BridgeModule+Timer.swift */,
|
|
||||||
1C0494862C23E7BD003D18BB /* BridgeModule+Watch.swift */,
|
|
||||||
1C0494892C25CB4F003D18BB /* BridgeModule+WorkoutActions.swift */,
|
|
||||||
1CF65A802A412AA30042FFBD /* DataStore.swift */,
|
|
||||||
1CF65AB92A4894430042FFBD /* UserStore.swift */,
|
|
||||||
1C6BF28E2A56602B00450FD7 /* Keychain.swift */,
|
|
||||||
1C4AFF142A60F25E0027710B /* ThotStyle.swift */,
|
|
||||||
1CC092EB2C1FAC2A0004E1E6 /* subview */,
|
|
||||||
1CF65A3F2A3973840042FFBD /* Views */,
|
|
||||||
1CF65A3E2A39737D0042FFBD /* APIModels */,
|
|
||||||
1CF65A3D2A3973760042FFBD /* Network */,
|
|
||||||
1CF65A2E2A3972850042FFBD /* Assets.xcassets */,
|
|
||||||
1CF65A302A3972850042FFBD /* Werkout_ios.entitlements */,
|
|
||||||
1CF65A292A3972840042FFBD /* Werkout_ios.xcdatamodeld */,
|
|
||||||
1CF65A312A3972850042FFBD /* Preview Content */,
|
|
||||||
1CF65A862A4400E10042FFBD /* ToDo */,
|
|
||||||
1CEF74AA2A89937800C1AE6A /* HealthKitHelper.swift */,
|
|
||||||
);
|
|
||||||
path = Werkout_ios;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1CF65A312A3972850042FFBD /* Preview Content */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1CF65A532A3A9A990042FFBD /* Straight_Leg_Sit_Up.mp4 */,
|
|
||||||
1CF65A322A3972850042FFBD /* Preview Assets.xcassets */,
|
|
||||||
);
|
|
||||||
path = "Preview Content";
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1CF65A3D2A3973760042FFBD /* Network */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1CF65A7C2A41275D0042FFBD /* Network.swift */,
|
|
||||||
1CF65A7E2A4129320042FFBD /* Fetchables.swift */,
|
|
||||||
);
|
|
||||||
path = Network;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1CF65A3E2A39737D0042FFBD /* APIModels */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1CF65A842A43E8060042FFBD /* CompletedWorkout.swift */,
|
|
||||||
1CF65A5A2A3BF4BE0042FFBD /* Equipment.swift */,
|
|
||||||
1CF65A442A39FB550042FFBD /* Exercise.swift */,
|
|
||||||
1CF65A582A3BF4B60042FFBD /* Muscle.swift */,
|
|
||||||
1CAF4D892A5132F900B00E50 /* PlannedWorkout.swift */,
|
|
||||||
1CF65A462A39FB6C0042FFBD /* RegisteredUser.swift */,
|
|
||||||
1CF65A422A39FB410042FFBD /* Workout.swift */,
|
|
||||||
1C4AFF172A65CD290027710B /* Superset.swift */,
|
|
||||||
1C4AFF1D2A7579410027710B /* NSFWVideo.swift */,
|
|
||||||
1C4AFF1F2A8800860027710B /* AudioQueue.swift */,
|
|
||||||
);
|
|
||||||
path = APIModels;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1CF65A3F2A3973840042FFBD /* Views */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1CF65A3B2A3972CE0042FFBD /* ExternalWorkoutDetailView.swift */,
|
|
||||||
1CF65A2C2A3972840042FFBD /* MainView.swift */,
|
|
||||||
1CAF4D942A52180600B00E50 /* PlanWorkoutView.swift */,
|
|
||||||
1C485C8B2A49D65600A6F896 /* WorkoutHistoryView.swift */,
|
|
||||||
1CF65A8B2A44B7590042FFBD /* AccountView */,
|
|
||||||
1CF65A8A2A44B74D0042FFBD /* AddExercise */,
|
|
||||||
1CF65A892A44B7390042FFBD /* AllWorkouts */,
|
|
||||||
1CF65A8C2A44B7680042FFBD /* CompletedWorkout */,
|
|
||||||
1CF65A6C2A3F60100042FFBD /* CreateWorkout */,
|
|
||||||
1C485C882A492BAA00A6F896 /* Login */,
|
|
||||||
1CF65A882A44B7290042FFBD /* WorkoutDetail */,
|
|
||||||
);
|
|
||||||
path = Views;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1CF65A482A39FB910042FFBD /* JSON */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1CF65A512A3A90A00042FFBD /* PreviewData.swift */,
|
|
||||||
1CF65A642A3BF6BE0042FFBD /* AllWorkouts.json */,
|
|
||||||
1CF65A492A39FBB10042FFBD /* WorkoutDetail.json */,
|
|
||||||
1CF65A562A3BF3830042FFBD /* AllMuscles.json */,
|
|
||||||
1CF65A5E2A3BF5A60042FFBD /* Equipment.json */,
|
|
||||||
1CF65A662A3BFF840042FFBD /* Exercises.json */,
|
|
||||||
1CF65ABB2A4897E20042FFBD /* RegisteredUser.json */,
|
|
||||||
1C485C822A489B9C00A6F896 /* CompletedWorkouts.json */,
|
|
||||||
1CAF4D8B2A51339200B00E50 /* PlannedWorkouts.json */,
|
|
||||||
);
|
|
||||||
path = JSON;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1CF65A6C2A3F60100042FFBD /* CreateWorkout */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1CF65A6D2A3F60480042FFBD /* CreateViewModels.swift */,
|
|
||||||
1CF65A6A2A3C1EAC0042FFBD /* CreateWorkoutMainView.swift */,
|
|
||||||
1CF65A722A3F60D20042FFBD /* CreateExerciseActionsView.swift */,
|
|
||||||
1CF65A7A2A3F83440042FFBD /* CreateWorkoutSupersetActionsView.swift */,
|
|
||||||
1C485C862A4915C400A6F896 /* CreateWorkoutItemPickerView.swift */,
|
|
||||||
);
|
|
||||||
path = CreateWorkout;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1CF65A882A44B7290042FFBD /* WorkoutDetail */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1CF65A4B2A39FDA20042FFBD /* WorkoutDetailView.swift */,
|
|
||||||
1CF65A4D2A39FF200042FFBD /* WorkoutDetailViewModel.swift */,
|
|
||||||
1C5190C72A589CDA00885849 /* CurrentWorkoutElapsedTimeView.swift */,
|
|
||||||
1C5190C92A589CEC00885849 /* ExerciseListView.swift */,
|
|
||||||
);
|
|
||||||
path = WorkoutDetail;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1CF65A892A44B7390042FFBD /* AllWorkouts */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1CF65A622A3BF6A30042FFBD /* AllWorkoutsView.swift */,
|
|
||||||
1C5190CF2A589D5F00885849 /* AllWorkoutsListView.swift */,
|
|
||||||
);
|
|
||||||
path = AllWorkouts;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1CF65A8A2A44B74D0042FFBD /* AddExercise */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1CF65A602A3BF6020042FFBD /* AddExerciseView.swift */,
|
|
||||||
);
|
|
||||||
path = AddExercise;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1CF65A8B2A44B7590042FFBD /* AccountView */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1CF65A682A3C018F0042FFBD /* AccountView.swift */,
|
|
||||||
);
|
|
||||||
path = AccountView;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1CF65A8C2A44B7680042FFBD /* CompletedWorkout */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1CF65A8D2A44B78B0042FFBD /* CompletedWorkoutView.swift */,
|
|
||||||
);
|
|
||||||
path = CompletedWorkout;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
1CF65A942A452D270042FFBD /* Werkout_watch Watch App */ = {
|
1CF65A942A452D270042FFBD /* Werkout_watch Watch App */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -552,8 +199,12 @@
|
|||||||
dependencies = (
|
dependencies = (
|
||||||
1C530FBA2A8C93AB00B766AA /* PBXTargetDependency */,
|
1C530FBA2A8C93AB00B766AA /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
1CE8877B2DDC27DB008E9727 /* Werkout_ios */,
|
||||||
|
);
|
||||||
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 */;
|
||||||
@@ -572,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";
|
||||||
@@ -604,6 +258,7 @@
|
|||||||
);
|
);
|
||||||
mainGroup = 1CF65A192A3972840042FFBD;
|
mainGroup = 1CF65A192A3972840042FFBD;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
|
D00100012E00000100000003 /* XCLocalSwiftPackageReference "../SharedCore" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 1CF65A232A3972840042FFBD /* Products */;
|
productRefGroup = 1CF65A232A3972840042FFBD /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -620,21 +275,7 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
1CF65A5F2A3BF5A60042FFBD /* Equipment.json in Resources */,
|
|
||||||
1C485C832A489B9C00A6F896 /* CompletedWorkouts.json in Resources */,
|
|
||||||
1C31C8852A53AE3E00350540 /* long_beep.m4a in Resources */,
|
|
||||||
1CD0C66C2A5E4EA100970E52 /* LaunchScreen.storyboard in Resources */,
|
1CD0C66C2A5E4EA100970E52 /* LaunchScreen.storyboard in Resources */,
|
||||||
1CF65A4A2A39FBB10042FFBD /* WorkoutDetail.json in Resources */,
|
|
||||||
1CF65A652A3BF6BE0042FFBD /* AllWorkouts.json in Resources */,
|
|
||||||
1CF65A332A3972850042FFBD /* Preview Assets.xcassets in Resources */,
|
|
||||||
1CF65ABC2A4897E20042FFBD /* RegisteredUser.json in Resources */,
|
|
||||||
1CF65A672A3BFF840042FFBD /* Exercises.json in Resources */,
|
|
||||||
1CF65A572A3BF3830042FFBD /* AllMuscles.json in Resources */,
|
|
||||||
1CAF4D8C2A51339200B00E50 /* PlannedWorkouts.json in Resources */,
|
|
||||||
1CF65A542A3A9AF30042FFBD /* Straight_Leg_Sit_Up.mp4 in Resources */,
|
|
||||||
1CF65A2F2A3972850042FFBD /* Assets.xcassets in Resources */,
|
|
||||||
1CF65A872A4400E10042FFBD /* ToDo in Resources */,
|
|
||||||
1C31C8842A53AE3E00350540 /* short_beep.m4a in Resources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -642,13 +283,8 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
1CF65AAF2A452DF50042FFBD /* AllMuscles.json in Resources */,
|
|
||||||
1CF65AAC2A452DF50042FFBD /* WorkoutDetail.json in Resources */,
|
|
||||||
1CF65AAD2A452DF50042FFBD /* Exercises.json in Resources */,
|
|
||||||
1CF65AB02A452DF50042FFBD /* Equipment.json in Resources */,
|
|
||||||
1CF65A9D2A452D290042FFBD /* Preview Assets.xcassets in Resources */,
|
1CF65A9D2A452D290042FFBD /* Preview Assets.xcassets in Resources */,
|
||||||
1CF65A9A2A452D290042FFBD /* Assets.xcassets in Resources */,
|
1CF65A9A2A452D290042FFBD /* Assets.xcassets in Resources */,
|
||||||
1CF65AAE2A452DF50042FFBD /* AllWorkouts.json in Resources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -659,77 +295,6 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
1C04948A2C25CB4F003D18BB /* BridgeModule+WorkoutActions.swift in Sources */,
|
|
||||||
1CF65A7F2A4129320042FFBD /* Fetchables.swift in Sources */,
|
|
||||||
1CF65A812A412AA30042FFBD /* DataStore.swift in Sources */,
|
|
||||||
1CF65A6B2A3C1EAC0042FFBD /* CreateWorkoutMainView.swift in Sources */,
|
|
||||||
1CF65A7B2A3F83440042FFBD /* CreateWorkoutSupersetActionsView.swift in Sources */,
|
|
||||||
1CF65A262A3972840042FFBD /* Werkout_iosApp.swift in Sources */,
|
|
||||||
1C6BF28F2A56602B00450FD7 /* Keychain.swift in Sources */,
|
|
||||||
1CD0C6632A5AF62900970E52 /* WorkoutOverviewView.swift in Sources */,
|
|
||||||
1C5190C42A589CAC00885849 /* InfoView.swift in Sources */,
|
|
||||||
1CF65A3C2A3972CE0042FFBD /* ExternalWorkoutDetailView.swift in Sources */,
|
|
||||||
1CF65A632A3BF6A30042FFBD /* AllWorkoutsView.swift in Sources */,
|
|
||||||
1CF65A692A3C018F0042FFBD /* AccountView.swift in Sources */,
|
|
||||||
1CC092EF2C1FACFC0004E1E6 /* NameView.swift in Sources */,
|
|
||||||
1CF65A4E2A39FF200042FFBD /* WorkoutDetailViewModel.swift in Sources */,
|
|
||||||
1CC092FB2C1FB3320004E1E6 /* AllEquipmentView.swift in Sources */,
|
|
||||||
1CC092FD2C20B0A30004E1E6 /* ExtCountdownView.swift in Sources */,
|
|
||||||
1CF65A472A39FB6C0042FFBD /* RegisteredUser.swift in Sources */,
|
|
||||||
1CF65AB32A452F360042FFBD /* WatchPackageModel.swift in Sources */,
|
|
||||||
1CC092FF2C20B0C80004E1E6 /* ExtExerciseList.swift in Sources */,
|
|
||||||
1CD0C6672A5CA19600970E52 /* BaseURLs.swift in Sources */,
|
|
||||||
1CF65A522A3A90A00042FFBD /* PreviewData.swift in Sources */,
|
|
||||||
1C5190CE2A589D4100885849 /* AllWorkoutPickerView.swift in Sources */,
|
|
||||||
1CF65A852A43E8060042FFBD /* CompletedWorkout.swift in Sources */,
|
|
||||||
1CF65A6E2A3F60480042FFBD /* CreateViewModels.swift in Sources */,
|
|
||||||
1CF65A4C2A39FDA20042FFBD /* WorkoutDetailView.swift in Sources */,
|
|
||||||
1CC092F12C1FAD1E0004E1E6 /* CompletedWorkoutsView.swift in Sources */,
|
|
||||||
1C4AFF182A65CD290027710B /* Superset.swift in Sources */,
|
|
||||||
1CF65A8E2A44B78B0042FFBD /* CompletedWorkoutView.swift in Sources */,
|
|
||||||
1CF65A432A39FB410042FFBD /* Workout.swift in Sources */,
|
|
||||||
1CF65A502A3A1EA90042FFBD /* BridgeModule.swift in Sources */,
|
|
||||||
1CF65A592A3BF4B60042FFBD /* Muscle.swift in Sources */,
|
|
||||||
1CC7CBD92C221286001614B8 /* WorkoutInfoView.swift in Sources */,
|
|
||||||
1CC092F32C1FADDA0004E1E6 /* ThotPreferenceView.swift in Sources */,
|
|
||||||
1C4AFF1E2A7579410027710B /* NSFWVideo.swift in Sources */,
|
|
||||||
1CC7CBD52C221159001614B8 /* CaloriesBurnedView.swift in Sources */,
|
|
||||||
1CC092F72C1FAFD50004E1E6 /* AllMusclesView.swift in Sources */,
|
|
||||||
1C5190D02A589D5F00885849 /* AllWorkoutsListView.swift in Sources */,
|
|
||||||
1C5190CA2A589CEC00885849 /* ExerciseListView.swift in Sources */,
|
|
||||||
1C4AFF202A8800860027710B /* AudioQueue.swift in Sources */,
|
|
||||||
1CAF4D8A2A5132F900B00E50 /* PlannedWorkout.swift in Sources */,
|
|
||||||
1C5190C22A57CA5F00885849 /* OvalTextFieldStyle.swift in Sources */,
|
|
||||||
1C5190CC2A589D0000885849 /* CountdownView.swift in Sources */,
|
|
||||||
1CF65A2B2A3972840042FFBD /* Werkout_ios.xcdatamodeld in Sources */,
|
|
||||||
1C31C8872A55B2CC00350540 /* PlayerUIView.swift in Sources */,
|
|
||||||
1CEF74AB2A89937800C1AE6A /* HealthKitHelper.swift in Sources */,
|
|
||||||
1CF65A2D2A3972840042FFBD /* MainView.swift in Sources */,
|
|
||||||
1CC7CBDB2C221666001614B8 /* PlannedWorkoutView.swift in Sources */,
|
|
||||||
1CF65A7D2A41275D0042FFBD /* Network.swift in Sources */,
|
|
||||||
1C485C8A2A492BB400A6F896 /* LoginView.swift in Sources */,
|
|
||||||
1C4AFF1B2A65FB190027710B /* CurrentWorkoutInfo.swift in Sources */,
|
|
||||||
1CF65A732A3F60D20042FFBD /* CreateExerciseActionsView.swift in Sources */,
|
|
||||||
1CC092ED2C1FAC730004E1E6 /* Logoutview.swift in Sources */,
|
|
||||||
1CF65A832A42347D0042FFBD /* Extensions.swift in Sources */,
|
|
||||||
1CF65A282A3972840042FFBD /* Persistence.swift in Sources */,
|
|
||||||
1CF65ABA2A4894430042FFBD /* UserStore.swift in Sources */,
|
|
||||||
1C485C8C2A49D65600A6F896 /* WorkoutHistoryView.swift in Sources */,
|
|
||||||
1CAF4D952A52180600B00E50 /* PlanWorkoutView.swift in Sources */,
|
|
||||||
1C5190C62A589CC100885849 /* ActionsView.swift in Sources */,
|
|
||||||
1C0494932C25CEF0003D18BB /* BridgeModule+Timer.swift in Sources */,
|
|
||||||
1CC092F52C1FAE7B0004E1E6 /* ShowNextUpView.swift in Sources */,
|
|
||||||
1CF65A5B2A3BF4BE0042FFBD /* Equipment.swift in Sources */,
|
|
||||||
1C4AFF152A60F25F0027710B /* ThotStyle.swift in Sources */,
|
|
||||||
1C0494872C23E7BD003D18BB /* BridgeModule+Watch.swift in Sources */,
|
|
||||||
1C04948C2C25CB80003D18BB /* AudioEngine.swift in Sources */,
|
|
||||||
1C5190C82A589CDA00885849 /* CurrentWorkoutElapsedTimeView.swift in Sources */,
|
|
||||||
1CC092F92C1FB1420004E1E6 /* AllExerciseView.swift in Sources */,
|
|
||||||
1CC093012C20B0E90004E1E6 /* TitleView.swift in Sources */,
|
|
||||||
1CC7CBD72C2211F0001614B8 /* RateWorkoutView.swift in Sources */,
|
|
||||||
1CF65A452A39FB550042FFBD /* Exercise.swift in Sources */,
|
|
||||||
1CF65A612A3BF6020042FFBD /* AddExerciseView.swift in Sources */,
|
|
||||||
1C485C872A4915C400A6F896 /* CreateWorkoutItemPickerView.swift in Sources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -737,32 +302,13 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
1C4AFF222A885EBD0027710B /* PreviewData.swift in Sources */,
|
|
||||||
1C1A3C722C3373E10010CDD5 /* WatchDelegate.swift in Sources */,
|
1C1A3C722C3373E10010CDD5 /* WatchDelegate.swift in Sources */,
|
||||||
1CF65A982A452D270042FFBD /* ContentView.swift in Sources */,
|
1CF65A982A452D270042FFBD /* ContentView.swift in Sources */,
|
||||||
1CF65A962A452D270042FFBD /* Werkout_watchApp.swift in Sources */,
|
1CF65A962A452D270042FFBD /* Werkout_watchApp.swift in Sources */,
|
||||||
1C4AFF192A65CD6F0027710B /* Superset.swift in Sources */,
|
|
||||||
1CF65AA92A452D9C0042FFBD /* Workout.swift in Sources */,
|
|
||||||
1C0494882C23E7C5003D18BB /* BridgeModule+Watch.swift in Sources */,
|
|
||||||
1CF65AA62A452D9C0042FFBD /* Equipment.swift in Sources */,
|
|
||||||
1C4AFF162A60F27E0027710B /* ThotStyle.swift in Sources */,
|
|
||||||
1C4AFF212A8801090027710B /* AudioQueue.swift in Sources */,
|
|
||||||
1CF65AB12A452E1A0042FFBD /* BridgeModule.swift in Sources */,
|
|
||||||
1C0494832C23C56E003D18BB /* WatchMainViewModel+WCSessionDelegate.swift in Sources */,
|
1C0494832C23C56E003D18BB /* WatchMainViewModel+WCSessionDelegate.swift in Sources */,
|
||||||
1CF65AAA2A452D9C0042FFBD /* RegisteredUser.swift in Sources */,
|
|
||||||
1C04948D2C25CC93003D18BB /* AudioEngine.swift in Sources */,
|
|
||||||
1CF65AB62A4532940042FFBD /* WatchMainViewModel.swift in Sources */,
|
1CF65AB62A4532940042FFBD /* WatchMainViewModel.swift in Sources */,
|
||||||
1C0494942C25CEF4003D18BB /* BridgeModule+Timer.swift in Sources */,
|
|
||||||
1CD0C6682A5CA1A200970E52 /* BaseURLs.swift in Sources */,
|
|
||||||
1CF65AA72A452D9C0042FFBD /* Muscle.swift in Sources */,
|
|
||||||
1C4AFF1C2A65FB2B0027710B /* CurrentWorkoutInfo.swift in Sources */,
|
|
||||||
1C485C8D2A49D95700A6F896 /* Extensions.swift in Sources */,
|
|
||||||
1CF65AA52A452D9C0042FFBD /* CompletedWorkout.swift in Sources */,
|
|
||||||
1CF65AA82A452D9C0042FFBD /* Exercise.swift in Sources */,
|
|
||||||
1C5190D22A59ACA400885849 /* WatchControlView.swift in Sources */,
|
1C5190D22A59ACA400885849 /* WatchControlView.swift in Sources */,
|
||||||
1C04948E2C25CD3D003D18BB /* BridgeModule+WorkoutActions.swift in Sources */,
|
|
||||||
1C5190D42A59AEDE00885849 /* MainWatchView.swift in Sources */,
|
1C5190D42A59AEDE00885849 /* MainWatchView.swift in Sources */,
|
||||||
1CF65AB42A4530200042FFBD /* WatchPackageModel.swift in Sources */,
|
|
||||||
1C1A3C742C3376150010CDD5 /* WatchWorkout.swift in Sources */,
|
1C1A3C742C3376150010CDD5 /* WatchWorkout.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -830,6 +376,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
INFOPLIST_FILE = "";
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
@@ -884,6 +431,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
INFOPLIST_FILE = "";
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
@@ -894,7 +442,6 @@
|
|||||||
1CF65A372A3972850042FFBD /* Debug */ = {
|
1CF65A372A3972850042FFBD /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
@@ -904,12 +451,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;
|
||||||
|
EXCLUDED_SOURCE_FILE_NAMES = "Werkout_ios/Resources/Werkout-ios-Info.plist";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Werkout-ios-Info.plist";
|
INFOPLIST_FILE = "Werkout-ios-Info.plist";
|
||||||
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_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;
|
||||||
@@ -921,7 +470,7 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.3;
|
MACOSX_DEPLOYMENT_TARGET = 13.3;
|
||||||
@@ -939,7 +488,6 @@
|
|||||||
1CF65A382A3972850042FFBD /* Release */ = {
|
1CF65A382A3972850042FFBD /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
@@ -949,12 +497,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;
|
||||||
|
EXCLUDED_SOURCE_FILE_NAMES = "Werkout_ios/Resources/Werkout-ios-Info.plist";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Werkout-ios-Info.plist";
|
INFOPLIST_FILE = "Werkout-ios-Info.plist";
|
||||||
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_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;
|
||||||
@@ -966,7 +516,7 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.3;
|
MACOSX_DEPLOYMENT_TARGET = 13.3;
|
||||||
@@ -992,12 +542,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 = (
|
||||||
@@ -1027,12 +578,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 = (
|
||||||
@@ -1084,18 +636,20 @@
|
|||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCVersionGroup section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
1CF65A292A3972840042FFBD /* Werkout_ios.xcdatamodeld */ = {
|
D00100012E00000100000003 /* XCLocalSwiftPackageReference "../SharedCore" */ = {
|
||||||
isa = XCVersionGroup;
|
isa = XCLocalSwiftPackageReference;
|
||||||
children = (
|
relativePath = ../SharedCore;
|
||||||
1CF65A2A2A3972840042FFBD /* Werkout_ios.xcdatamodel */,
|
|
||||||
);
|
|
||||||
currentVersion = 1CF65A2A2A3972840042FFBD /* Werkout_ios.xcdatamodel */;
|
|
||||||
path = Werkout_ios.xcdatamodeld;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
versionGroupType = wrapper.xcdatamodel;
|
|
||||||
};
|
};
|
||||||
/* End XCVersionGroup section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
D00100012E00000100000004 /* SharedCore */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = D00100012E00000100000003 /* XCLocalSwiftPackageReference "../SharedCore" */;
|
||||||
|
productName = SharedCore;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 1CF65A1A2A3972840042FFBD /* Project object */;
|
rootObject = 1CF65A1A2A3972840042FFBD /* Project object */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ struct PlannedWorkout: Codable {
|
|||||||
case onDate = "on_date"
|
case onDate = "on_date"
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct RegisteredUser: Codable, Hashable {
|
struct RegisteredUser: Codable, Hashable, Equatable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let firstName, lastName, image: String?
|
let firstName, lastName, image: String?
|
||||||
let nickName: String?
|
let nickName: String?
|
||||||
|
|||||||
@@ -31,6 +31,37 @@ struct Workout: Codable, Identifiable, Equatable {
|
|||||||
case estimatedTime = "estimated_time"
|
case estimatedTime = "estimated_time"
|
||||||
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,18 +77,68 @@ 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)
|
||||||
|
|
||||||
allSupersetExecercise = try container.decodeIfPresent([SupersetExercise].self, forKey: .allSupersetExecercise)
|
allSupersetExecercise = try container.decodeIfPresent([SupersetExercise].self, forKey: .allSupersetExecercise)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Array where Element == Workout {
|
||||||
|
func filterWorkouts(nameSearchString: String,
|
||||||
|
musclesSearchString: Set<String>,
|
||||||
|
equipmentSearchString: Set<String>,
|
||||||
|
filteredRegisterdUser: RegisteredUser?) -> [Workout] {
|
||||||
|
var matchingWorkouts = [Workout]()
|
||||||
|
|
||||||
|
if matchingWorkouts.isEmpty {
|
||||||
|
matchingWorkouts.append(contentsOf: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nameSearchString.isEmpty && nameSearchString.count > 0) {
|
||||||
|
matchingWorkouts = self.filter({
|
||||||
|
if $0.name.lowercased().contains(nameSearchString.lowercased()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!equipmentSearchString.isEmpty && equipmentSearchString.count > 0) {
|
||||||
|
matchingWorkouts = matchingWorkouts.filter({
|
||||||
|
if let equipment = $0.equipment?.joined(separator: "").lowercased() {
|
||||||
|
for word in equipmentSearchString {
|
||||||
|
if equipment.contains(word.lowercased()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!musclesSearchString.isEmpty && musclesSearchString.count > 0) {
|
||||||
|
matchingWorkouts = matchingWorkouts.filter({
|
||||||
|
if let muscles = $0.muscles?.joined(separator: "").lowercased() {
|
||||||
|
for word in musclesSearchString {
|
||||||
|
if muscles.contains(word.lowercased()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if let filteredRegisterdUser = filteredRegisterdUser {
|
||||||
|
matchingWorkouts = matchingWorkouts.filter({
|
||||||
|
$0.registeredUser == filteredRegisterdUser
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchingWorkouts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
"colors" : [
|
"colors" : [
|
||||||
{
|
{
|
||||||
"color" : {
|
"color" : {
|
||||||
"platform" : "universal",
|
"color-space" : "srgb",
|
||||||
"reference" : "systemPurpleColor"
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "1.000",
|
||||||
|
"green" : "0.350",
|
||||||
|
"red" : "0.750"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
},
|
},
|
||||||
@@ -15,8 +20,13 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"color" : {
|
"color" : {
|
||||||
"platform" : "universal",
|
"color-space" : "srgb",
|
||||||
"reference" : "systemPurpleColor"
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "1.000",
|
||||||
|
"green" : "0.350",
|
||||||
|
"red" : "0.750"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import Foundation
|
|||||||
|
|
||||||
enum BaseURLs: String {
|
enum BaseURLs: String {
|
||||||
case local = "http://127.0.0.1:8000"
|
case local = "http://127.0.0.1:8000"
|
||||||
case dev = "https://dev.werkout.fitness"
|
case dev = "https://werkout.treytartt.com"
|
||||||
|
|
||||||
static var currentBaseURL: String {
|
static var currentBaseURL: String {
|
||||||
return BaseURLs.dev.rawValue
|
return BaseURLs.dev.rawValue
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ import Foundation
|
|||||||
|
|
||||||
extension BridgeModule {
|
extension BridgeModule {
|
||||||
func startWorkoutTimer() {
|
func startWorkoutTimer() {
|
||||||
currentWorkoutRunTimer?.invalidate()
|
DispatchQueue.main.async {
|
||||||
currentWorkoutRunTimer = nil
|
self.currentWorkoutRunTimer?.invalidate()
|
||||||
currentWorkoutRunTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { timer in
|
self.currentWorkoutRunTimer = nil
|
||||||
self.currentWorkoutRunTimeInSeconds += 1
|
self.currentWorkoutRunTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self] _ in
|
||||||
self.sendCurrentExerciseToWatch()
|
guard let self else { return }
|
||||||
})
|
dispatchPrecondition(condition: .onQueue(.main))
|
||||||
currentWorkoutRunTimer?.fire()
|
self.currentWorkoutRunTimeInSeconds += 1
|
||||||
|
self.sendCurrentExerciseToWatch()
|
||||||
|
})
|
||||||
|
self.currentWorkoutRunTimer?.tolerance = 0.1
|
||||||
|
self.currentWorkoutRunTimer?.fire()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startExerciseTimerWith(duration: Int) {
|
func startExerciseTimerWith(duration: Int) {
|
||||||
@@ -28,6 +33,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,7 +42,7 @@ extension BridgeModule {
|
|||||||
if currentExerciseTimeLeft > 1 {
|
if currentExerciseTimeLeft > 1 {
|
||||||
currentExerciseTimeLeft -= 1
|
currentExerciseTimeLeft -= 1
|
||||||
|
|
||||||
if let currentExercise = currentExerciseInfo.allSupersetExecercise, let audioQueues = currentExercise.audioQueues {
|
if let currentExercise = currentWorkoutInfo.allSupersetExecercise, let audioQueues = currentExercise.audioQueues {
|
||||||
if let audioQueue = audioQueues.first(where: {
|
if let audioQueue = audioQueues.first(where: {
|
||||||
$0.playAt == currentExerciseTimeLeft
|
$0.playAt == currentExerciseTimeLeft
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -9,101 +9,153 @@ 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() {
|
||||||
|
watchMessageQueue.sync {
|
||||||
|
let queuedMessages = queuedWatchMessages.dequeueAll()
|
||||||
|
guard queuedMessages.isEmpty == false else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queuedMessages.forEach { send($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enqueueWatchMessage(_ data: Data) {
|
||||||
|
watchMessageQueue.sync {
|
||||||
|
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() {
|
||||||
if let currentExercise = currentExerciseInfo.currentExercise,
|
if let currentExercise = currentWorkoutInfo.currentExercise,
|
||||||
let duration = currentExercise.duration ,
|
let duration = currentExercise.duration ,
|
||||||
duration > 0 {
|
duration > 0 {
|
||||||
let watchModel = WatchPackageModel(currentExerciseName: currentExercise.exercise.name,
|
let watchModel = WatchPackageModel(currentExerciseName: currentExercise.exercise.name,
|
||||||
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 = currentExerciseInfo.currentExercise,
|
if let currentExercise = currentWorkoutInfo.currentExercise,
|
||||||
let reps = currentExercise.reps,
|
let reps = currentExercise.reps,
|
||||||
reps > 0 {
|
reps > 0 {
|
||||||
|
|
||||||
// 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 dataToAction(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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func session(_ session: WCSession, didReceiveMessageData messageData: Data) {
|
||||||
|
dataToAction(messageData: messageData)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -114,9 +166,31 @@ extension BridgeModule: WCSessionDelegate {
|
|||||||
func sessionDidDeactivate(_ session: WCSession) {
|
func sessionDidDeactivate(_ session: WCSession) {
|
||||||
session.activate()
|
session.activate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
|
||||||
|
if let messageData = applicationContext["package"] as? Data {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.dataToAction(messageData: messageData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
|
||||||
|
if let messageData = userInfo["package"] as? Data {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.dataToAction(messageData: messageData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
#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 +202,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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ extension BridgeModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func nextExercise() {
|
func nextExercise() {
|
||||||
if let nextSupersetExercise = currentExerciseInfo.goToNextExercise {
|
if let nextSupersetExercise = currentWorkoutInfo.goToNextExercise {
|
||||||
updateCurrent(exercise: nextSupersetExercise)
|
updateCurrent(exercise: nextSupersetExercise)
|
||||||
} else {
|
} else {
|
||||||
completeWorkout()
|
completeWorkout()
|
||||||
@@ -29,7 +29,7 @@ extension BridgeModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func previousExercise() {
|
func previousExercise() {
|
||||||
if let nextSupersetExercise = currentExerciseInfo.previousExercise {
|
if let nextSupersetExercise = currentWorkoutInfo.previousExercise {
|
||||||
updateCurrent(exercise: nextSupersetExercise)
|
updateCurrent(exercise: nextSupersetExercise)
|
||||||
} else {
|
} else {
|
||||||
completeWorkout()
|
completeWorkout()
|
||||||
@@ -37,7 +37,7 @@ extension BridgeModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func restartExercise() {
|
func restartExercise() {
|
||||||
if let currentExercise = currentExerciseInfo.currentExercise {
|
if let currentExercise = currentWorkoutInfo.currentExercise {
|
||||||
updateCurrent(exercise: currentExercise)
|
updateCurrent(exercise: currentExercise)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,17 +70,14 @@ extension BridgeModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func start(workout: Workout) {
|
func start(workout: Workout) {
|
||||||
currentExerciseInfo.complete = {
|
lastSentInExercisePayload = nil
|
||||||
self.completeWorkout()
|
currentWorkoutInfo.start(workout: workout)
|
||||||
}
|
|
||||||
|
|
||||||
currentExerciseInfo.start(workout: workout)
|
|
||||||
currentWorkoutRunTimeInSeconds = 0
|
currentWorkoutRunTimeInSeconds = 0
|
||||||
currentWorkoutRunTimer?.invalidate()
|
currentWorkoutRunTimer?.invalidate()
|
||||||
currentWorkoutRunTimer = nil
|
currentWorkoutRunTimer = nil
|
||||||
isPaused = false
|
isPaused = false
|
||||||
|
|
||||||
if let superetExercise = currentExerciseInfo.currentExercise {
|
if let superetExercise = currentWorkoutInfo.currentExercise {
|
||||||
updateCurrent(exercise: superetExercise)
|
updateCurrent(exercise: superetExercise)
|
||||||
startWorkoutTimer()
|
startWorkoutTimer()
|
||||||
workoutStartDate = Date()
|
workoutStartDate = Date()
|
||||||
@@ -91,22 +91,12 @@ extension BridgeModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func goToExerciseAt(section: Int, row: Int) {
|
|
||||||
if let superetExercise = currentExerciseInfo.goToWorkoutAt(supersetIndex: section,
|
|
||||||
exerciseIndex: row) {
|
|
||||||
updateCurrent(exercise: superetExercise)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var nextExerciseObject: SupersetExercise? {
|
var nextExerciseObject: SupersetExercise? {
|
||||||
currentExerciseInfo.goToNextExercise
|
currentWorkoutInfo.goToNextExercise
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -115,7 +105,8 @@ extension BridgeModule {
|
|||||||
self.currentExerciseTimer = nil
|
self.currentExerciseTimer = nil
|
||||||
|
|
||||||
self.currentWorkoutRunTimeInSeconds = -1
|
self.currentWorkoutRunTimeInSeconds = -1
|
||||||
self.currentExerciseInfo.reset()
|
self.currentWorkoutInfo.reset()
|
||||||
|
self.lastSentInExercisePayload = nil
|
||||||
|
|
||||||
self.isInWorkout = false
|
self.isInWorkout = false
|
||||||
self.workoutStartDate = nil
|
self.workoutStartDate = nil
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -41,7 +42,7 @@ class BridgeModule: NSObject, ObservableObject {
|
|||||||
|
|
||||||
public var currentExerciseTimer: Timer?
|
public var currentExerciseTimer: Timer?
|
||||||
|
|
||||||
@Published public private(set) var currentExerciseInfo = CurrentWorkoutInfo()
|
@Published public private(set) var currentWorkoutInfo = CurrentWorkoutInfo()
|
||||||
@Published var previewWorkout: Workout?
|
@Published var previewWorkout: Workout?
|
||||||
|
|
||||||
@Published var currentExerciseTimeLeft: Int = 0
|
@Published var currentExerciseTimeLeft: Int = 0
|
||||||
@@ -54,4 +55,7 @@ class BridgeModule: NSObject, ObservableObject {
|
|||||||
@Published var isPaused = false
|
@Published var isPaused = false
|
||||||
|
|
||||||
let session: WCSession = WCSession.default
|
let session: WCSession = WCSession.default
|
||||||
|
let watchMessageQueue = DispatchQueue(label: "com.werkout.watchMessageQueue")
|
||||||
|
var queuedWatchMessages = BoundedFIFOQueue<Data>(maxCount: 100)
|
||||||
|
var lastSentInExercisePayload: Data?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -140,7 +140,8 @@ class CurrentWorkoutInfo {
|
|||||||
self.workout = nil
|
self.workout = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func goToWorkoutAt(supersetIndex: Int, exerciseIndex: Int) -> SupersetExercise? {
|
@discardableResult
|
||||||
|
func goToExerciseAt(supersetIndex: Int, exerciseIndex: Int) -> SupersetExercise? {
|
||||||
self.supersetIndex = supersetIndex
|
self.supersetIndex = supersetIndex
|
||||||
self.exerciseIndex = exerciseIndex
|
self.exerciseIndex = exerciseIndex
|
||||||
self.currentRound = 1
|
self.currentRound = 1
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import Combine
|
||||||
|
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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) ?? ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
func getDetails(forHealthKitUUID uuid: UUID, completion: @escaping ((HealthKitWorkoutData?) -> Void)) {
|
||||||
caloriesBurned: nil,
|
runtimeReporter.recordInfo("Fetching HealthKit workout details", metadata: ["uuid": uuid.uuidString])
|
||||||
minHeartRate: nil,
|
|
||||||
maxHeartRate: nil,
|
|
||||||
avgHeartRate: nil)
|
|
||||||
|
|
||||||
var completion: ((HealthKitWorkoutData) -> Void)?
|
|
||||||
|
|
||||||
func getDetails(forHealthKitUUID uuid: UUID, completion: @escaping ((HealthKitWorkoutData) -> Void)) {
|
|
||||||
self.completion = completion
|
|
||||||
self.returnCount = 0
|
|
||||||
|
|
||||||
print("get details \(uuid.uuidString)")
|
|
||||||
|
|
||||||
let predicate = HKQuery.predicateForObject(with: uuid)
|
|
||||||
let query = HKSampleQuery(sampleType: HKWorkoutType.workoutType(),
|
let query = HKSampleQuery(sampleType: HKWorkoutType.workoutType(),
|
||||||
predicate: predicate,
|
predicate: HKQuery.predicateForObject(with: uuid),
|
||||||
limit: 0,
|
limit: 1,
|
||||||
sortDescriptors: nil)
|
sortDescriptors: nil)
|
||||||
{ (sampleQuery, results, error ) -> Void in
|
{ [weak self] (_, results, error) -> Void in
|
||||||
|
guard let self else {
|
||||||
if let queryError = error {
|
DispatchQueue.main.async {
|
||||||
self.shitReturned()
|
completion(nil)
|
||||||
self.shitReturned()
|
|
||||||
print( "There was an error while reading the samples: \(queryError.localizedDescription)")
|
|
||||||
} 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 calPredicate = HKQuery.predicateForSamples(withStart: workout.startDate,
|
||||||
let calQuery = HKStatisticsQuery(quantityType: calType!,
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ class WorkoutDetailFetchable: Fetchable {
|
|||||||
var endPoint: String
|
var endPoint: String
|
||||||
|
|
||||||
init(workoutID: Int) {
|
init(workoutID: Int) {
|
||||||
self.endPoint = "/workout/"+String(workoutID)+"/details/"
|
self.endPoint = "/workout/\(workoutID)/details/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class AllNSFWVideosFetchable: Fetchable {
|
|||||||
var endPoint: String = "/videos/nsfw_videos/"
|
var endPoint: String = "/videos/nsfw_videos/"
|
||||||
}
|
}
|
||||||
|
|
||||||
class RefreshUserInfoFetcable: Fetchable {
|
class RefreshUserInfoFetchable: Fetchable {
|
||||||
typealias Response = RegisteredUser
|
typealias Response = RegisteredUser
|
||||||
var endPoint: String = "/registered_user/refresh/"
|
var endPoint: String = "/registered_user/refresh/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -34,105 +59,175 @@ extension Fetchable {
|
|||||||
var baseURL: String {
|
var baseURL: String {
|
||||||
BaseURLs.currentBaseURL
|
BaseURLs.currentBaseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
||||||
var request = URLRequest(url: url,timeoutInterval: Double.infinity)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let data else {
|
||||||
|
runtimeReporter.recordError("GET request returned no data", metadata: ["url": url.absoluteString])
|
||||||
|
completeOnMain(completion, with: .failure(.noData))
|
||||||
|
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)))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
||||||
let postData = try! JSONSerialization.data(withJSONObject:postableData)
|
return
|
||||||
|
}
|
||||||
var request = URLRequest(url: url,timeoutInterval: Double.infinity)
|
|
||||||
if attachToken {
|
let postData: Data
|
||||||
guard let token = UserStore.shared.token else {
|
do {
|
||||||
completion(.failure(.noPostData))
|
postData = try JSONSerialization.data(withJSONObject: postableData)
|
||||||
return
|
} catch {
|
||||||
}
|
runtimeReporter.recordError(
|
||||||
request.addValue(token, forHTTPHeaderField: "Authorization")
|
"Failed encoding POST payload",
|
||||||
}
|
metadata: ["url": url.absoluteString, "error": error.localizedDescription]
|
||||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
)
|
||||||
|
completeOnMain(completion, with: .failure(.apiError(error)))
|
||||||
request.httpMethod = "POST"
|
return
|
||||||
request.httpBody = postData
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url, timeoutInterval: requestTimeout)
|
||||||
|
if attachToken {
|
||||||
|
guard let token = UserStore.shared.token else {
|
||||||
|
runtimeReporter.recordWarning("Missing auth token", metadata: ["method": "POST", "endpoint": endPoint])
|
||||||
|
completeOnMain(completion, with: .failure(.noToken))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.addValue(token, forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.httpBody = postData
|
||||||
|
|
||||||
let task = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
|
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 {
|
!(200...299).contains(httpResponse.statusCode) {
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let data = data else {
|
|
||||||
completion(.failure(.noData))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let data else {
|
||||||
|
runtimeReporter.recordError("POST request returned no data", metadata: ["url": url.absoluteString])
|
||||||
|
completeOnMain(completion, with: .failure(.noData))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
28
iphone/Werkout_ios/Resources/Werkout-ios-Info.plist
Normal file
28
iphone/Werkout_ios/Resources/Werkout-ios-Info.plist
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionDomains</key>
|
||||||
|
<dict>
|
||||||
|
<key>127.0.0.1</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>localhost</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -6,99 +6,198 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public func refreshUserData() {
|
public func refreshUserData() {
|
||||||
RefreshUserInfoFetcable().fetch(completion: { result in
|
RefreshUserInfoFetchable().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.userLoggedOut, object: nil, userInfo: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setFakeUser() {
|
func setFakeUser() {
|
||||||
self.registeredUser = PreviewData.parseRegisterdUser()
|
self.registeredUser = PreviewData.parseRegisterdUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchPlannedWorkouts() {
|
func fetchPlannedWorkouts() {
|
||||||
PlannedWorkoutFetchable().fetch(completion: { result in
|
PlannedWorkoutFetchable().fetch(completion: { result in
|
||||||
switch result {
|
switch result {
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func plannedWorkoutFor(date: Date) -> PlannedWorkout? {
|
func plannedWorkoutFor(date: Date) -> PlannedWorkout? {
|
||||||
for plannedWorkout in plannedWorkouts {
|
for plannedWorkout in plannedWorkouts {
|
||||||
if let plannedworkoutDate = plannedWorkout.date {
|
if let plannedworkoutDate = plannedWorkout.date {
|
||||||
@@ -109,8 +208,90 @@ class UserStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,32 +10,34 @@ import SwiftUI
|
|||||||
|
|
||||||
struct AccountView: View {
|
struct AccountView: View {
|
||||||
@ObservedObject var userStore = UserStore.shared
|
@ObservedObject var userStore = UserStore.shared
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
NameView()
|
NameView()
|
||||||
|
|
||||||
CompletedWorkoutsView()
|
CompletedWorkoutsView()
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
|
|
||||||
ThotPreferenceView()
|
ThotPreferenceView()
|
||||||
|
|
||||||
ShowNextUpView()
|
ShowNextUpView()
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Logoutview()
|
Logoutview()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
.background(WerkoutTheme.background)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//struct AccountView_Previews: PreviewProvider {
|
//struct AccountView_Previews: PreviewProvider {
|
||||||
// static let userStore = UserStore.shared
|
// static let userStore = UserStore.shared
|
||||||
// static let completedWorkouts = PreviewData.parseCompletedWorkouts()
|
// static let completedWorkouts = PreviewData.parseCompletedWorkouts()
|
||||||
//
|
//
|
||||||
// static var previews: some View {
|
// static var previews: some View {
|
||||||
// AccountView(completedWorkouts: completedWorkouts)
|
// AccountView(completedWorkouts: completedWorkouts)
|
||||||
// .onAppear{
|
// .onAppear{
|
||||||
|
|||||||
@@ -14,77 +14,63 @@ struct AddExerciseView: View {
|
|||||||
case muscles
|
case muscles
|
||||||
case equipment
|
case equipment
|
||||||
}
|
}
|
||||||
|
|
||||||
@State var selectedMuscles = [Muscle]()
|
@State var selectedMuscles = [Muscle]()
|
||||||
@State var selectedEquipment = [Equipment]()
|
@State var selectedEquipment = [Equipment]()
|
||||||
@State var filteredExercises = [Exercise]()
|
@State var filteredExercises = [Exercise]()
|
||||||
|
|
||||||
@StateObject var bridgeModule = BridgeModule.shared
|
@ObservedObject var bridgeModule = BridgeModule.shared
|
||||||
let selectedExercise: ((Exercise) -> Void)
|
let selectedExercise: ((Exercise) -> Void)
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(spacing: 0) {
|
||||||
AllExerciseView(filteredExercises: $filteredExercises,
|
AllExerciseView(filteredExercises: $filteredExercises,
|
||||||
selectedExercise: { excercise in
|
selectedExercise: { excercise in
|
||||||
selectedExercise(excercise)
|
selectedExercise(excercise)
|
||||||
})
|
})
|
||||||
.padding(.top)
|
.padding(.top, WerkoutTheme.md)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
AllMusclesView(selectedMuscles: $selectedMuscles)
|
AllMusclesView(selectedMuscles: $selectedMuscles)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
|
|
||||||
AllEquipmentView(selectedEquipment: $selectedEquipment)
|
AllEquipmentView(selectedEquipment: $selectedEquipment)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.padding(.top)
|
.padding(.top, WerkoutTheme.sm)
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
}
|
}
|
||||||
.onChange(of: selectedMuscles, perform: { _ in
|
.background(WerkoutTheme.background)
|
||||||
|
.onChange(of: selectedMuscles) { _, _ in
|
||||||
filterExercises()
|
filterExercises()
|
||||||
}) .onChange(of: selectedEquipment, perform: { _ in
|
}
|
||||||
|
.onChange(of: selectedEquipment) { _, _ in
|
||||||
filterExercises()
|
filterExercises()
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterExercises() {
|
func filterExercises() {
|
||||||
guard let exercises = DataStore.shared.allExercise else {
|
guard let exercises = DataStore.shared.allExercise else {
|
||||||
filteredExercises = []
|
filteredExercises = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let filtered = exercises.filter({ exercise in
|
let selectedMuscleIds = Set(selectedMuscles.map { $0.id })
|
||||||
var hasCorrectMuscles = false
|
let selectedEquipmentIds = Set(selectedEquipment.map { $0.id })
|
||||||
if selectedMuscles.count == 0 {
|
|
||||||
hasCorrectMuscles = true
|
filteredExercises = exercises.filter { exercise in
|
||||||
} else {
|
let muscleOK = selectedMuscleIds.isEmpty ||
|
||||||
let exerciseMuscleIds = exercise.muscles.map({ $0.muscle ?? -1 })
|
exercise.muscles.contains(where: { selectedMuscleIds.contains($0.muscle ?? -1) })
|
||||||
let selctedMuscleIds = selectedMuscles.map({ $0.id })
|
let equipmentOK = selectedEquipmentIds.isEmpty ||
|
||||||
// if one items match
|
exercise.equipment.contains(where: { selectedEquipmentIds.contains($0.equipment ?? -1) })
|
||||||
if exerciseMuscleIds.contains(where: selctedMuscleIds.contains) {
|
return muscleOK && equipmentOK
|
||||||
// if all items match
|
}
|
||||||
hasCorrectMuscles = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasCorrectEquipment = false
|
|
||||||
if selectedEquipment.count == 0 {
|
|
||||||
hasCorrectEquipment = true
|
|
||||||
} else {
|
|
||||||
let exerciseEquipmentIds = exercise.equipment.map({ $0.equipment ?? -1 })
|
|
||||||
let selctedEquipmentIds = selectedEquipment.map({ $0.id })
|
|
||||||
// if one items match
|
|
||||||
if exerciseEquipmentIds.contains(where: selctedEquipmentIds.contains) {
|
|
||||||
// if all items match
|
|
||||||
hasCorrectEquipment = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasCorrectMuscles && hasCorrectEquipment
|
|
||||||
})
|
|
||||||
|
|
||||||
filteredExercises = filtered
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,155 +7,94 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
enum SortType: String, CaseIterable {
|
||||||
|
case name = "Name"
|
||||||
|
case createdDate = "Created Date"
|
||||||
|
}
|
||||||
|
|
||||||
struct AllWorkoutsListView: View {
|
struct AllWorkoutsListView: View {
|
||||||
enum SortType: String, CaseIterable {
|
|
||||||
case name = "Name"
|
|
||||||
case date = "Date"
|
|
||||||
}
|
|
||||||
|
|
||||||
@State var searchString: String = ""
|
|
||||||
@Binding var uniqueWorkoutUsers: [RegisteredUser]?
|
|
||||||
@State private var filteredRegisterdUser: RegisteredUser?
|
|
||||||
|
|
||||||
let workouts: [Workout]
|
let workouts: [Workout]
|
||||||
|
@Binding var searchText: String
|
||||||
|
@Binding var selectedMuscles: Set<String>
|
||||||
|
@Binding var selectedEquipment: Set<String>
|
||||||
|
@Binding var filteredRegisterdUser: RegisteredUser?
|
||||||
|
@Binding var currentSort: SortType?
|
||||||
|
@Binding var sortAscending: Bool
|
||||||
|
|
||||||
let selectedWorkout: ((Workout) -> Void)
|
let selectedWorkout: ((Workout) -> Void)
|
||||||
@State var filteredWorkouts = [Workout]()
|
|
||||||
var refresh: (() -> Void)
|
var refresh: (() -> Void)
|
||||||
@State var currentSort: SortType?
|
|
||||||
|
@State private var filteredWorkouts = [Workout]()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
ScrollView {
|
||||||
if let filteredRegisterdUser = filteredRegisterdUser {
|
LazyVStack(spacing: WerkoutTheme.sm) {
|
||||||
Text((filteredRegisterdUser.firstName ?? "NA") + "'s Workouts")
|
ForEach(filteredWorkouts, id: \.id) { workout in
|
||||||
}
|
Button(action: {
|
||||||
|
selectedWorkout(workout)
|
||||||
ScrollView {
|
|
||||||
LazyVStack(spacing: 20) {
|
|
||||||
ForEach(filteredWorkouts, id:\.id) { workout in
|
|
||||||
WorkoutOverviewView(workout: workout)
|
|
||||||
.padding([.leading, .trailing])
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
selectedWorkout(workout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.refreshable {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
TextField("Filter" ,text: $searchString)
|
|
||||||
.padding()
|
|
||||||
.textFieldStyle(OvalTextFieldStyle())
|
|
||||||
|
|
||||||
if let uniqueWorkoutUsers = uniqueWorkoutUsers {
|
|
||||||
Menu(content: {
|
|
||||||
ForEach(uniqueWorkoutUsers, id: \.self) { index in
|
|
||||||
Button(action: {
|
|
||||||
filteredRegisterdUser = index
|
|
||||||
filteredWorkouts = filterWorkouts()
|
|
||||||
}, label: {
|
|
||||||
Text((index.firstName ?? "") + " -" + (index.lastName ?? ""))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
filteredRegisterdUser = nil
|
|
||||||
filteredWorkouts = filterWorkouts()
|
|
||||||
}, label: {
|
|
||||||
Text("All")
|
|
||||||
})
|
|
||||||
|
|
||||||
}, label: {
|
}, label: {
|
||||||
Image(systemName: filteredRegisterdUser == nil ? "person.2" : "person.2.fill")
|
WorkoutOverviewView(workout: workout)
|
||||||
.padding(.trailing)
|
.padding(.horizontal, WerkoutTheme.sm)
|
||||||
|
.contentShape(Rectangle())
|
||||||
})
|
})
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Open \(workout.name)")
|
||||||
|
.accessibilityHint("Shows workout details")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Menu(content: {
|
.padding(.top, WerkoutTheme.sm)
|
||||||
ForEach(SortType.allCases, id: \.self) { index in
|
}
|
||||||
Button(action: {
|
.scrollEdgeEffectStyle(.soft, for: .top)
|
||||||
sortWorkouts(sortType: index)
|
.background(WerkoutTheme.background)
|
||||||
}, label: {
|
.refreshable {
|
||||||
Text(index.rawValue)
|
refresh()
|
||||||
})
|
}
|
||||||
}
|
.onAppear {
|
||||||
}, label: {
|
applyFilters()
|
||||||
Image(systemName: "list.number")
|
}
|
||||||
.padding(.trailing)
|
.onChange(of: searchText) { applyFilters() }
|
||||||
})
|
.onChange(of: selectedMuscles) { applyFilters() }
|
||||||
|
.onChange(of: selectedEquipment) { applyFilters() }
|
||||||
|
.onChange(of: filteredRegisterdUser) { applyFilters() }
|
||||||
|
.onChange(of: currentSort) { applyFilters() }
|
||||||
|
.onChange(of: sortAscending) { applyFilters() }
|
||||||
|
.onChange(of: workouts) { applyFilters() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyFilters() {
|
||||||
|
var results = workouts.filterWorkouts(
|
||||||
|
nameSearchString: searchText,
|
||||||
|
musclesSearchString: selectedMuscles,
|
||||||
|
equipmentSearchString: selectedEquipment,
|
||||||
|
filteredRegisterdUser: filteredRegisterdUser
|
||||||
|
)
|
||||||
|
|
||||||
|
if let sort = currentSort {
|
||||||
|
switch sort {
|
||||||
|
case .name:
|
||||||
|
results.sort { $0.name < $1.name }
|
||||||
|
case .createdDate:
|
||||||
|
results.sort { ($0.createdAt ?? Date()) < ($1.createdAt ?? Date()) }
|
||||||
|
}
|
||||||
|
if !sortAscending {
|
||||||
|
results.reverse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: searchString) { newValue in
|
|
||||||
filteredWorkouts = filterWorkouts()
|
|
||||||
}
|
|
||||||
.onAppear{
|
|
||||||
filteredWorkouts = filterWorkouts()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortWorkouts(sortType: SortType) {
|
|
||||||
if currentSort == sortType {
|
|
||||||
filteredWorkouts = filteredWorkouts.reversed()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch sortType {
|
|
||||||
case .name:
|
|
||||||
filteredWorkouts = filteredWorkouts.sorted(by: {
|
|
||||||
$0.name < $1.name
|
|
||||||
})
|
|
||||||
case .date:
|
|
||||||
filteredWorkouts = filteredWorkouts.sorted(by: {
|
|
||||||
$0.createdAt ?? Date() < $1.createdAt ?? Date()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
currentSort = sortType
|
|
||||||
}
|
|
||||||
|
|
||||||
func filterWorkouts() -> [Workout] {
|
|
||||||
var matchingWorkouts = [Workout]()
|
|
||||||
|
|
||||||
if (!searchString.isEmpty && searchString.count > 0) {
|
|
||||||
matchingWorkouts = workouts.filter({
|
|
||||||
if $0.name.lowercased().contains(searchString.lowercased()) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if let equipment = $0.equipment?.joined(separator: "").lowercased(),
|
|
||||||
equipment.contains(searchString.lowercased()) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if let muscles = $0.muscles?.joined(separator: "").lowercased(),
|
|
||||||
muscles.contains(searchString.lowercased()) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
filteredWorkouts = results
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if matchingWorkouts.isEmpty {
|
|
||||||
matchingWorkouts.append(contentsOf: workouts)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let filteredRegisterdUser = filteredRegisterdUser {
|
|
||||||
matchingWorkouts = matchingWorkouts.filter({
|
|
||||||
$0.registeredUser == filteredRegisterdUser
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchingWorkouts
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AllWorkoutsListView_Previews: PreviewProvider {
|
struct AllWorkoutsListView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
AllWorkoutsListView(uniqueWorkoutUsers: .constant([]),
|
AllWorkoutsListView(workouts: PreviewData.allWorkouts(),
|
||||||
workouts: PreviewData.allWorkouts(),
|
searchText: .constant(""),
|
||||||
selectedWorkout: { workout in },
|
selectedMuscles: .constant([]),
|
||||||
refresh: { })
|
selectedEquipment: .constant([]),
|
||||||
|
filteredRegisterdUser: .constant(nil),
|
||||||
|
currentSort: .constant(nil),
|
||||||
|
sortAscending: .constant(true),
|
||||||
|
selectedWorkout: { _ in },
|
||||||
|
refresh: {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,93 +8,152 @@
|
|||||||
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
|
||||||
case MyWorkouts
|
case MyWorkouts
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
||||||
case .AllWorkout:
|
case .AllWorkout:
|
||||||
return "All Workouts"
|
return "All Workouts"
|
||||||
case .MyWorkouts:
|
case .MyWorkouts:
|
||||||
return "Planned Workouts"
|
return "Planned"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AllWorkoutsView: View {
|
struct AllWorkoutsView: View {
|
||||||
@State var isUpdating = false
|
@State private var isUpdating = false
|
||||||
@State var workouts: [Workout]?
|
|
||||||
@State var uniqueWorkoutUsers: [RegisteredUser]?
|
private var workouts: [Workout]? {
|
||||||
|
dataStore.allWorkouts?.sorted(by: { $0.createdAt ?? Date() < $1.createdAt ?? Date() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private var uniqueWorkoutUsers: [RegisteredUser]? {
|
||||||
|
dataStore.workoutsUniqueUsers
|
||||||
|
}
|
||||||
|
|
||||||
let healthStore = HKHealthStore()
|
let healthStore = HKHealthStore()
|
||||||
var bridgeModule = BridgeModule.shared
|
var bridgeModule = BridgeModule.shared
|
||||||
@State public var needsUpdating: Bool = true
|
@State private 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? {
|
||||||
didSet {
|
didSet {
|
||||||
bridgeModule.currentExerciseInfo.workout = selectedWorkout
|
bridgeModule.currentWorkoutInfo.workout = selectedWorkout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@State private var selectedPlannedWorkout: Workout? {
|
@State private var selectedPlannedWorkout: Workout? {
|
||||||
didSet {
|
didSet {
|
||||||
bridgeModule.currentExerciseInfo.workout = selectedPlannedWorkout
|
bridgeModule.currentWorkoutInfo.workout = selectedPlannedWorkout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 private var selectedDate: Date = Date()
|
||||||
|
private let runtimeReporter = RuntimeReporter.shared
|
||||||
let pub = NotificationCenter.default.publisher(for: NSNotification.Name("CreatedNewWorkout"))
|
|
||||||
|
// MARK: - Hoisted filter state
|
||||||
|
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var selectedMuscles: Set<String> = []
|
||||||
|
@State private var selectedEquipment: Set<String> = []
|
||||||
|
@State private var filteredRegisterdUser: RegisteredUser?
|
||||||
|
@State private var currentSort: SortType?
|
||||||
|
@State private var sortAscending: Bool = true
|
||||||
|
|
||||||
|
let pub = NotificationCenter.default.publisher(for: AppNotifications.createdNewWorkout)
|
||||||
|
|
||||||
|
private var hasActiveFilters: Bool {
|
||||||
|
!selectedMuscles.isEmpty || !selectedEquipment.isEmpty || filteredRegisterdUser != nil
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
NavigationStack {
|
||||||
if let workouts = workouts {
|
ZStack {
|
||||||
VStack {
|
WerkoutTheme.background.ignoresSafeArea()
|
||||||
AllWorkoutPickerView(mainViews: MainViewTypes.allCases,
|
|
||||||
selectedSegment: $selectedSegment,
|
if let workouts = workouts {
|
||||||
showCurrentWorkout: {
|
VStack(spacing: 0) {
|
||||||
selectedWorkout = bridgeModule.currentExerciseInfo.workout
|
// Active filter chips
|
||||||
})
|
if hasActiveFilters {
|
||||||
|
filterChipsRow
|
||||||
|
|
||||||
switch selectedSegment {
|
|
||||||
case .AllWorkout:
|
|
||||||
if isUpdating {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(.circular)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AllWorkoutsListView(uniqueWorkoutUsers: $uniqueWorkoutUsers,
|
switch selectedSegment {
|
||||||
workouts: workouts,
|
case .AllWorkout:
|
||||||
selectedWorkout: { workout in
|
if isUpdating {
|
||||||
selectedWorkout = workout
|
ProgressView()
|
||||||
}, refresh: {
|
.progressViewStyle(.circular)
|
||||||
self.needsUpdating = true
|
.tint(WerkoutTheme.accent)
|
||||||
maybeUpdateShit()
|
}
|
||||||
})
|
|
||||||
Divider()
|
AllWorkoutsListView(workouts: workouts,
|
||||||
case .MyWorkouts:
|
searchText: $searchText,
|
||||||
PlannedWorkoutView(workouts: UserStore.shared.plannedWorkouts,
|
selectedMuscles: $selectedMuscles,
|
||||||
selectedPlannedWorkout: $selectedPlannedWorkout)
|
selectedEquipment: $selectedEquipment,
|
||||||
|
filteredRegisterdUser: $filteredRegisterdUser,
|
||||||
|
currentSort: $currentSort,
|
||||||
|
sortAscending: $sortAscending,
|
||||||
|
selectedWorkout: { workout in
|
||||||
|
selectedWorkout = workout
|
||||||
|
}, refresh: {
|
||||||
|
self.needsUpdating = true
|
||||||
|
maybeRefreshData()
|
||||||
|
})
|
||||||
|
case .MyWorkouts:
|
||||||
|
PlannedWorkoutView(workouts: userStore.plannedWorkouts,
|
||||||
|
selectedPlannedWorkout: $selectedPlannedWorkout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Workouts")
|
||||||
|
.searchable(text: $searchText, prompt: "Search workouts")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
Picker("View", selection: $selectedSegment) {
|
||||||
|
ForEach(MainViewTypes.allCases, id: \.self) { viewType in
|
||||||
|
Text(viewType.title).tag(viewType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
HStack(spacing: WerkoutTheme.sm) {
|
||||||
|
if bridgeModule.isInWorkout {
|
||||||
|
Button(action: {
|
||||||
|
selectedWorkout = bridgeModule.currentWorkoutInfo.workout
|
||||||
|
}) {
|
||||||
|
Image(systemName: "figure.strengthtraining.traditional")
|
||||||
|
}
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
filterMenu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
ProgressView("Updating")
|
|
||||||
}
|
}
|
||||||
}.onAppear{
|
}
|
||||||
// UserStore.shared.logout()
|
.onAppear {
|
||||||
authorizeHealthKit()
|
authorizeHealthKit()
|
||||||
maybeUpdateShit()
|
maybeRefreshData()
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedWorkout) { item in
|
.sheet(item: $selectedWorkout) { item in
|
||||||
let isPreview = item.id == bridgeModule.currentExerciseInfo.workout?.id
|
let isPreview = item.id == bridgeModule.currentWorkoutInfo.workout?.id
|
||||||
let viewModel = WorkoutDetailViewModel(workout: item, isPreview: isPreview)
|
let viewModel = WorkoutDetailViewModel(workout: item, isPreview: isPreview)
|
||||||
WorkoutDetailView(viewModel: viewModel)
|
WorkoutDetailView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
@@ -105,55 +164,205 @@ 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) { _ in
|
||||||
self.needsUpdating = true
|
self.needsUpdating = true
|
||||||
maybeUpdateShit()
|
maybeRefreshData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func maybeUpdateShit() {
|
// MARK: - Filter Menu
|
||||||
if UserStore.shared.token != nil{
|
|
||||||
if UserStore.shared.plannedWorkouts.isEmpty {
|
private var filterMenu: some View {
|
||||||
UserStore.shared.fetchPlannedWorkouts()
|
Menu {
|
||||||
|
// Sort section
|
||||||
|
Section("Sort") {
|
||||||
|
ForEach(SortType.allCases, id: \.self) { sortType in
|
||||||
|
Button(action: {
|
||||||
|
if currentSort == sortType {
|
||||||
|
sortAscending.toggle()
|
||||||
|
} else {
|
||||||
|
currentSort = sortType
|
||||||
|
sortAscending = true
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Label {
|
||||||
|
Text(sortType.rawValue)
|
||||||
|
} icon: {
|
||||||
|
if currentSort == sortType {
|
||||||
|
Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Creator filter
|
||||||
|
if let users = uniqueWorkoutUsers, !users.isEmpty {
|
||||||
|
Section("Creator") {
|
||||||
|
ForEach(users, id: \.self) { user in
|
||||||
|
Button(action: {
|
||||||
|
filteredRegisterdUser = user
|
||||||
|
}) {
|
||||||
|
Label {
|
||||||
|
Text("\(user.firstName ?? "") \(user.lastName ?? "")")
|
||||||
|
} icon: {
|
||||||
|
if filteredRegisterdUser == user {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("All Creators") {
|
||||||
|
filteredRegisterdUser = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Muscles filter
|
||||||
|
if let muscles = DataStore.shared.allMuscles {
|
||||||
|
Section("Muscles") {
|
||||||
|
ForEach(muscles, id: \.id) { muscle in
|
||||||
|
Button(action: {
|
||||||
|
if selectedMuscles.contains(muscle.name) {
|
||||||
|
selectedMuscles.remove(muscle.name)
|
||||||
|
} else {
|
||||||
|
selectedMuscles.insert(muscle.name)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Label {
|
||||||
|
Text(muscle.name)
|
||||||
|
} icon: {
|
||||||
|
if selectedMuscles.contains(muscle.name) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equipment filter
|
||||||
|
if let equipments = DataStore.shared.allEquipment {
|
||||||
|
Section("Equipment") {
|
||||||
|
ForEach(equipments, id: \.id) { equipment in
|
||||||
|
Button(action: {
|
||||||
|
if selectedEquipment.contains(equipment.name) {
|
||||||
|
selectedEquipment.remove(equipment.name)
|
||||||
|
} else {
|
||||||
|
selectedEquipment.insert(equipment.name)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Label {
|
||||||
|
Text(equipment.name)
|
||||||
|
} icon: {
|
||||||
|
if selectedEquipment.contains(equipment.name) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all
|
||||||
|
if hasActiveFilters || currentSort != nil {
|
||||||
|
Divider()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
clearAllFilters()
|
||||||
|
} label: {
|
||||||
|
Label("Clear All Filters", systemImage: "xmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: hasActiveFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
||||||
|
}
|
||||||
|
.tint(hasActiveFilters ? WerkoutTheme.accent : nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Filter Chips Row
|
||||||
|
|
||||||
|
private var filterChipsRow: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: WerkoutTheme.sm) {
|
||||||
|
ForEach(Array(selectedMuscles), id: \.self) { muscle in
|
||||||
|
FilterChip(label: muscle, color: WerkoutTheme.accent) {
|
||||||
|
selectedMuscles.remove(muscle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(Array(selectedEquipment), id: \.self) { equipment in
|
||||||
|
FilterChip(label: equipment, color: WerkoutTheme.textSecondary) {
|
||||||
|
selectedEquipment.remove(equipment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let user = filteredRegisterdUser {
|
||||||
|
FilterChip(label: user.firstName ?? "User", color: WerkoutTheme.accentNeon) {
|
||||||
|
filteredRegisterdUser = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, WerkoutTheme.md)
|
||||||
|
.padding(.vertical, WerkoutTheme.xs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func clearAllFilters() {
|
||||||
|
selectedMuscles.removeAll()
|
||||||
|
selectedEquipment.removeAll()
|
||||||
|
filteredRegisterdUser = nil
|
||||||
|
currentSort = nil
|
||||||
|
sortAscending = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func maybeRefreshData() {
|
||||||
|
if userStore.token != nil {
|
||||||
|
if userStore.plannedWorkouts.isEmpty {
|
||||||
|
userStore.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 allWorkouts = dataStore.allWorkouts else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.workouts = allWorkouts.sorted(by: {
|
|
||||||
$0.createdAt ?? Date() < $1.createdAt ?? Date()
|
|
||||||
})
|
|
||||||
self.isUpdating = false
|
self.isUpdating = false
|
||||||
self.uniqueWorkoutUsers = dataStore.workoutsUniqueUsers
|
self.needsUpdating = false
|
||||||
}
|
}
|
||||||
|
|
||||||
self.isUpdating = false
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} 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)
|
||||||
HKQuantityType.workoutType()
|
].compactMap { $0 }
|
||||||
]
|
|
||||||
|
let healthKitTypes: Set<HKObjectType> = Set(
|
||||||
healthStore.requestAuthorization(toShare: healthKitTypes, read: healthKitTypes) { (succ, error) in
|
quantityTypes + [
|
||||||
if !succ {
|
HKObjectType.activitySummaryType(),
|
||||||
fatalError("Error requesting authorization from health store: \(String(describing: error)))")
|
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"]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,6 +370,6 @@ struct AllWorkoutsView: View {
|
|||||||
|
|
||||||
struct AllWorkoutsView_Previews: PreviewProvider {
|
struct AllWorkoutsView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
AllWorkoutsView(workouts: PreviewData.allWorkouts())
|
AllWorkoutsView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,19 @@
|
|||||||
|
|
||||||
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
|
||||||
@State var healthKitWorkoutData: HealthKitWorkoutData?
|
@State private var healthKitWorkoutData: HealthKitWorkoutData?
|
||||||
@State var difficulty: Float = 0
|
@State private var difficulty: Float = 0
|
||||||
@State var notes: String = ""
|
@State private var notes: String = ""
|
||||||
@State var isUploading: Bool = false
|
@State private var isUploading: Bool = false
|
||||||
@State var gettingHealthKitData: Bool = false
|
@State private 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()
|
||||||
let workout: Workout
|
let workout: Workout
|
||||||
@@ -25,70 +29,84 @@ struct CompletedWorkoutView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
WerkoutTheme.background
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
if isUploading {
|
if isUploading {
|
||||||
ProgressView("Uploading")
|
ProgressView("Uploading")
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: WerkoutTheme.accent))
|
||||||
}
|
}
|
||||||
VStack {
|
VStack {
|
||||||
WorkoutInfoView(workout: workout)
|
WorkoutInfoView(workout: workout)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if let calsBurned = healthKitWorkoutData?.caloriesBurned {
|
if let calsBurned = healthKitWorkoutData?.caloriesBurned {
|
||||||
CaloriesBurnedView(healthKitWorkoutData: $healthKitWorkoutData,
|
CaloriesBurnedView(healthKitWorkoutData: $healthKitWorkoutData,
|
||||||
calsBurned: calsBurned)
|
calsBurned: calsBurned)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RateWorkoutView(difficulty: $difficulty)
|
RateWorkoutView(difficulty: $difficulty)
|
||||||
.frame(maxHeight: 88)
|
.frame(maxHeight: 88)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
|
|
||||||
TextField("Notes", text: $notes)
|
TextField("Notes", text: $notes)
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
.frame(height: 55)
|
.frame(height: 55)
|
||||||
.textFieldStyle(PlainTextFieldStyle())
|
.textFieldStyle(PlainTextFieldStyle())
|
||||||
.padding([.horizontal], 4)
|
.padding([.horizontal], 4)
|
||||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color(uiColor: .clear))).background(Color(uiColor: .init(red: 200/255, green: 200/255, blue: 200/255, alpha: 0.2)))
|
.background(WerkoutTheme.surfaceElevated)
|
||||||
.cornerRadius(8)
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous)
|
||||||
|
.strokeBorder(WerkoutTheme.divider, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
|
|
||||||
if gettingHealthKitData {
|
if gettingHealthKitData {
|
||||||
ProgressView("Getting HealthKit data")
|
ProgressView("Getting HealthKit data")
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: WerkoutTheme.accent))
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button("Upload", action: {
|
Button("Upload", action: {
|
||||||
isUploading = true
|
|
||||||
upload(postBody: postData)
|
upload(postBody: postData)
|
||||||
})
|
})
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
.foregroundColor(.blue)
|
.glassEffect(.regular.interactive())
|
||||||
.background(.yellow)
|
.tint(WerkoutTheme.success)
|
||||||
.cornerRadius(8)
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
.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
|
|
||||||
if let healthKitUUID = healthKitUUID {
|
|
||||||
gettingHealthKitData = true
|
|
||||||
healthKitHelper.getDetails(forHealthKitUUID: healthKitUUID,
|
|
||||||
completion: { healthKitWorkoutData in
|
|
||||||
self.healthKitWorkoutData = healthKitWorkoutData
|
|
||||||
gettingHealthKitData = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -100,6 +118,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)
|
||||||
@@ -107,8 +126,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]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -123,9 +147,9 @@ struct CompletedWorkoutView_Previews: PreviewProvider {
|
|||||||
"total_calories": Float(120.0),
|
"total_calories": Float(120.0),
|
||||||
"heart_rates": [65,65,4,54,232,12]
|
"heart_rates": [65,65,4,54,232,12]
|
||||||
] as [String : Any]
|
] as [String : Any]
|
||||||
|
|
||||||
static let workout = PreviewData.workout()
|
static let workout = PreviewData.workout()
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
CompletedWorkoutView(postData: CompletedWorkoutView_Previews.postBody,
|
CompletedWorkoutView(postData: CompletedWorkoutView_Previews.postBody,
|
||||||
workout: workout,
|
workout: workout,
|
||||||
|
|||||||
@@ -6,101 +6,146 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
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: BaseURLs.currentBaseURL + "/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null"))
|
||||||
|
@State private var currentVideoURL: URL?
|
||||||
|
@State var videoExercise: Exercise? {
|
||||||
|
didSet {
|
||||||
|
if let viddd = self.videoExercise?.videoURL,
|
||||||
|
let url = URL(string: BaseURLs.currentBaseURL + viddd) {
|
||||||
|
updatePlayer(for: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(spacing: WerkoutTheme.sm) {
|
||||||
HStack {
|
VStack(spacing: WerkoutTheme.xs) {
|
||||||
VStack {
|
HStack {
|
||||||
VStack {
|
Text("Reps: ")
|
||||||
Text("Reps: ")
|
.font(WerkoutTheme.bodyText)
|
||||||
Text("\(workoutExercise.reps)")
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
.foregroundColor(workoutExercise.reps == 0 && workoutExercise.duration == 0 ? .red : Color(uiColor: .label))
|
Text("\(workoutExercise.reps)")
|
||||||
.bold()
|
.foregroundColor(workoutExercise.reps == 0 && workoutExercise.duration == 0 ? WerkoutTheme.danger : WerkoutTheme.textPrimary)
|
||||||
}
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.bold()
|
||||||
|
|
||||||
Stepper("", onIncrement: {
|
Stepper("", onIncrement: {
|
||||||
workoutExercise.increaseReps()
|
workoutExercise.increaseReps()
|
||||||
}, onDecrement: {
|
}, onDecrement: {
|
||||||
workoutExercise.decreaseReps()
|
workoutExercise.decreaseReps()
|
||||||
})
|
})
|
||||||
.labelsHidden()
|
.tint(WerkoutTheme.accent)
|
||||||
}
|
.accessibilityLabel("Reps")
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
VStack{
|
|
||||||
VStack {
|
|
||||||
Text("Weight: ")
|
|
||||||
Text("\(workoutExercise.weight)")
|
|
||||||
}
|
|
||||||
Stepper("", onIncrement: {
|
|
||||||
workoutExercise.increaseWeight()
|
|
||||||
}, onDecrement: {
|
|
||||||
workoutExercise.decreaseWeight()
|
|
||||||
})
|
|
||||||
.labelsHidden()
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
VStack{
|
|
||||||
VStack {
|
|
||||||
Text("Duration: ")
|
|
||||||
Text("\(workoutExercise.duration)")
|
|
||||||
.foregroundColor(workoutExercise.reps == 0 && workoutExercise.duration == 0 ? .red : Color(uiColor: .label))
|
|
||||||
.bold()
|
|
||||||
}
|
|
||||||
Stepper("", onIncrement: {
|
|
||||||
workoutExercise.increaseDuration()
|
|
||||||
}, onDecrement: {
|
|
||||||
workoutExercise.decreaseDuration()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Text("Weight: ")
|
||||||
Button(action: {
|
.font(WerkoutTheme.bodyText)
|
||||||
}) {
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
Image(systemName: "video.fill")
|
Text("\(workoutExercise.weight)")
|
||||||
}
|
.font(WerkoutTheme.bodyText)
|
||||||
.frame(width: 88, height: 44)
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
.foregroundColor(.white)
|
|
||||||
.background(.blue)
|
Stepper("", onIncrement: {
|
||||||
.cornerRadius(10)
|
workoutExercise.increaseWeight()
|
||||||
.buttonStyle(BorderlessButtonStyle())
|
}, onDecrement: {
|
||||||
|
workoutExercise.decreaseWeight()
|
||||||
Spacer()
|
})
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
Divider()
|
.accessibilityLabel("Weight")
|
||||||
|
}
|
||||||
Spacer()
|
|
||||||
|
HStack {
|
||||||
Button(action: {
|
Text("Duration: ")
|
||||||
superset.deleteExerciseForChosenSuperset(exercise: workoutExercise)
|
.font(WerkoutTheme.bodyText)
|
||||||
viewModel.increaseRandomNumberForUpdating()
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
viewModel.objectWillChange.send()
|
Text("\(workoutExercise.duration)")
|
||||||
}) {
|
.foregroundColor(
|
||||||
Image(systemName: "trash.fill")
|
workoutExercise.reps == 0 && workoutExercise.duration == 0 ? WerkoutTheme.danger : WerkoutTheme.textPrimary
|
||||||
}
|
)
|
||||||
.frame(width: 88, height: 44)
|
.font(WerkoutTheme.bodyText)
|
||||||
.foregroundColor(.white)
|
.bold()
|
||||||
.background(.red)
|
|
||||||
.cornerRadius(10)
|
Stepper("", onIncrement: {
|
||||||
.buttonStyle(BorderlessButtonStyle())
|
workoutExercise.increaseDuration()
|
||||||
|
}, onDecrement: {
|
||||||
Spacer()
|
workoutExercise.decreaseDuration()
|
||||||
|
})
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
.accessibilityLabel("Duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
GlassEffectContainer {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
videoExercise = workoutExercise.exercise
|
||||||
|
}) {
|
||||||
|
Image(systemName: "video.fill")
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
}
|
||||||
|
.frame(width: 88, height: 44)
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
.buttonStyle(BorderlessButtonStyle())
|
||||||
|
.accessibilityLabel("Preview exercise video")
|
||||||
|
.accessibilityHint("Opens a video preview for this exercise")
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
superset
|
||||||
|
.deleteExerciseForChosenSuperset(exercise: workoutExercise)
|
||||||
|
viewModel.increaseRandomNumberForUpdating()
|
||||||
|
}) {
|
||||||
|
Image(systemName: "trash.fill")
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
}
|
||||||
|
.frame(width: 88, height: 44)
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.danger)
|
||||||
|
.buttonStyle(BorderlessButtonStyle())
|
||||||
|
.accessibilityLabel("Delete exercise")
|
||||||
|
.accessibilityHint("Removes this exercise from the superset")
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
|
||||||
.background(.blue)
|
|
||||||
}
|
}
|
||||||
|
.sheet(item: $videoExercise) { exercise in
|
||||||
|
PlayerView(player: $avPlayer)
|
||||||
|
.onAppear{
|
||||||
|
avPlayer.isMuted = true
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
// Created by Trey Tartt on 6/18/23.
|
// Created by Trey Tartt on 6/18/23.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import SharedCore
|
||||||
|
|
||||||
class CreateWorkoutExercise: ObservableObject, Identifiable {
|
class CreateWorkoutExercise: ObservableObject, Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
@@ -49,7 +51,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
|
||||||
}
|
}
|
||||||
@@ -62,6 +64,7 @@ class CreateWorkoutSuperSet: ObservableObject, Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
|
@Published var title: String = ""
|
||||||
@Published var exercises = [CreateWorkoutExercise]()
|
@Published var exercises = [CreateWorkoutExercise]()
|
||||||
@Published var numberOfRounds = 0
|
@Published var numberOfRounds = 0
|
||||||
|
|
||||||
@@ -89,6 +92,11 @@ 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
|
||||||
|
// MARK: - Manual Invalidation
|
||||||
|
// Workaround: nested ObservableObject changes don't propagate to parent.
|
||||||
|
// Remove when migrating to @Observable (iOS 17+).
|
||||||
@Published var randomValueForUpdatingValue = 0
|
@Published var randomValueForUpdatingValue = 0
|
||||||
|
|
||||||
func increaseRandomNumberForUpdating() {
|
func increaseRandomNumberForUpdating() {
|
||||||
@@ -108,62 +116,109 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
increaseRandomNumberForUpdating()
|
increaseRandomNumberForUpdating()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addExercise(_ exercise: Exercise, to superset: CreateWorkoutSuperSet) {
|
||||||
|
let workoutExercise = CreateWorkoutExercise(exercise: exercise)
|
||||||
|
superset.exercises.append(workoutExercise)
|
||||||
|
|
||||||
|
if exercise.side?.isEmpty == false {
|
||||||
|
autoAddSiblingExercises(for: exercise, to: superset)
|
||||||
|
}
|
||||||
|
increaseRandomNumberForUpdating()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func autoAddSiblingExercises(for exercise: Exercise, to superset: CreateWorkoutSuperSet) {
|
||||||
|
guard let allExercises = DataStore.shared.allExercise else { return }
|
||||||
|
let siblings = allExercises.filter { $0.name == exercise.name }
|
||||||
|
guard siblings.count == 2,
|
||||||
|
let recover = allExercises.first(where: { $0.name.lowercased() == "recover" }) else { return }
|
||||||
|
|
||||||
|
let recoverExercise = CreateWorkoutExercise(exercise: recover)
|
||||||
|
superset.exercises.append(recoverExercise)
|
||||||
|
|
||||||
|
for sibling in siblings where sibling.id != exercise.id {
|
||||||
|
let otherSideExercise = CreateWorkoutExercise(exercise: sibling)
|
||||||
|
superset.exercises.append(otherSideExercise)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ struct CreateWorkoutItemPickerModel {
|
|||||||
|
|
||||||
class CreateWorkoutItemPickerViewModel: Identifiable, ObservableObject {
|
class CreateWorkoutItemPickerViewModel: Identifiable, ObservableObject {
|
||||||
let allValues: [CreateWorkoutItemPickerModel]
|
let allValues: [CreateWorkoutItemPickerModel]
|
||||||
@Published var selectedIds: [Int]
|
@Published var selectedIds: Set<Int>
|
||||||
|
|
||||||
init(allValues: [CreateWorkoutItemPickerModel], selectedIds: [Int]) {
|
init(allValues: [CreateWorkoutItemPickerModel], selectedIds: [Int]) {
|
||||||
self.allValues = allValues
|
self.allValues = allValues
|
||||||
self.selectedIds = selectedIds
|
self.selectedIds = Set(selectedIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleAll() {
|
func toggleAll() {
|
||||||
if selectedIds.isEmpty {
|
if selectedIds.isEmpty {
|
||||||
selectedIds.append(contentsOf: allValues.map({ $0.id }))
|
selectedIds = Set(allValues.map({ $0.id }))
|
||||||
} else {
|
} else {
|
||||||
selectedIds.removeAll()
|
selectedIds.removeAll()
|
||||||
}
|
}
|
||||||
@@ -38,65 +38,71 @@ struct CreateWorkoutItemPickerView: View {
|
|||||||
@State var searchString: String = ""
|
@State var searchString: String = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(spacing: 0) {
|
||||||
List() {
|
List() {
|
||||||
ForEach(viewModel.allValues, id:\.self.id) { value in
|
ForEach(viewModel.allValues, id:\.self.id) { value in
|
||||||
if searchString.isEmpty || value.name.lowercased().contains(searchString.lowercased()) {
|
if searchString.isEmpty || value.name.lowercased().contains(searchString.lowercased()) {
|
||||||
HStack {
|
HStack(spacing: WerkoutTheme.sm) {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(.blue, lineWidth: 1)
|
.stroke(WerkoutTheme.accent, lineWidth: 1.5)
|
||||||
.background(Circle().fill(viewModel.selectedIds.contains(value.id) ? .blue :.clear))
|
.background(Circle().fill(viewModel.selectedIds.contains(value.id) ? WerkoutTheme.accent : Color.clear))
|
||||||
.frame(width: 33, height: 33)
|
.frame(width: 33, height: 33)
|
||||||
|
|
||||||
Text(value.name)
|
Text(value.name)
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if viewModel.selectedIds.contains(value.id) {
|
if viewModel.selectedIds.contains(value.id) {
|
||||||
if let idx = viewModel.selectedIds.firstIndex(of: value.id){
|
viewModel.selectedIds.remove(value.id)
|
||||||
viewModel.selectedIds.remove(at: idx)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
viewModel.selectedIds.append(value.id)
|
viewModel.selectedIds.insert(value.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listRowBackground(WerkoutTheme.surfaceCard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
TextField("Filter", text: $searchString)
|
.background(WerkoutTheme.background)
|
||||||
.padding()
|
|
||||||
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Button(action: {
|
|
||||||
viewModel.toggleAll()
|
|
||||||
}, label: {
|
|
||||||
Image(systemName: "checklist")
|
|
||||||
.font(.title)
|
|
||||||
})
|
|
||||||
.frame(maxWidth: 44, alignment: .center)
|
|
||||||
.frame(height: 44)
|
|
||||||
.foregroundColor(.green)
|
|
||||||
.background(.white)
|
|
||||||
.cornerRadius(8)
|
|
||||||
.padding()
|
|
||||||
|
|
||||||
Button(action: {
|
TextField("Filter", text: $searchString)
|
||||||
completed(viewModel.selectedIds)
|
.werkoutTextField()
|
||||||
dismiss()
|
.padding(.horizontal, WerkoutTheme.md)
|
||||||
}, label: {
|
.padding(.vertical, WerkoutTheme.sm)
|
||||||
Text("done")
|
|
||||||
})
|
GlassEffectContainer {
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
HStack(spacing: WerkoutTheme.md) {
|
||||||
.frame(height: 44)
|
Button(action: {
|
||||||
.foregroundColor(.blue)
|
viewModel.toggleAll()
|
||||||
.background(.yellow)
|
}, label: {
|
||||||
.cornerRadius(8)
|
Image(systemName: "checklist")
|
||||||
.padding()
|
.font(.title)
|
||||||
.frame(maxWidth: .infinity)
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
})
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.success)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
completed(Array(viewModel.selectedIds))
|
||||||
|
dismiss()
|
||||||
|
}, label: {
|
||||||
|
Text("Done")
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
})
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, WerkoutTheme.md)
|
||||||
|
.padding(.vertical, WerkoutTheme.sm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.background(WerkoutTheme.background)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,10 +110,10 @@ struct CreateWorkoutItemPickerView_Previews: PreviewProvider {
|
|||||||
static let fakeValues = [CreateWorkoutItemPickerModel(id: 1, name: "one"),
|
static let fakeValues = [CreateWorkoutItemPickerModel(id: 1, name: "one"),
|
||||||
CreateWorkoutItemPickerModel(id: 2, name: "two"),
|
CreateWorkoutItemPickerModel(id: 2, name: "two"),
|
||||||
CreateWorkoutItemPickerModel(id: 3, name: "three")]
|
CreateWorkoutItemPickerModel(id: 3, name: "three")]
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
CreateWorkoutItemPickerView(viewModel: CreateWorkoutItemPickerViewModel(allValues: fakeValues, selectedIds: [1]), completed: { selectedIds in
|
CreateWorkoutItemPickerView(viewModel: CreateWorkoutItemPickerViewModel(allValues: fakeValues, selectedIds: [1]), completed: { selectedIds in
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,151 +8,118 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CreateWorkoutMainView: View {
|
struct CreateWorkoutMainView: View {
|
||||||
@ObservedObject var viewModel = WorkoutViewModel()
|
@StateObject var viewModel = WorkoutViewModel()
|
||||||
@State private var showAddExercise = false
|
|
||||||
@State var selectedCreateWorkoutSuperSet: CreateWorkoutSuperSet?
|
@State var selectedCreateWorkoutSuperSet: CreateWorkoutSuperSet?
|
||||||
|
@State private var showAddExercise = false
|
||||||
|
|
||||||
|
private var canSubmit: Bool {
|
||||||
|
viewModel.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false &&
|
||||||
|
viewModel.isUploading == false
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(spacing: 0) {
|
||||||
|
VStack(spacing: WerkoutTheme.sm) {
|
||||||
TextField("Title", text: $viewModel.title)
|
TextField("Title", text: $viewModel.title)
|
||||||
.padding()
|
.werkoutTextField()
|
||||||
.frame(height: 55)
|
.padding(.horizontal, WerkoutTheme.md)
|
||||||
.textFieldStyle(OvalTextFieldStyle())
|
|
||||||
|
TextField("Description", text: $viewModel.description)
|
||||||
TextField("Description", text: $viewModel.description)
|
.werkoutTextField()
|
||||||
.padding()
|
.padding(.horizontal, WerkoutTheme.md)
|
||||||
.frame(height: 55)
|
}
|
||||||
.textFieldStyle(OvalTextFieldStyle())
|
.padding(.vertical, WerkoutTheme.md)
|
||||||
|
.background(WerkoutTheme.surfaceCard)
|
||||||
|
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
List() {
|
List() {
|
||||||
ForEach($viewModel.superSets, id: \.id) { superset in
|
ForEach(viewModel.superSets) { superset in
|
||||||
Section {
|
CreateWorkoutSupersetView(
|
||||||
ForEach(superset.exercises, id: \.id) { exercise in
|
selectedCreateWorkoutSuperSet: $selectedCreateWorkoutSuperSet,
|
||||||
HStack {
|
showAddExercise: $showAddExercise,
|
||||||
VStack {
|
superset: superset,
|
||||||
Text(exercise.wrappedValue.exercise.name)
|
viewModel: viewModel)
|
||||||
.font(.title2)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
if exercise.wrappedValue.exercise.side != nil && exercise.wrappedValue.exercise.side!.count > 0 {
|
|
||||||
Text(exercise.wrappedValue.exercise.side!)
|
|
||||||
.font(.title3)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
}
|
|
||||||
CreateExerciseActionsView(workoutExercise: exercise.wrappedValue,
|
|
||||||
superset: superset.wrappedValue,
|
|
||||||
viewModel: viewModel)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Stepper("Number of rounds", onIncrement: {
|
|
||||||
superset.wrappedValue.increaseNumberOfRounds()
|
|
||||||
viewModel.increaseRandomNumberForUpdating()
|
|
||||||
viewModel.objectWillChange.send()
|
|
||||||
}, onDecrement: {
|
|
||||||
superset.wrappedValue.decreaseNumberOfRounds()
|
|
||||||
viewModel.increaseRandomNumberForUpdating()
|
|
||||||
viewModel.objectWillChange.send()
|
|
||||||
})
|
|
||||||
|
|
||||||
Text("\(superset.wrappedValue.numberOfRounds)")
|
|
||||||
.foregroundColor(superset.numberOfRounds.wrappedValue > 0 ? .black : .red)
|
|
||||||
.bold()
|
|
||||||
}
|
|
||||||
|
|
||||||
CreateWorkoutSupersetActionsView(workoutSuperSet: superset.wrappedValue,
|
|
||||||
showAddExercise: $showAddExercise,
|
|
||||||
viewModel: viewModel,
|
|
||||||
selectedCreateWorkoutSuperSet: $selectedCreateWorkoutSuperSet)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Text("this is the bottom 🤷♂️")
|
.listRowBackground(WerkoutTheme.surfaceCard)
|
||||||
|
// after adding new exercise we have to scroll to the bottom
|
||||||
|
// where the new exercise is sooo keep this so we can scroll
|
||||||
|
// to id 999
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 1)
|
||||||
|
.accessibilityHidden(true)
|
||||||
.id(999)
|
.id(999)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
.listRowSeparator(.hidden)
|
.listRowBackground(Color.clear)
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.randomValueForUpdatingValue, perform: { newValue in
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(WerkoutTheme.background)
|
||||||
|
.onChange(of: viewModel.randomValueForUpdatingValue) { _, _ in
|
||||||
withAnimation {
|
withAnimation {
|
||||||
proxy.scrollTo(999, anchor: .bottom)
|
proxy.scrollTo(999, anchor: .bottom)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
// .overlay(Group {
|
|
||||||
// if($viewModel.superSets.isEmpty) {
|
|
||||||
// ZStack() {
|
|
||||||
// Color(uiColor: .secondarySystemBackground)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Button("Add Superset", action: {
|
|
||||||
viewModel.addNewSuperset()
|
|
||||||
})
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.frame(height: 44)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
.background(.yellow)
|
|
||||||
.cornerRadius(8)
|
|
||||||
.padding()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Button("Done", action: {
|
|
||||||
viewModel.uploadWorkout()
|
|
||||||
})
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.frame(height: 44)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.background(.blue)
|
|
||||||
.cornerRadius(8)
|
|
||||||
.padding()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.disabled(viewModel.title.isEmpty)
|
|
||||||
}
|
|
||||||
.frame(height: 44)
|
|
||||||
.padding(.bottom)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showAddExercise) {
|
|
||||||
AddExerciseView(selectedExercise: { exercise in
|
|
||||||
let workoutExercise = CreateWorkoutExercise(exercise: exercise)
|
|
||||||
selectedCreateWorkoutSuperSet?.exercises.append(workoutExercise)
|
|
||||||
|
|
||||||
|
|
||||||
// if left or right auto add the other side
|
|
||||||
// with a recover in between b/c its
|
|
||||||
// eaiser to delete a recover than add one
|
|
||||||
if exercise.side != nil && exercise.side!.count > 0 {
|
|
||||||
let exercises = DataStore.shared.allExercise?.filter({
|
|
||||||
$0.name == exercise.name
|
|
||||||
})
|
|
||||||
let recover = DataStore.shared.allExercise?.first(where: {
|
|
||||||
$0.name.lowercased() == "recover"
|
|
||||||
})
|
|
||||||
if let exercises = exercises, let recover = recover {
|
|
||||||
if exercises.count == 2 {
|
|
||||||
let recoverWorkoutExercise = CreateWorkoutExercise(exercise: recover)
|
|
||||||
selectedCreateWorkoutSuperSet?.exercises.append(recoverWorkoutExercise)
|
|
||||||
for LRExercise in exercises {
|
|
||||||
if LRExercise.id != exercise.id {
|
|
||||||
let otherSideExercise = CreateWorkoutExercise(exercise: LRExercise)
|
|
||||||
selectedCreateWorkoutSuperSet?.exercises.append(otherSideExercise)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
viewModel.increaseRandomNumberForUpdating()
|
.sheet(isPresented: $showAddExercise) {
|
||||||
viewModel.objectWillChange.send()
|
AddExerciseView(selectedExercise: { exercise in
|
||||||
selectedCreateWorkoutSuperSet = nil
|
if let superset = selectedCreateWorkoutSuperSet {
|
||||||
})
|
viewModel.addExercise(exercise, to: superset)
|
||||||
|
}
|
||||||
|
selectedCreateWorkoutSuperSet = nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
|
|
||||||
|
GlassEffectContainer {
|
||||||
|
HStack(spacing: WerkoutTheme.md) {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.addNewSuperset()
|
||||||
|
}) {
|
||||||
|
Label("Add Superset", systemImage: "plus.circle.fill")
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
}
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
.accessibilityLabel("Add superset")
|
||||||
|
.accessibilityHint("Adds a new superset section to this workout")
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
viewModel.uploadWorkout()
|
||||||
|
}, label: {
|
||||||
|
if viewModel.isUploading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
.tint(WerkoutTheme.textPrimary)
|
||||||
|
} else {
|
||||||
|
Text("Done")
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.success)
|
||||||
|
.disabled(canSubmit == false)
|
||||||
|
.accessibilityLabel("Upload workout")
|
||||||
|
.accessibilityHint("Uploads this workout to your account")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, WerkoutTheme.md)
|
||||||
|
.padding(.vertical, WerkoutTheme.sm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(WerkoutTheme.background)
|
||||||
|
.alert("Create Workout", isPresented: Binding<Bool>(
|
||||||
|
get: { viewModel.validationError != nil },
|
||||||
|
set: { _ in viewModel.validationError = nil }
|
||||||
|
)) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(viewModel.validationError ?? "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,36 +12,38 @@ struct CreateWorkoutSupersetActionsView: View {
|
|||||||
@Binding var showAddExercise: Bool
|
@Binding var showAddExercise: Bool
|
||||||
var viewModel: WorkoutViewModel
|
var viewModel: WorkoutViewModel
|
||||||
@Binding var selectedCreateWorkoutSuperSet: CreateWorkoutSuperSet?
|
@Binding var selectedCreateWorkoutSuperSet: CreateWorkoutSuperSet?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
GlassEffectContainer {
|
||||||
Button(action: {
|
HStack(spacing: WerkoutTheme.md) {
|
||||||
selectedCreateWorkoutSuperSet = workoutSuperSet
|
Button(action: {
|
||||||
showAddExercise.toggle()
|
selectedCreateWorkoutSuperSet = workoutSuperSet
|
||||||
}) {
|
showAddExercise.toggle()
|
||||||
Text("Add exercise")
|
}) {
|
||||||
.padding()
|
Text("Add exercise")
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.success)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
// viewModel.delete(superset: workoutSuperSet)
|
||||||
|
// viewModel.increaseRandomNumberForUpdating()
|
||||||
|
// viewModel.objectWillChange.send()
|
||||||
|
|
||||||
|
}) {
|
||||||
|
Text("Delete superset")
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.danger)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
.foregroundColor(.white)
|
|
||||||
.background(.green)
|
|
||||||
.cornerRadius(10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.buttonStyle(BorderlessButtonStyle())
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
viewModel.delete(superset: workoutSuperSet)
|
|
||||||
viewModel.increaseRandomNumberForUpdating()
|
|
||||||
viewModel.objectWillChange.send()
|
|
||||||
|
|
||||||
}) {
|
|
||||||
Text("Delete superset")
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.background(.red)
|
|
||||||
.cornerRadius(10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
.buttonStyle(BorderlessButtonStyle())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,18 @@ import SwiftUI
|
|||||||
import AVKit
|
import AVKit
|
||||||
|
|
||||||
struct ExternalWorkoutDetailView: View {
|
struct ExternalWorkoutDetailView: View {
|
||||||
@StateObject 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: BaseURLs.currentBaseURL + "/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: BaseURLs.currentBaseURL + "/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"
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if let workout = bridgeModule.currentExerciseInfo.workout {
|
if let workout = bridgeModule.currentWorkoutInfo.workout {
|
||||||
GeometryReader { metrics in
|
GeometryReader { metrics in
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -26,33 +28,35 @@ struct ExternalWorkoutDetailView: View {
|
|||||||
PlayerView(player: $avPlayer)
|
PlayerView(player: $avPlayer)
|
||||||
.frame(width: metrics.size.width * 0.5, height: metrics.size.height * 0.8)
|
.frame(width: metrics.size.width * 0.5, height: metrics.size.height * 0.8)
|
||||||
.onAppear{
|
.onAppear{
|
||||||
|
avPlayer.isMuted = true
|
||||||
avPlayer.play()
|
avPlayer.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
ExtExerciseList(workout: workout,
|
ExtExerciseList(workout: workout,
|
||||||
allSupersetExecerciseIndex: bridgeModule.currentExerciseInfo.allSupersetExecerciseIndex)
|
allSupersetExecerciseIndex: bridgeModule.currentWorkoutInfo.allSupersetExecerciseIndex)
|
||||||
}
|
}
|
||||||
.frame(width: metrics.size.width * 0.4, height: metrics.size.height * 0.8)
|
.frame(width: metrics.size.width * 0.4, height: metrics.size.height * 0.8)
|
||||||
.padding([.top, .bottom], 20)
|
.padding([.top, .bottom], WerkoutTheme.lg)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
ExtCountdownView()
|
ExtCountdownView()
|
||||||
.padding(.leading, 50)
|
.padding(.leading, 50)
|
||||||
.padding(.bottom, 20)
|
.padding(.bottom, WerkoutTheme.lg)
|
||||||
|
|
||||||
if extShowNextVideo && extThotStyle != .off {
|
if extShowNextVideo && extThotStyle != .off {
|
||||||
PlayerView(player: $smallAVPlayer)
|
PlayerView(player: $smallAVPlayer)
|
||||||
.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.play()
|
smallAVPlayer.isMuted = true
|
||||||
|
smallAVPlayer.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(WerkoutTheme.surfaceCard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -61,79 +65,96 @@ struct ExternalWorkoutDetailView: View {
|
|||||||
.edgesIgnoringSafeArea(.all)
|
.edgesIgnoringSafeArea(.all)
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
if bridgeModule.currentWorkoutRunTimeInSeconds > -1 {
|
if bridgeModule.currentWorkoutRunTimeInSeconds > -1 {
|
||||||
Text(" \(Double(bridgeModule.currentWorkoutRunTimeInSeconds).asString(style: .positional)) ")
|
Text(" \(Double(bridgeModule.currentWorkoutRunTimeInSeconds).asString(style: .positional)) ")
|
||||||
.font(Font.system(size: 120))
|
.font(WerkoutTheme.stat)
|
||||||
.minimumScaleFactor(0.01)
|
.minimumScaleFactor(0.01)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.padding()
|
.padding()
|
||||||
.bold()
|
.foregroundColor(WerkoutTheme.textPrimary)
|
||||||
.foregroundColor(.white)
|
.glassEffect(.regular.interactive())
|
||||||
.background(
|
.tint(WerkoutTheme.accent)
|
||||||
Capsule()
|
|
||||||
.strokeBorder(Color.black, lineWidth: 0.8)
|
|
||||||
.background(Color(uiColor: UIColor(red: 148/255,
|
|
||||||
green: 0,
|
|
||||||
blue: 211/255,
|
|
||||||
alpha: 0.5)))
|
|
||||||
.clipped()
|
|
||||||
)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.leading, 50)
|
.padding(.leading, 50)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.onChange(of: bridgeModule.isInWorkout, perform: { _ in
|
.onChange(of: bridgeModule.isInWorkout) {
|
||||||
playVideos()
|
playVideos()
|
||||||
})
|
}
|
||||||
.onChange(of: bridgeModule.currentExerciseInfo.allSupersetExecerciseIndex, perform: { _ in
|
.onChange(of: bridgeModule.currentWorkoutInfo.allSupersetExecerciseIndex) {
|
||||||
playVideos()
|
playVideos()
|
||||||
})
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(bridgeModule.currentExerciseInfo.workout == nil ? Color(red: 157/255, green: 138/255, blue: 255/255) : Color(uiColor: .systemBackground))
|
.background(bridgeModule.currentWorkoutInfo.workout == nil ? WerkoutTheme.accent : WerkoutTheme.background)
|
||||||
.onReceive(NotificationCenter.default.publisher(
|
.onReceive(NotificationCenter.default.publisher(
|
||||||
for: UIScene.willEnterForegroundNotification)) { _ in
|
for: UIScene.willEnterForegroundNotification)) { _ in
|
||||||
avPlayer.play()
|
avPlayer.play()
|
||||||
smallAVPlayer.play()
|
smallAVPlayer.play()
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
avPlayer.pause()
|
||||||
|
smallAVPlayer.pause()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func playVideos() {
|
func playVideos() {
|
||||||
if let currentExtercise = bridgeModule.currentExerciseInfo.currentExercise {
|
if let currentExtercise = bridgeModule.currentWorkoutInfo.currentExercise {
|
||||||
if let videoURL = VideoURLCreator.videoURL(
|
if let videoURL = VideoURLCreator.videoURL(
|
||||||
thotStyle: extThotStyle,
|
thotStyle: extThotStyle,
|
||||||
gender: thotGenderOption,
|
gender: thotGenderOption,
|
||||||
defaultVideoURLStr: currentExtercise.exercise.videoURL,
|
defaultVideoURLStr: currentExtercise.exercise.videoURL,
|
||||||
exerciseName: currentExtercise.exercise.name,
|
exerciseName: currentExtercise.exercise.name,
|
||||||
workout: bridgeModule.currentExerciseInfo.workout) {
|
workout: bridgeModule.currentWorkoutInfo.workout) {
|
||||||
avPlayer = AVPlayer(url: videoURL)
|
updateMainPlayer(for: videoURL)
|
||||||
avPlayer.play()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let smallVideoURL = VideoURLCreator.videoURL(
|
if let smallVideoURL = VideoURLCreator.videoURL(
|
||||||
thotStyle: .never,
|
thotStyle: .never,
|
||||||
gender: thotGenderOption,
|
gender: thotGenderOption,
|
||||||
defaultVideoURLStr: BridgeModule.shared.currentExerciseInfo.nextExerciseInfo?.exercise.videoURL,
|
defaultVideoURLStr: BridgeModule.shared.currentWorkoutInfo.nextExerciseInfo?.exercise.videoURL,
|
||||||
exerciseName: BridgeModule.shared.currentExerciseInfo.nextExerciseInfo?.exercise.name,
|
exerciseName: BridgeModule.shared.currentWorkoutInfo.nextExerciseInfo?.exercise.name,
|
||||||
workout: bridgeModule.currentExerciseInfo.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 {
|
||||||
// static var bridge = BridgeModule.shared
|
// static var bridge = BridgeModule.shared
|
||||||
//
|
//
|
||||||
// static var previews: some View {
|
// static var previews: some View {
|
||||||
// ExternalWorkoutDetailView().environmentObject({ () -> BridgeModule in
|
// ExternalWorkoutDetailView().environmentObject({ () -> BridgeModule in
|
||||||
// let envObj = BridgeModule.shared
|
// let envObj = BridgeModule.shared
|
||||||
|
|||||||
@@ -12,72 +12,117 @@ 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 errorMessage = ""
|
||||||
|
@State var hasError: Bool = false
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
TextField("Email", text: $email)
|
TextField("Email", text: $email)
|
||||||
.textContentType(.username)
|
.textContentType(.username)
|
||||||
.autocapitalization(.none)
|
.textInputAutocapitalization(.never)
|
||||||
.frame(height: 55)
|
.autocorrectionDisabled(true)
|
||||||
.textFieldStyle(PlainTextFieldStyle())
|
.keyboardType(.emailAddress)
|
||||||
.padding([.horizontal], 4)
|
.padding()
|
||||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color(uiColor: .clear))).background(Color(uiColor: .init(red: 255/255, green: 255/255, blue: 255/255, alpha: 1)))
|
.background(WerkoutTheme.surfaceCard.opacity(0.8))
|
||||||
.cornerRadius(8)
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous)
|
||||||
|
.strokeBorder(WerkoutTheme.textSecondary.opacity(0.4), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
.padding(.horizontal)
|
||||||
.padding(.top, 25)
|
.padding(.top, 25)
|
||||||
|
.accessibilityLabel("Email")
|
||||||
|
.submitLabel(.next)
|
||||||
|
|
||||||
SecureField("Password", text: $password)
|
SecureField("Password", text: $password)
|
||||||
.textContentType(.password)
|
.textContentType(.password)
|
||||||
.autocapitalization(.none)
|
.padding()
|
||||||
.frame(height: 55)
|
.background(WerkoutTheme.surfaceCard.opacity(0.8))
|
||||||
.textFieldStyle(PlainTextFieldStyle())
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
.padding([.horizontal], 4)
|
.overlay(
|
||||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color(uiColor: .clear))).background(Color(uiColor: .init(red: 255/255, green: 255/255, blue: 255/255, alpha: 1)))
|
RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous)
|
||||||
.cornerRadius(8)
|
.strokeBorder(WerkoutTheme.textSecondary.opacity(0.4), lineWidth: 1)
|
||||||
|
)
|
||||||
if doingNetworkShit {
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled(true)
|
||||||
|
.accessibilityLabel("Password")
|
||||||
|
.submitLabel(.go)
|
||||||
|
|
||||||
|
if isLoggingIn {
|
||||||
ProgressView("Logging In")
|
ProgressView("Logging In")
|
||||||
.padding()
|
.padding()
|
||||||
.foregroundColor(.white)
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
.progressViewStyle(CircularProgressViewStyle(tint: WerkoutTheme.accent))
|
||||||
.scaleEffect(1.5, anchor: .center)
|
.scaleEffect(1.5, anchor: .center)
|
||||||
} else {
|
} else {
|
||||||
Button("Login", action: {
|
Button("Login", action: {
|
||||||
login()
|
login()
|
||||||
})
|
})
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
.foregroundColor(.blue)
|
.glassEffect(.regular.interactive())
|
||||||
.background(.yellow)
|
.tint(WerkoutTheme.accent)
|
||||||
.cornerRadius(8)
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
.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()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(
|
.background(
|
||||||
Image("icon")
|
ZStack {
|
||||||
.resizable()
|
Image("icon")
|
||||||
|
.resizable()
|
||||||
|
.edgesIgnoringSafeArea(.all)
|
||||||
|
.scaledToFill()
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color.black.opacity(0.0),
|
||||||
|
Color.black.opacity(0.6)
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
.edgesIgnoringSafeArea(.all)
|
.edgesIgnoringSafeArea(.all)
|
||||||
.scaledToFill()
|
}
|
||||||
)
|
)
|
||||||
|
.alert(errorTitle, isPresented: $hasError, actions: {
|
||||||
|
|
||||||
|
}, message: {
|
||||||
|
Text(errorMessage)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func login() {
|
func login() {
|
||||||
let postData = [
|
let postData = [
|
||||||
"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()
|
||||||
|
} else {
|
||||||
|
errorTitle = "error logging in"
|
||||||
|
errorMessage = "invalid credentials"
|
||||||
|
hasError = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -86,8 +131,7 @@ struct LoginView: View {
|
|||||||
struct LoginView_Previews: PreviewProvider {
|
struct LoginView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
LoginView(completion: {
|
LoginView(completion: {
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import CoreData
|
|||||||
|
|
||||||
struct MainView: View {
|
struct MainView: View {
|
||||||
@State var workout: Workout?
|
@State var workout: Workout?
|
||||||
@StateObject var bridgeModule = BridgeModule.shared
|
@ObservedObject var bridgeModule = BridgeModule.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
|||||||
@@ -9,79 +9,107 @@ 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)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack() {
|
VStack() {
|
||||||
Text(workout.name)
|
Text(workout.name)
|
||||||
.font(.title)
|
.font(WerkoutTheme.heroTitle)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
Text(selectedDate.formatted(date: .abbreviated, time: .omitted))
|
Text(selectedDate.formatted(date: .abbreviated, time: .omitted))
|
||||||
.font(.system(size: 28))
|
.font(.system(size: 28))
|
||||||
.bold()
|
.bold()
|
||||||
.foregroundColor(Color.accentColor)
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
.padding()
|
.padding()
|
||||||
.animation(.spring(), value: selectedDate)
|
.animation(.spring(), value: selectedDate)
|
||||||
|
|
||||||
Divider().frame(height: 1)
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
|
.frame(height: 1)
|
||||||
|
|
||||||
DatePicker("Select Date", selection: $selectedDate, displayedComponents: [.date])
|
DatePicker("Select Date", selection: $selectedDate, displayedComponents: [.date])
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.datePickerStyle(.graphical)
|
.datePickerStyle(.graphical)
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
HStack {
|
|
||||||
Button(action: {
|
GlassEffectContainer {
|
||||||
planWorkout()
|
HStack {
|
||||||
}, label: {
|
Button(action: {
|
||||||
Image(systemName: "plus.app")
|
dismiss()
|
||||||
.font(.title)
|
}, label: {
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
Image(systemName: "xmark.octagon.fill")
|
||||||
})
|
.font(.title)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.frame(height: 44)
|
})
|
||||||
.foregroundColor(.blue)
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
.background(.yellow)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.cornerRadius(8)
|
.frame(height: 44)
|
||||||
.padding()
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.danger)
|
||||||
Button(action: {
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
dismiss()
|
.padding()
|
||||||
}, label: {
|
.accessibilityLabel("Cancel planning")
|
||||||
Image(systemName: "xmark.octagon.fill")
|
.accessibilityHint("Closes this screen without planning a workout")
|
||||||
.font(.title)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
Button(action: {
|
||||||
})
|
planWorkout()
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
}, label: {
|
||||||
.frame(height: 44)
|
Image(systemName: "plus.app")
|
||||||
.foregroundColor(.white)
|
.font(.title)
|
||||||
.background(.red)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.cornerRadius(8)
|
})
|
||||||
.padding()
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.frame(height: 44)
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
|
.padding()
|
||||||
|
.accessibilityLabel("Plan workout")
|
||||||
|
.accessibilityHint("Adds this workout to your selected date")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
.background(WerkoutTheme.background)
|
||||||
|
.alert("Unable to Plan Workout", isPresented: $hasError) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(errorMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func planWorkout() {
|
func planWorkout() {
|
||||||
let postData = [
|
let postData = [
|
||||||
"on_date": selectedDate.formatForPlannedWorkout,
|
"on_date": selectedDate.formatForPlannedWorkout,
|
||||||
"workout": workout.id
|
"workout": workout.id
|
||||||
] as [String : Any]
|
] as [String : Any]
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CurrentWorkoutElapsedTimeView: View {
|
struct CurrentWorkoutElapsedTimeView: View {
|
||||||
@ObservedObject var bridgeModule = BridgeModule.shared
|
let currentWorkoutRunTimeInSeconds: Int
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if bridgeModule.currentWorkoutRunTimeInSeconds > -1 {
|
if currentWorkoutRunTimeInSeconds > -1 {
|
||||||
VStack {
|
VStack {
|
||||||
Text("\(Double(bridgeModule.currentWorkoutRunTimeInSeconds).asString(style: .positional))")
|
Text("\(Double(currentWorkoutRunTimeInSeconds).asString(style: .positional))")
|
||||||
.font(.title2)
|
.font(.system(size: 20, weight: .black, design: .monospaced))
|
||||||
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,151 +10,214 @@ 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
|
@State private var avPlayer = AVPlayer(url: URL(string: BaseURLs.currentBaseURL + "/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null"))
|
||||||
@State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!)
|
@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"
|
||||||
|
|
||||||
@State var videoExercise: Exercise? {
|
let isInWorkout: Bool
|
||||||
|
let currentSupersetIndex: Int
|
||||||
|
let currentExerciseIndex: Int
|
||||||
|
let allSupersetExecerciseIndex: Int
|
||||||
|
let currentExercise: SupersetExercise?
|
||||||
|
let currentWorkout: Workout?
|
||||||
|
let goToExerciseAt: (Int, Int) -> Void
|
||||||
|
|
||||||
|
@State private var videoExercise: Exercise? {
|
||||||
didSet {
|
didSet {
|
||||||
if let videoURL = VideoURLCreator.videoURL(
|
if let videoURL = VideoURLCreator.videoURL(
|
||||||
thotStyle: phoneThotStyle,
|
thotStyle: phoneThotStyle,
|
||||||
gender: thotGenderOption,
|
gender: thotGenderOption,
|
||||||
defaultVideoURLStr: self.videoExercise?.videoURL,
|
defaultVideoURLStr: self.videoExercise?.videoURL,
|
||||||
exerciseName: self.videoExercise?.name,
|
exerciseName: self.videoExercise?.name,
|
||||||
workout: bridgeModule.currentExerciseInfo.workout) {
|
workout: currentWorkout) {
|
||||||
avPlayer = AVPlayer(url: videoURL)
|
updatePreviewPlayer(for: videoURL)
|
||||||
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(Array(supersets.enumerated()), id: \.offset) { supersetIndex, superset in
|
||||||
let superset = supersets[supersetIndex]
|
|
||||||
Section(content: {
|
Section(content: {
|
||||||
ForEach(superset.exercises.indices, id: \.self) { exerciseIndex in
|
ForEach(Array(superset.exercises.enumerated()), id: \.offset) { exerciseIndex, supersetExecercise in
|
||||||
let supersetExecercise = superset.exercises[exerciseIndex]
|
let rowID = rowIdentifier(
|
||||||
|
supersetIndex: supersetIndex,
|
||||||
|
exerciseIndex: exerciseIndex,
|
||||||
|
exercise: supersetExecercise
|
||||||
|
)
|
||||||
|
let isCurrentExercise = isInWorkout &&
|
||||||
|
supersetIndex == currentSupersetIndex &&
|
||||||
|
exerciseIndex == currentExerciseIndex
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
Button(action: {
|
||||||
if bridgeModule.isInWorkout &&
|
if isInWorkout {
|
||||||
supersetIndex == bridgeModule.currentExerciseInfo.supersetIndex &&
|
goToExerciseAt(supersetIndex, exerciseIndex)
|
||||||
exerciseIndex == bridgeModule.currentExerciseInfo.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 {
|
|
||||||
bridgeModule.goToExerciseAt(section: supersetIndex, row: exerciseIndex)
|
|
||||||
} else {
|
} else {
|
||||||
videoExercise = supersetExecercise.exercise
|
videoExercise = supersetExecercise.exercise
|
||||||
}
|
}
|
||||||
}
|
}, label: {
|
||||||
|
HStack {
|
||||||
if bridgeModule.isInWorkout &&
|
if isCurrentExercise {
|
||||||
supersetIndex == bridgeModule.currentExerciseInfo.supersetIndex &&
|
Image(systemName: "figure.run")
|
||||||
exerciseIndex == bridgeModule.currentExerciseInfo.exerciseIndex &&
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
showExecersizeInfo {
|
}
|
||||||
|
|
||||||
|
Text(supersetExecercise.exercise.extName)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let reps = supersetExecercise.reps,
|
||||||
|
reps > 0 {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "number")
|
||||||
|
Text("\(reps)")
|
||||||
|
}
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, WerkoutTheme.sm)
|
||||||
|
.padding(.vertical, WerkoutTheme.xs)
|
||||||
|
.background(WerkoutTheme.accent)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
if let duration = supersetExecercise.duration,
|
||||||
|
duration > 0 {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "stopwatch")
|
||||||
|
Text("\(duration)")
|
||||||
|
}
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, WerkoutTheme.sm)
|
||||||
|
.padding(.vertical, WerkoutTheme.xs)
|
||||||
|
.background(WerkoutTheme.success)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
})
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Exercise \(supersetExecercise.exercise.extName)")
|
||||||
|
.accessibilityHint(isInWorkout ? "Jump to this exercise in the workout" : "Preview exercise video")
|
||||||
|
|
||||||
|
if isCurrentExercise && showExecersizeInfo {
|
||||||
detailView(forExercise: supersetExecercise)
|
detailView(forExercise: supersetExecercise)
|
||||||
}
|
}
|
||||||
}.id(supersetExecercise.id)
|
}
|
||||||
|
.listRowBackground(
|
||||||
|
isCurrentExercise
|
||||||
|
? WerkoutTheme.accent.opacity(0.1)
|
||||||
|
: WerkoutTheme.surfaceCard
|
||||||
|
)
|
||||||
|
.id(rowID)
|
||||||
}
|
}
|
||||||
}, header: {
|
}, header: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(superset.name ?? "--")
|
Text(superset.name ?? "--")
|
||||||
.foregroundColor(Color("appColor"))
|
.font(WerkoutTheme.sectionTitle)
|
||||||
.bold()
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(superset.rounds) rounds")
|
Text("\(superset.rounds) rounds")
|
||||||
.foregroundColor(Color("appColor"))
|
.font(WerkoutTheme.caption)
|
||||||
.bold()
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
|
|
||||||
if let estimatedTime = superset.estimatedTime {
|
if let estimatedTime = superset.estimatedTime {
|
||||||
Text("@ " + estimatedTime.asString(style: .abbreviated))
|
Text("@ " + estimatedTime.asString(style: .abbreviated))
|
||||||
.foregroundColor(Color("appColor"))
|
.font(WerkoutTheme.caption)
|
||||||
.bold()
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: bridgeModule.currentExerciseInfo.allSupersetExecerciseIndex, perform: { newValue in
|
.scrollContentBackground(.hidden)
|
||||||
if let newCurrentExercise = bridgeModule.currentExerciseInfo.currentExercise {
|
.background(WerkoutTheme.background)
|
||||||
|
.onChange(of: allSupersetExecerciseIndex) { _, _ in
|
||||||
|
if let newCurrentExercise = currentExercise {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
proxy.scrollTo(newCurrentExercise.id, anchor: .top)
|
proxy.scrollTo(
|
||||||
|
rowIdentifier(
|
||||||
|
supersetIndex: currentSupersetIndex,
|
||||||
|
exerciseIndex: currentExerciseIndex,
|
||||||
|
exercise: newCurrentExercise
|
||||||
|
),
|
||||||
|
anchor: .top
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
.sheet(item: $videoExercise) { exercise in
|
.sheet(item: $videoExercise) { exercise in
|
||||||
PlayerView(player: $avPlayer)
|
PlayerView(player: $avPlayer)
|
||||||
.onAppear{
|
.onAppear{
|
||||||
|
avPlayer.isMuted = true
|
||||||
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(spacing: WerkoutTheme.sm) {
|
||||||
Text(supersetExecercise.exercise.description)
|
Text(supersetExecercise.exercise.description)
|
||||||
.frame(alignment: .leading)
|
.font(WerkoutTheme.bodyText)
|
||||||
Divider()
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
WerkoutTheme.divider.frame(height: 0.5)
|
||||||
Text(supersetExecercise.exercise.muscles.map({ $0.name }).joined(separator: ", "))
|
Text(supersetExecercise.exercise.muscles.map({ $0.name }).joined(separator: ", "))
|
||||||
.frame(alignment: .leading)
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//struct ExerciseListView_Previews: PreviewProvider {
|
struct ExerciseListView_Previews: PreviewProvider {
|
||||||
// static var previews: some View {
|
static var previews: some View {
|
||||||
// ExerciseListView(workout: PreviewData.workout(), showExecersizeInfo: )
|
ExerciseListView(
|
||||||
// }
|
workout: PreviewData.workout(),
|
||||||
//}
|
showExecersizeInfo: .constant(true),
|
||||||
|
isInWorkout: false,
|
||||||
|
currentSupersetIndex: 0,
|
||||||
|
currentExerciseIndex: 0,
|
||||||
|
allSupersetExecerciseIndex: 0,
|
||||||
|
currentExercise: nil,
|
||||||
|
currentWorkout: nil,
|
||||||
|
goToExerciseAt: { _, _ in }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import SwiftUI
|
|||||||
import AVKit
|
import AVKit
|
||||||
|
|
||||||
struct WorkoutDetailView: View {
|
struct WorkoutDetailView: View {
|
||||||
@StateObject var viewModel: WorkoutDetailViewModel
|
@ObservedObject var viewModel: WorkoutDetailViewModel
|
||||||
@State var avPlayer = AVPlayer(url: URL(string: "https://dev.werkout.fitness/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4")!)
|
@State private var avPlayer = AVPlayer(url: URL(string: BaseURLs.currentBaseURL + "/media/exercise_videos/2_Dumbbell_Lateral_Lunges.mp4") ?? URL(fileURLWithPath: "/dev/null"))
|
||||||
|
@State private var currentVideoURL: URL?
|
||||||
@StateObject var bridgeModule = BridgeModule.shared
|
|
||||||
|
@ObservedObject var bridgeModule = BridgeModule.shared
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@AppStorage(Constants.phoneThotStyle) private var phoneThotStyle: ThotStyle = .never
|
@AppStorage(Constants.phoneThotStyle) private var phoneThotStyle: ThotStyle = .never
|
||||||
@AppStorage(Constants.thotGenderOption) private var thotGenderOption: String = "female"
|
@AppStorage(Constants.thotGenderOption) private var thotGenderOption: String = "female"
|
||||||
@@ -21,109 +22,141 @@ struct WorkoutDetailView: View {
|
|||||||
case completedWorkout([String: Any])
|
case completedWorkout([String: Any])
|
||||||
var id: String { return "completedWorkoutSheet" }
|
var id: String { return "completedWorkoutSheet" }
|
||||||
}
|
}
|
||||||
|
|
||||||
@State var workoutComplete: Sheet?
|
@State private var workoutComplete: Sheet?
|
||||||
@State var workoutToPlan: Workout?
|
@State private var workoutToPlan: Workout?
|
||||||
@State var showExecersizeInfo: Bool = false
|
@State private var showExecersizeInfo: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
WerkoutTheme.background.ignoresSafeArea()
|
||||||
|
|
||||||
switch viewModel.status {
|
switch viewModel.status {
|
||||||
case .loading:
|
case .loading:
|
||||||
Text("Loading")
|
ProgressView()
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
case .failed(let errorMessage):
|
||||||
|
VStack(spacing: WerkoutTheme.md) {
|
||||||
|
Text("Unable to load workout")
|
||||||
|
.font(WerkoutTheme.sectionTitle)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
case .showWorkout(let workout):
|
case .showWorkout(let workout):
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if bridgeModule.isInWorkout {
|
if bridgeModule.isInWorkout {
|
||||||
HStack {
|
HStack {
|
||||||
CountdownView()
|
CountdownView(
|
||||||
|
currentExerciseDuration: bridgeModule.currentWorkoutInfo.currentExercise?.duration,
|
||||||
|
currentExerciseTimeLeft: bridgeModule.currentExerciseTimeLeft
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
if phoneThotStyle != .off {
|
if phoneThotStyle != .off {
|
||||||
GeometryReader { metrics in
|
PlayerView(player: $avPlayer)
|
||||||
ZStack {
|
.frame(height: 220)
|
||||||
PlayerView(player: $avPlayer)
|
.onAppear{
|
||||||
.frame(width: metrics.size.width * 1, height: metrics.size.height * 1)
|
avPlayer.isMuted = true
|
||||||
.onAppear{
|
avPlayer.play()
|
||||||
avPlayer.play()
|
}
|
||||||
}
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if let assetURL = ((avPlayer.currentItem?.asset) as? AVURLAsset)?.url,
|
if let assetURL = ((avPlayer.currentItem?.asset) as? AVURLAsset)?.url,
|
||||||
let currentExtercise = bridgeModule.currentExerciseInfo.currentExercise,
|
let currentExtercise = bridgeModule.currentWorkoutInfo.currentExercise,
|
||||||
let otherVideoURL = VideoURLCreator.videoURL(
|
let otherVideoURL = VideoURLCreator.videoURL(
|
||||||
thotStyle: VideoURLCreator.otherVideoType(forVideoURL: assetURL),
|
thotStyle: VideoURLCreator.otherVideoType(forVideoURL: assetURL),
|
||||||
gender: thotGenderOption,
|
gender: thotGenderOption,
|
||||||
defaultVideoURLStr: currentExtercise.exercise.videoURL,
|
defaultVideoURLStr: currentExtercise.exercise.videoURL,
|
||||||
exerciseName: currentExtercise.exercise.name,
|
exerciseName: currentExtercise.exercise.name,
|
||||||
workout: bridgeModule.currentExerciseInfo.workout) {
|
workout: bridgeModule.currentWorkoutInfo.workout) {
|
||||||
avPlayer = AVPlayer(url: otherVideoURL)
|
updatePlayer(for: otherVideoURL)
|
||||||
avPlayer.play()
|
|
||||||
}
|
}
|
||||||
}, label: {
|
}) {
|
||||||
Image(systemName: "arrow.triangle.2.circlepath.camera.fill")
|
Image(systemName: "arrow.triangle.2.circlepath.camera.fill")
|
||||||
.frame(width: 44, height: 44)
|
.font(.title2)
|
||||||
.foregroundColor(Color("appColor"))
|
.padding(WerkoutTheme.sm)
|
||||||
})
|
}
|
||||||
.foregroundColor(.blue)
|
.glassEffect(.regular.interactive())
|
||||||
.cornerRadius(4)
|
.tint(WerkoutTheme.accent)
|
||||||
.frame(width: 160, height: 120)
|
.padding(WerkoutTheme.sm)
|
||||||
.position(x: metrics.size.width - 22, y: metrics.size.height - 30)
|
.accessibilityLabel("Switch video style")
|
||||||
|
.accessibilityHint("Toggles between alternate and default exercise videos")
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showExecersizeInfo.toggle()
|
showExecersizeInfo.toggle()
|
||||||
}, label: {
|
}) {
|
||||||
Image(systemName: "info.circle.fill")
|
Image(systemName: "info.circle.fill")
|
||||||
.frame(width: 44, height: 44)
|
.font(.title2)
|
||||||
.foregroundColor(Color("appColor"))
|
.padding(WerkoutTheme.sm)
|
||||||
})
|
}
|
||||||
.foregroundColor(.blue)
|
.glassEffect(.regular.interactive())
|
||||||
.cornerRadius(4)
|
.tint(WerkoutTheme.accent)
|
||||||
.frame(width: 120, height: 120)
|
.padding(WerkoutTheme.sm)
|
||||||
.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])
|
.background(WerkoutTheme.background)
|
||||||
.background(Color(uiColor: .tertiarySystemBackground))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !bridgeModule.isInWorkout {
|
if !bridgeModule.isInWorkout {
|
||||||
InfoView(workout: workout)
|
InfoView(workout: workout)
|
||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
}
|
}
|
||||||
|
|
||||||
if bridgeModule.isInWorkout {
|
if bridgeModule.isInWorkout {
|
||||||
Divider()
|
WerkoutTheme.divider.frame(height: 0.5)
|
||||||
.background(Color(uiColor: .secondaryLabel))
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(bridgeModule.currentExerciseInfo.currentRound) of \(bridgeModule.currentExerciseInfo.numberOfRoundsInCurrentSuperSet)")
|
Text("\(bridgeModule.currentWorkoutInfo.currentRound) of \(bridgeModule.currentWorkoutInfo.numberOfRoundsInCurrentSuperSet)")
|
||||||
.font(.title3)
|
.font(.system(size: 17, weight: .bold, design: .monospaced))
|
||||||
.bold()
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.leading, 10)
|
.padding(.leading, 10)
|
||||||
|
|
||||||
CurrentWorkoutElapsedTimeView()
|
CurrentWorkoutElapsedTimeView(
|
||||||
|
currentWorkoutRunTimeInSeconds: bridgeModule.currentWorkoutRunTimeInSeconds
|
||||||
|
)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
||||||
Text("\(bridgeModule.currentExerciseInfo.allSupersetExecerciseIndex+1)/\(bridgeModule.currentExerciseInfo.workout?.allSupersetExecercise?.count ?? -99)")
|
Text(progressText)
|
||||||
.font(.title3)
|
.font(.system(size: 17, weight: .bold, design: .monospaced))
|
||||||
.bold()
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
.padding(.trailing, 10)
|
.padding(.trailing, 10)
|
||||||
}
|
}
|
||||||
.padding([.top, .bottom])
|
.padding([.top, .bottom])
|
||||||
.background(Color(uiColor: .tertiarySystemBackground))
|
.background(WerkoutTheme.surfaceCard)
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
WerkoutTheme.divider.frame(height: 0.5)
|
||||||
.background(Color(uiColor: .secondaryLabel))
|
|
||||||
|
ExerciseListView(
|
||||||
ExerciseListView(workout: workout, showExecersizeInfo: $showExecersizeInfo)
|
workout: workout,
|
||||||
|
showExecersizeInfo: $showExecersizeInfo,
|
||||||
|
isInWorkout: bridgeModule.isInWorkout,
|
||||||
|
currentSupersetIndex: bridgeModule.currentWorkoutInfo.supersetIndex,
|
||||||
|
currentExerciseIndex: bridgeModule.currentWorkoutInfo.exerciseIndex,
|
||||||
|
allSupersetExecerciseIndex: bridgeModule.currentWorkoutInfo.allSupersetExecerciseIndex,
|
||||||
|
currentExercise: bridgeModule.currentWorkoutInfo.currentExercise,
|
||||||
|
currentWorkout: bridgeModule.currentWorkoutInfo.workout,
|
||||||
|
goToExerciseAt: { supersetIndex, exerciseIndex in
|
||||||
|
bridgeModule.currentWorkoutInfo.goToExerciseAt(
|
||||||
|
supersetIndex: supersetIndex,
|
||||||
|
exerciseIndex: exerciseIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
.padding([.top, .bottom], 10)
|
.padding([.top, .bottom], 10)
|
||||||
.background(Color(uiColor: .systemGroupedBackground))
|
.background(WerkoutTheme.background)
|
||||||
|
|
||||||
ActionsView(completedWorkout: {
|
ActionsView(completedWorkout: {
|
||||||
bridgeModule.completeWorkout()
|
bridgeModule.completeWorkout()
|
||||||
}, planWorkout: { workout in
|
}, planWorkout: { workout in
|
||||||
@@ -131,8 +164,8 @@ struct WorkoutDetailView: View {
|
|||||||
}, workout: workout, showAddToCalendar: viewModel.isPreview, startWorkoutAction: {
|
}, workout: workout, showAddToCalendar: viewModel.isPreview, startWorkoutAction: {
|
||||||
startWorkout(workout: workout)
|
startWorkout(workout: workout)
|
||||||
})
|
})
|
||||||
.frame(height: 44)
|
.frame(height: 56)
|
||||||
|
|
||||||
}
|
}
|
||||||
.sheet(item: $workoutComplete) { item in
|
.sheet(item: $workoutComplete) { item in
|
||||||
switch item {
|
switch item {
|
||||||
@@ -154,25 +187,16 @@ struct WorkoutDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: bridgeModule.currentExerciseInfo.allSupersetExecerciseIndex, perform: { _ in
|
.onChange(of: bridgeModule.currentWorkoutInfo.allSupersetExecerciseIndex) { _, _ in
|
||||||
playVideos()
|
playVideos()
|
||||||
})
|
}
|
||||||
.onChange(of: bridgeModule.isInWorkout, perform: { _ in
|
.onChange(of: bridgeModule.isInWorkout) { _, _ in
|
||||||
playVideos()
|
playVideos()
|
||||||
})
|
}
|
||||||
.onAppear{
|
.onAppear{
|
||||||
if let currentExtercise = bridgeModule.currentExerciseInfo.currentExercise {
|
viewModel.load()
|
||||||
if let videoURL = VideoURLCreator.videoURL(
|
playVideos()
|
||||||
thotStyle: phoneThotStyle,
|
|
||||||
gender: thotGenderOption,
|
|
||||||
defaultVideoURLStr: currentExtercise.exercise.videoURL,
|
|
||||||
exerciseName: currentExtercise.exercise.name,
|
|
||||||
workout: bridgeModule.currentExerciseInfo.workout) {
|
|
||||||
avPlayer = AVPlayer(url: videoURL)
|
|
||||||
avPlayer.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bridgeModule.completedWorkout = {
|
bridgeModule.completedWorkout = {
|
||||||
if let workoutData = createWorkoutData() {
|
if let workoutData = createWorkoutData() {
|
||||||
workoutComplete = .completedWorkout(workoutData)
|
workoutComplete = .completedWorkout(workoutData)
|
||||||
@@ -181,30 +205,58 @@ struct WorkoutDetailView: View {
|
|||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(
|
.onReceive(NotificationCenter.default.publisher(
|
||||||
for: UIScene.willEnterForegroundNotification)) { _ in
|
for: UIScene.willEnterForegroundNotification)) { _ in
|
||||||
|
avPlayer.isMuted = true
|
||||||
avPlayer.play()
|
avPlayer.play()
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
avPlayer.pause()
|
||||||
|
bridgeModule.completedWorkout = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func playVideos() {
|
func playVideos() {
|
||||||
if let currentExtercise = bridgeModule.currentExerciseInfo.currentExercise {
|
if let currentExtercise = bridgeModule.currentWorkoutInfo.currentExercise {
|
||||||
if let videoURL = VideoURLCreator.videoURL(
|
if let videoURL = VideoURLCreator.videoURL(
|
||||||
thotStyle: phoneThotStyle,
|
thotStyle: phoneThotStyle,
|
||||||
gender: thotGenderOption,
|
gender: thotGenderOption,
|
||||||
defaultVideoURLStr: currentExtercise.exercise.videoURL,
|
defaultVideoURLStr: currentExtercise.exercise.videoURL,
|
||||||
exerciseName: currentExtercise.exercise.name,
|
exerciseName: currentExtercise.exercise.name,
|
||||||
workout: bridgeModule.currentExerciseInfo.workout) {
|
workout: bridgeModule.currentWorkoutInfo.workout) {
|
||||||
avPlayer = AVPlayer(url: videoURL)
|
updatePlayer(for: videoURL)
|
||||||
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.currentExerciseInfo.workout?.id,
|
guard let workoutid = bridgeModule.currentWorkoutInfo.workout?.id,
|
||||||
let startTime = bridgeModule.workoutStartDate?.timeFormatForUpload,
|
let startTime = bridgeModule.workoutStartDate?.timeFormatForUpload,
|
||||||
let endTime = bridgeModule.workoutEndDate?.timeFormatForUpload else {
|
let endTime = bridgeModule.workoutEndDate?.timeFormatForUpload else {
|
||||||
return nil
|
return nil
|
||||||
@@ -216,7 +268,7 @@ struct WorkoutDetailView: View {
|
|||||||
"workout": workoutid,
|
"workout": workoutid,
|
||||||
"total_time": bridgeModule.currentWorkoutRunTimeInSeconds
|
"total_time": bridgeModule.currentWorkoutRunTimeInSeconds
|
||||||
] as [String : Any]
|
] as [String : Any]
|
||||||
|
|
||||||
return postBody
|
return postBody
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,28 +12,37 @@ 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
|
||||||
let isPreview: Bool
|
let isPreview: Bool
|
||||||
|
let workout: Workout
|
||||||
|
|
||||||
init(workout: Workout, status: WorkoutDetailViewModelStatus? = nil, isPreview: Bool) {
|
init(workout: Workout, status: WorkoutDetailViewModelStatus? = nil, isPreview: Bool) {
|
||||||
self.status = .loading
|
self.workout = workout
|
||||||
self.isPreview = isPreview
|
self.isPreview = isPreview
|
||||||
|
|
||||||
if let passedStatus = status {
|
if let passedStatus = status {
|
||||||
self.status = passedStatus
|
self.status = passedStatus
|
||||||
} else {
|
} else {
|
||||||
WorkoutDetailFetchable(workoutID: workout.id).fetch(completion: { result in
|
self.status = .loading
|
||||||
switch result {
|
|
||||||
case .success(let model):
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.status = .showWorkout(model)
|
|
||||||
}
|
|
||||||
case .failure(let failure):
|
|
||||||
fatalError("failed \(failure.localizedDescription)")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
guard case .loading = status else { return }
|
||||||
|
WorkoutDetailFetchable(workoutID: workout.id).fetch(completion: { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let model):
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.status = .showWorkout(model)
|
||||||
|
}
|
||||||
|
case .failure(let failure):
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.status = .failed("Failed to load workout details: \(failure.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ struct WorkoutHistoryView: View {
|
|||||||
case average
|
case average
|
||||||
case hard
|
case hard
|
||||||
case death
|
case death
|
||||||
|
|
||||||
var stringValue: String {
|
var stringValue: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
||||||
case .easy:
|
case .easy:
|
||||||
return "Easy"
|
return "Easy"
|
||||||
case .moderate:
|
case .moderate:
|
||||||
@@ -30,43 +30,81 @@ struct WorkoutHistoryView: View {
|
|||||||
return "Death"
|
return "Death"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .easy:
|
||||||
|
return WerkoutTheme.success
|
||||||
|
case .moderate:
|
||||||
|
return Color(red: 0.4, green: 0.8, blue: 0.2)
|
||||||
|
case .average:
|
||||||
|
return WerkoutTheme.warning
|
||||||
|
case .hard:
|
||||||
|
return Color(red: 1.0, green: 0.5, blue: 0.2)
|
||||||
|
case .death:
|
||||||
|
return WerkoutTheme.danger
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let completedWorkouts: [CompletedWorkout]
|
let completedWorkouts: [CompletedWorkout]
|
||||||
|
|
||||||
@State private var selectedPlannedWorkout: Workout?
|
@State private var selectedPlannedWorkout: Workout?
|
||||||
|
|
||||||
|
private var sortedWorkouts: [CompletedWorkout] {
|
||||||
|
completedWorkouts.sorted(by: { $0.createdAt > $1.createdAt })
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
ForEach(completedWorkouts, id:\.self.id) { completedWorkout in
|
ForEach(sortedWorkouts, id: \.id) { completedWorkout in
|
||||||
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)
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
|
|
||||||
Text("\(date.get(.day))")
|
Text("\(date.get(.day))")
|
||||||
|
.font(WerkoutTheme.stat.monospacedDigit())
|
||||||
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
|
|
||||||
Text("\(date.get(.hour))")
|
Text("\(date.get(.hour))")
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.textMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(minWidth: 60)
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(completedWorkout.workout.name)
|
Text(completedWorkout.workout.name)
|
||||||
.font(.title3)
|
.font(WerkoutTheme.cardTitle)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
|
||||||
if let desc = completedWorkout.workout.description {
|
if let desc = completedWorkout.workout.description {
|
||||||
Text(desc)
|
Text(desc)
|
||||||
.font(.footnote)
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
|
|
||||||
if let difficulty = completedWorkout.difficulty,
|
if let difficulty = completedWorkout.difficulty,
|
||||||
let string = DifficltyString.init(rawValue: difficulty)?.stringValue {
|
let difficultyEnum = DifficltyString(rawValue: difficulty) {
|
||||||
Text(string)
|
Text(difficultyEnum.stringValue)
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(difficultyEnum.color)
|
||||||
|
.padding(.horizontal, WerkoutTheme.sm)
|
||||||
|
.padding(.vertical, WerkoutTheme.xs)
|
||||||
|
.background(difficultyEnum.color.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.badgeRadius, style: .continuous))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let notes = completedWorkout.notes {
|
if let notes = completedWorkout.notes {
|
||||||
Text(notes)
|
Text(notes)
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.leading)
|
.padding(.leading)
|
||||||
@@ -74,10 +112,14 @@ struct WorkoutHistoryView: View {
|
|||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
selectedPlannedWorkout = completedWorkout.workout
|
selectedPlannedWorkout = completedWorkout.workout
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
.listRowBackground(WerkoutTheme.surfaceCard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.background(WerkoutTheme.background)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
.sheet(item: $selectedPlannedWorkout) { item in
|
.sheet(item: $selectedPlannedWorkout) { item in
|
||||||
let viewModel = WorkoutDetailViewModel(workout: item, isPreview: true)
|
let viewModel = WorkoutDetailViewModel(workout: item, isPreview: true)
|
||||||
WorkoutDetailView(viewModel: viewModel)
|
WorkoutDetailView(viewModel: viewModel)
|
||||||
|
|||||||
127
iphone/Werkout_ios/WerkoutTheme.swift
Normal file
127
iphone/Werkout_ios/WerkoutTheme.swift
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
//
|
||||||
|
// WerkoutTheme.swift
|
||||||
|
// Werkout_ios
|
||||||
|
//
|
||||||
|
// Design system: iOS 26 Liquid Glass + Industrial Gym Aesthetic
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Design Tokens
|
||||||
|
|
||||||
|
enum WerkoutTheme {
|
||||||
|
|
||||||
|
// MARK: Colors
|
||||||
|
|
||||||
|
static let background = Color.black
|
||||||
|
static let surfaceCard = Color(white: 0.09)
|
||||||
|
static let surfaceElevated = Color(white: 0.12)
|
||||||
|
static let accent = Color("appColor")
|
||||||
|
static let accentNeon = Color(red: 0.75, green: 0.35, blue: 1.0)
|
||||||
|
static let success = Color(red: 0.0, green: 0.9, blue: 0.5)
|
||||||
|
static let danger = Color(red: 1.0, green: 0.25, blue: 0.3)
|
||||||
|
static let warning = Color(red: 1.0, green: 0.75, blue: 0.0)
|
||||||
|
static let textPrimary = Color.white
|
||||||
|
static let textSecondary = Color(white: 0.55)
|
||||||
|
static let textMuted = Color(white: 0.35)
|
||||||
|
static let divider = Color(white: 0.15)
|
||||||
|
|
||||||
|
// MARK: Typography
|
||||||
|
|
||||||
|
static let heroTitle = Font.system(size: 34, weight: .black, design: .rounded)
|
||||||
|
static let sectionTitle = Font.system(size: 20, weight: .heavy, design: .rounded)
|
||||||
|
static let cardTitle = Font.system(size: 17, weight: .bold)
|
||||||
|
static let bodyText = Font.system(size: 15, weight: .medium)
|
||||||
|
static let caption = Font.system(size: 12, weight: .semibold)
|
||||||
|
static let stat = Font.system(size: 48, weight: .black, design: .monospaced)
|
||||||
|
|
||||||
|
// MARK: Spacing
|
||||||
|
|
||||||
|
static let xs: CGFloat = 4
|
||||||
|
static let sm: CGFloat = 8
|
||||||
|
static let md: CGFloat = 16
|
||||||
|
static let lg: CGFloat = 24
|
||||||
|
static let xl: CGFloat = 32
|
||||||
|
|
||||||
|
// MARK: Corner Radius
|
||||||
|
|
||||||
|
static let cardRadius: CGFloat = 16
|
||||||
|
static let buttonRadius: CGFloat = 12
|
||||||
|
static let badgeRadius: CGFloat = 8
|
||||||
|
static let pillRadius: CGFloat = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reusable View Modifiers
|
||||||
|
|
||||||
|
struct WerkoutCardModifier: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.padding(WerkoutTheme.md)
|
||||||
|
.background(WerkoutTheme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.cardRadius, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: WerkoutTheme.cardRadius, style: .continuous)
|
||||||
|
.strokeBorder(WerkoutTheme.divider, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WerkoutButtonModifier: ViewModifier {
|
||||||
|
var color: Color = WerkoutTheme.accent
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, WerkoutTheme.lg)
|
||||||
|
.padding(.vertical, WerkoutTheme.sm + 4)
|
||||||
|
.background(color)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WerkoutTextFieldModifier: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.padding(12)
|
||||||
|
.background(WerkoutTheme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous)
|
||||||
|
.strokeBorder(WerkoutTheme.accent.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func werkoutCard() -> some View {
|
||||||
|
modifier(WerkoutCardModifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
func werkoutButton(color: Color = WerkoutTheme.accent) -> some View {
|
||||||
|
modifier(WerkoutButtonModifier(color: color))
|
||||||
|
}
|
||||||
|
|
||||||
|
func werkoutTextField() -> some View {
|
||||||
|
modifier(WerkoutTextFieldModifier())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Button Style
|
||||||
|
|
||||||
|
struct WerkoutActionButtonStyle: ButtonStyle {
|
||||||
|
var color: Color = WerkoutTheme.accent
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, WerkoutTheme.lg)
|
||||||
|
.padding(.vertical, WerkoutTheme.sm + 4)
|
||||||
|
.background(color)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
|
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
import AVKit
|
import AVKit
|
||||||
|
import SharedCore
|
||||||
|
|
||||||
|
|
||||||
struct Constants {
|
struct Constants {
|
||||||
@@ -15,6 +16,8 @@ struct Constants {
|
|||||||
static let extThotStyle = "extThotStyle"
|
static let extThotStyle = "extThotStyle"
|
||||||
static let extShowNextVideo = "extShowNextVideo"
|
static let extShowNextVideo = "extShowNextVideo"
|
||||||
static let thotGenderOption = "thotGenderOption"
|
static let thotGenderOption = "thotGenderOption"
|
||||||
|
|
||||||
|
static let buttonRadius: CGFloat = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -22,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)
|
||||||
|
let logoutPub = NotificationCenter.default.publisher(for: AppNotifications.userLoggedOut)
|
||||||
|
|
||||||
private var screenDidConnectPublisher: AnyPublisher<UIScreen, Never> {
|
private var screenDidConnectPublisher: AnyPublisher<UIWindowScene, Never> {
|
||||||
NotificationCenter.default
|
NotificationCenter.default
|
||||||
.publisher(for: UIScreen.didConnectNotification)
|
.publisher(for: UIScene.willConnectNotification)
|
||||||
.compactMap { $0.object as? UIScreen }
|
.compactMap { $0.object as? UIWindowScene }
|
||||||
|
.filter { $0.screen != UIApplication.shared.connectedScenes.compactMap { ($0 as? UIWindowScene)?.screen }.first }
|
||||||
.receive(on: RunLoop.main)
|
.receive(on: RunLoop.main)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var screenDidDisconnectPublisher: AnyPublisher<UIScreen, Never> {
|
private var screenDidDisconnectPublisher: AnyPublisher<UIWindowScene, Never> {
|
||||||
NotificationCenter.default
|
NotificationCenter.default
|
||||||
.publisher(for: UIScreen.didDisconnectNotification)
|
.publisher(for: UIScene.didDisconnectNotification)
|
||||||
.compactMap { $0.object as? UIScreen }
|
.compactMap { $0.object as? UIWindowScene }
|
||||||
.receive(on: RunLoop.main)
|
.receive(on: RunLoop.main)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
@@ -45,14 +51,12 @@ struct Werkout_iosApp: App {
|
|||||||
WindowGroup {
|
WindowGroup {
|
||||||
TabView(selection: $tabSelection) {
|
TabView(selection: $tabSelection) {
|
||||||
AllWorkoutsView()
|
AllWorkoutsView()
|
||||||
.onReceive(
|
.onReceive(screenDidConnectPublisher) { scene in
|
||||||
screenDidConnectPublisher,
|
screenDidConnect(scene)
|
||||||
perform: screenDidConnect
|
}
|
||||||
)
|
.onReceive(screenDidDisconnectPublisher) { scene in
|
||||||
.onReceive(
|
screenDidDisconnect(scene)
|
||||||
screenDidDisconnectPublisher,
|
}
|
||||||
perform: screenDidDisconnect
|
|
||||||
)
|
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("All Workouts", systemImage: "figure.strengthtraining.traditional")
|
Label("All Workouts", systemImage: "figure.strengthtraining.traditional")
|
||||||
}
|
}
|
||||||
@@ -70,30 +74,33 @@ struct Werkout_iosApp: App {
|
|||||||
}
|
}
|
||||||
.tag(3)
|
.tag(3)
|
||||||
}
|
}
|
||||||
.accentColor(Color("appColor"))
|
.tint(WerkoutTheme.accent)
|
||||||
|
.tabBarMinimizeBehavior(.onScrollDown)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
.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()
|
|
||||||
}
|
}
|
||||||
.onReceive(pub) { (output) in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = phase == .active
|
||||||
|
}
|
||||||
|
.onReceive(pub) { _ in
|
||||||
|
self.tabSelection = 1
|
||||||
|
}
|
||||||
|
.onReceive(logoutPub) { _ in
|
||||||
self.tabSelection = 1
|
self.tabSelection = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func screenDidDisconnect(_ screen: UIScreen) {
|
private func screenDidDisconnect(_ scene: UIWindowScene) {
|
||||||
additionalWindows.removeAll { $0.screen == screen }
|
additionalWindows.removeAll { $0.windowScene == scene }
|
||||||
BridgeModule.shared.isShowingOnExternalDisplay = false
|
BridgeModule.shared.isShowingOnExternalDisplay = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func screenDidConnect(_ screen: UIScreen) {
|
private func screenDidConnect(_ scene: UIWindowScene) {
|
||||||
let window = UIWindow(frame: screen.bounds)
|
let window = UIWindow(windowScene: scene)
|
||||||
|
|
||||||
window.windowScene = UIApplication.shared.connectedScenes
|
|
||||||
.first { ($0 as? UIWindowScene)?.screen == screen }
|
|
||||||
as? UIWindowScene
|
|
||||||
|
|
||||||
let view = ExternalWorkoutDetailView()
|
let view = ExternalWorkoutDetailView()
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
let controller = UIHostingController(rootView: view)
|
let controller = UIHostingController(rootView: view)
|
||||||
|
|||||||
@@ -11,101 +11,105 @@ struct ActionsView: View {
|
|||||||
@ObservedObject var bridgeModule = BridgeModule.shared
|
@ObservedObject var bridgeModule = BridgeModule.shared
|
||||||
var completedWorkout: (() -> Void)?
|
var completedWorkout: (() -> Void)?
|
||||||
var planWorkout: ((Workout) -> Void)?
|
var planWorkout: ((Workout) -> Void)?
|
||||||
|
|
||||||
var workout: Workout
|
var workout: Workout
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
var showAddToCalendar: Bool
|
var showAddToCalendar: Bool
|
||||||
var startWorkoutAction: (() -> Void)
|
var startWorkoutAction: (() -> Void)
|
||||||
|
@State var showCompleteSheet: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
GlassEffectContainer {
|
||||||
if bridgeModule.isInWorkout == false {
|
HStack(spacing: WerkoutTheme.sm) {
|
||||||
Button(action: {
|
if bridgeModule.isInWorkout == false {
|
||||||
bridgeModule.resetCurrentWorkout()
|
|
||||||
dismiss()
|
|
||||||
}, label: {
|
|
||||||
Image(systemName: "xmark.octagon.fill")
|
|
||||||
.font(.title)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
})
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(.red)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
if showAddToCalendar {
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
planWorkout?(workout)
|
bridgeModule.resetCurrentWorkout()
|
||||||
|
dismiss()
|
||||||
}, label: {
|
}, label: {
|
||||||
Image(systemName: "calendar.badge.plus")
|
Image(systemName: "xmark.octagon.fill")
|
||||||
.font(.title)
|
.font(.title)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
})
|
})
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.glassEffect(.regular.interactive())
|
||||||
.background(.blue)
|
.tint(WerkoutTheme.danger)
|
||||||
.foregroundColor(.white)
|
.accessibilityLabel("Close workout")
|
||||||
|
|
||||||
|
if showAddToCalendar {
|
||||||
|
Button(action: {
|
||||||
|
planWorkout?(workout)
|
||||||
|
}, label: {
|
||||||
|
Image(systemName: "calendar.badge.plus")
|
||||||
|
.font(.title)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
})
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
.accessibilityLabel("Plan workout")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
startWorkoutAction()
|
||||||
|
}, label: {
|
||||||
|
Image(systemName: "arrowtriangle.forward.fill")
|
||||||
|
.font(.title)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
})
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.success)
|
||||||
|
.accessibilityLabel("Start workout")
|
||||||
|
} else {
|
||||||
|
Button(action: {
|
||||||
|
showCompleteSheet.toggle()
|
||||||
|
}, label: {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.title)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
})
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
.accessibilityLabel("Complete workout")
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
AudioEngine.shared.playFinished()
|
||||||
|
bridgeModule.pauseWorkout()
|
||||||
|
}, label: {
|
||||||
|
Image(systemName: bridgeModule.isPaused ? "play.circle.fill" : "pause.circle.fill")
|
||||||
|
.font(.title)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
})
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(bridgeModule.isPaused ? WerkoutTheme.success : WerkoutTheme.warning)
|
||||||
|
.accessibilityLabel(bridgeModule.isPaused ? "Resume workout" : "Pause workout")
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
AudioEngine.shared.playFinished()
|
||||||
|
nextExercise()
|
||||||
|
}, label: {
|
||||||
|
Image(systemName: "arrow.forward")
|
||||||
|
.font(.title)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
})
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.success)
|
||||||
|
.accessibilityLabel("Next exercise")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
startWorkoutAction()
|
|
||||||
}, label: {
|
|
||||||
Image(systemName: "arrowtriangle.forward.fill")
|
|
||||||
.font(.title)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
})
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(.green)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
} else {
|
|
||||||
Button(action: {
|
|
||||||
AudioEngine.shared.playFinished()
|
|
||||||
nextExercise()
|
|
||||||
}, label: {
|
|
||||||
Image(systemName: "arrow.forward")
|
|
||||||
.font(.title)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
})
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(.green)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
AudioEngine.shared.playFinished()
|
|
||||||
bridgeModule.pauseWorkout()
|
|
||||||
}, label: {
|
|
||||||
bridgeModule.isPaused ?
|
|
||||||
Image(systemName: "play.circle.fill")
|
|
||||||
.font(.title)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
:
|
|
||||||
Image(systemName: "pause.circle.fill")
|
|
||||||
.font(.title)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
})
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(bridgeModule.isPaused ? .mint : .yellow)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
AudioEngine.shared.playFinished()
|
|
||||||
completedWorkout?()
|
|
||||||
}, label: {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.font(.title)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
})
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(.blue)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, WerkoutTheme.sm)
|
||||||
|
}
|
||||||
|
.alert("Complete Workout", isPresented: $showCompleteSheet) {
|
||||||
|
Button("Complete Workout", role: .destructive) {
|
||||||
|
AudioEngine.shared.playFinished()
|
||||||
|
completedWorkout?()
|
||||||
|
}
|
||||||
|
Button("Back to workout", role: .cancel) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func nextExercise() {
|
func nextExercise() {
|
||||||
bridgeModule.nextExercise()
|
bridgeModule.nextExercise()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
struct ActionsView_Previews: PreviewProvider {
|
struct ActionsView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ActionsView(workout: PreviewData.workout(), showAddToCalendar: true, startWorkoutAction: {})
|
ActionsView(workout: PreviewData.workout(), showAddToCalendar: true, startWorkoutAction: {})
|
||||||
|
|||||||
50
iphone/Werkout_ios/subview/AddSupersetView.swift
Normal file
50
iphone/Werkout_ios/subview/AddSupersetView.swift
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//
|
||||||
|
// AddSupersetView.swift
|
||||||
|
// Werkout_ios
|
||||||
|
//
|
||||||
|
// Created by Trey Tartt on 12/18/24.
|
||||||
|
//
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddSupersetView: View {
|
||||||
|
@ObservedObject var createWorkoutSuperSet: CreateWorkoutSuperSet
|
||||||
|
var viewModel: WorkoutViewModel
|
||||||
|
@Binding var selectedCreateWorkoutSuperSet: CreateWorkoutSuperSet?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView {
|
||||||
|
ForEach(createWorkoutSuperSet.exercises, id: \.id) { createWorkoutExercise in
|
||||||
|
VStack(spacing: WerkoutTheme.sm) {
|
||||||
|
Text(createWorkoutExercise.exercise.name)
|
||||||
|
.font(WerkoutTheme.cardTitle)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
if let side = createWorkoutExercise.exercise.side,
|
||||||
|
side.isEmpty == false {
|
||||||
|
Text(side)
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
CreateExerciseActionsView(workoutExercise: createWorkoutExercise,
|
||||||
|
superset: createWorkoutSuperSet,
|
||||||
|
viewModel: viewModel)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, WerkoutTheme.md)
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minHeight: 220, maxHeight: 320)
|
||||||
|
.tabViewStyle(.page)
|
||||||
|
.background(WerkoutTheme.background)
|
||||||
|
.onAppear {
|
||||||
|
setupAppearance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupAppearance() {
|
||||||
|
UIPageControl.appearance().currentPageIndicatorTintColor = UIColor(WerkoutTheme.accent)
|
||||||
|
UIPageControl.appearance().pageIndicatorTintColor = UIColor(WerkoutTheme.accent).withAlphaComponent(0.25)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,11 +12,14 @@ struct AllEquipmentView: View {
|
|||||||
@State var createWorkoutItemPickerViewModel: CreateWorkoutItemPickerViewModel?
|
@State var createWorkoutItemPickerViewModel: CreateWorkoutItemPickerViewModel?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(spacing: WerkoutTheme.xs) {
|
||||||
if let _ = DataStore.shared.allEquipment {
|
if let _ = DataStore.shared.allEquipment {
|
||||||
Text("Select Equipment")
|
Text("Select Equipment")
|
||||||
.foregroundColor(.cyan)
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
Text("\(selectedEquipment.count) Selected")
|
Text("\(selectedEquipment.count) Selected")
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
|
|||||||
@@ -12,78 +12,118 @@ struct AllExerciseView: View {
|
|||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@State var searchString: String = ""
|
@State var searchString: String = ""
|
||||||
@Binding var filteredExercises: [Exercise]
|
@Binding var filteredExercises: [Exercise]
|
||||||
|
@State private var displayedExercises: [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: BaseURLs.currentBaseURL + "/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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(spacing: WerkoutTheme.sm) {
|
||||||
TextField("Filter", text: $searchString)
|
TextField("Filter", text: $searchString)
|
||||||
.padding()
|
.werkoutTextField()
|
||||||
|
.padding(.horizontal, WerkoutTheme.md)
|
||||||
|
|
||||||
List() {
|
List() {
|
||||||
ForEach(filteredExercises, id: \.self) { exercise in
|
ForEach(displayedExercises, id: \.self) { exercise in
|
||||||
if searchString.isEmpty || (exercise.name.lowercased().contains(searchString.lowercased()) || (exercise.muscleGroups ?? "").lowercased().contains(searchString.lowercased())) {
|
HStack {
|
||||||
HStack {
|
VStack(spacing: WerkoutTheme.xs) {
|
||||||
VStack {
|
Text(exercise.name)
|
||||||
Text(exercise.name)
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
if let side = exercise.side,
|
||||||
|
side.isEmpty == false {
|
||||||
|
Text(side)
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
if exercise.side != nil && !exercise.side!.isEmpty {
|
|
||||||
Text(exercise.side!)
|
|
||||||
.font(.footnote)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exercise.equipmentRequired != nil && !exercise.equipmentRequired!.isEmpty {
|
|
||||||
Text(exercise.spacedEquipmentRequired)
|
|
||||||
.font(.footnote)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exercise.muscleGroups != nil && !exercise.muscleGroups!.isEmpty {
|
|
||||||
Text(exercise.spacedMuscleGroups)
|
|
||||||
.font(.footnote)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
if exercise.equipmentRequired?.isEmpty == false {
|
||||||
selectedExercise(exercise)
|
Text(exercise.spacedEquipmentRequired)
|
||||||
dismiss()
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
Button(action: {
|
|
||||||
videoExercise = exercise
|
if exercise.muscleGroups?.isEmpty == false {
|
||||||
}) {
|
Text(exercise.spacedMuscleGroups)
|
||||||
ZStack {
|
.font(WerkoutTheme.caption)
|
||||||
Circle()
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
.fill(.blue)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.frame(width: 33, height: 33)
|
|
||||||
Image(systemName: "video.fill")
|
|
||||||
.frame(width: 33, height: 33)
|
|
||||||
.foregroundColor(.white )
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(width: 33, height: 33)
|
|
||||||
}
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedExercise(exercise)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
Button(action: {
|
||||||
|
videoExercise = exercise
|
||||||
|
}) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(WerkoutTheme.accent)
|
||||||
|
.frame(width: 33, height: 33)
|
||||||
|
Image(systemName: "video.fill")
|
||||||
|
.frame(width: 33, height: 33)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 33, height: 33)
|
||||||
}
|
}
|
||||||
|
.listRowBackground(WerkoutTheme.surfaceCard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(WerkoutTheme.background)
|
||||||
}
|
}
|
||||||
.sheet(item: $videoExercise) { exercise in
|
.sheet(item: $videoExercise) { exercise in
|
||||||
PlayerView(player: $avPlayer)
|
PlayerView(player: $avPlayer)
|
||||||
.onAppear{
|
.onAppear{
|
||||||
|
avPlayer.isMuted = true
|
||||||
avPlayer.play()
|
avPlayer.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear { applySearch() }
|
||||||
|
.onChange(of: searchString) { _, _ in applySearch() }
|
||||||
|
.onChange(of: filteredExercises) { _, _ in applySearch() }
|
||||||
|
.onDisappear {
|
||||||
|
avPlayer.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySearch() {
|
||||||
|
if searchString.isEmpty {
|
||||||
|
displayedExercises = filteredExercises
|
||||||
|
} else {
|
||||||
|
let query = searchString.lowercased()
|
||||||
|
displayedExercises = filteredExercises.filter { $0.name.lowercased().contains(query) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ struct AllMusclesView: View {
|
|||||||
@State var createWorkoutItemPickerViewModel: CreateWorkoutItemPickerViewModel?
|
@State var createWorkoutItemPickerViewModel: CreateWorkoutItemPickerViewModel?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(spacing: WerkoutTheme.xs) {
|
||||||
if let _ = DataStore.shared.allMuscles {
|
if let _ = DataStore.shared.allMuscles {
|
||||||
Text("Select Muscles")
|
Text("Select Muscles")
|
||||||
.foregroundColor(.cyan)
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
Text("\(selectedMuscles.count) Selected")
|
Text("\(selectedMuscles.count) Selected")
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
//
|
|
||||||
// AllWorkoutPickerView.swift
|
|
||||||
// Werkout_ios
|
|
||||||
//
|
|
||||||
// Created by Trey Tartt on 7/7/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct AllWorkoutPickerView: View {
|
|
||||||
var mainViews: [MainViewTypes]
|
|
||||||
@Binding var selectedSegment: MainViewTypes
|
|
||||||
@StateObject var bridgeModule = BridgeModule.shared
|
|
||||||
var showCurrentWorkout: (() -> Void)
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Picker("", selection: $selectedSegment) {
|
|
||||||
ForEach(mainViews, id: \.self) { viewType in
|
|
||||||
Text(viewType.title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.padding([.top, .leading, .trailing])
|
|
||||||
|
|
||||||
if bridgeModule.isInWorkout {
|
|
||||||
Button(action: {
|
|
||||||
showCurrentWorkout()
|
|
||||||
}, label: {
|
|
||||||
Image(systemName: "figure.strengthtraining.traditional")
|
|
||||||
.padding(.trailing)
|
|
||||||
})
|
|
||||||
.tint(Color("appColor"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,45 +10,47 @@ import SwiftUI
|
|||||||
struct CaloriesBurnedView: View {
|
struct CaloriesBurnedView: View {
|
||||||
@Binding var healthKitWorkoutData: HealthKitWorkoutData?
|
@Binding var healthKitWorkoutData: HealthKitWorkoutData?
|
||||||
let calsBurned: Double
|
let calsBurned: Double
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(spacing: WerkoutTheme.sm) {
|
||||||
HStack {
|
HStack {
|
||||||
HStack {
|
HStack(spacing: WerkoutTheme.sm) {
|
||||||
Image(systemName: "flame.fill")
|
Image(systemName: "flame.fill")
|
||||||
.foregroundColor(.orange)
|
.foregroundStyle(.orange)
|
||||||
.font(.title)
|
.font(.title)
|
||||||
VStack {
|
Text("\(calsBurned, specifier: "%.0f")")
|
||||||
Text("\(calsBurned, specifier: "%.0f")")
|
.font(.system(size: 28, weight: .black, design: .monospaced))
|
||||||
}
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let minHeart = healthKitWorkoutData?.minHeartRate,
|
if let minHeart = healthKitWorkoutData?.minHeartRate,
|
||||||
let maxHeart = healthKitWorkoutData?.maxHeartRate,
|
let maxHeart = healthKitWorkoutData?.maxHeartRate,
|
||||||
let avgHeart = healthKitWorkoutData?.avgHeartRate {
|
let avgHeart = healthKitWorkoutData?.avgHeartRate {
|
||||||
VStack {
|
VStack(spacing: WerkoutTheme.xs) {
|
||||||
HStack {
|
HStack(spacing: WerkoutTheme.sm) {
|
||||||
Image(systemName: "heart")
|
Image(systemName: "heart.fill")
|
||||||
.foregroundColor(.red)
|
.foregroundStyle(WerkoutTheme.danger)
|
||||||
.font(.title)
|
.font(.title)
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(minHeart, specifier: "%.0f")")
|
Text("\(minHeart, specifier: "%.0f")")
|
||||||
Text("-")
|
Text("-")
|
||||||
|
.foregroundStyle(WerkoutTheme.textMuted)
|
||||||
Text("\(maxHeart, specifier: "%.0f")")
|
Text("\(maxHeart, specifier: "%.0f")")
|
||||||
}
|
}
|
||||||
Text("\(avgHeart, specifier: "%.0f")")
|
.font(.system(size: 20, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
Text("avg \(avgHeart, specifier: "%.0f")")
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.werkoutCard()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// CaloriesBurnedView()
|
|
||||||
//}
|
|
||||||
|
|||||||
@@ -8,59 +8,107 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CompletedWorkoutsView: View {
|
struct CompletedWorkoutsView: View {
|
||||||
@State var completedWorkouts: [CompletedWorkout]?
|
@State private var completedWorkouts: [CompletedWorkout]?
|
||||||
@State var showCompletedWorkouts: Bool = false
|
@State private var showCompletedWorkouts: Bool = false
|
||||||
|
@State private var loadError: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if let completedWorkouts = completedWorkouts {
|
if let completedWorkouts = completedWorkouts {
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
|
|
||||||
Text("Workout History:")
|
Text("Workout History:")
|
||||||
|
.font(WerkoutTheme.sectionTitle)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("Number of workouts:")
|
Text("Number of workouts:")
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
Text("\(completedWorkouts.count)")
|
Text("\(completedWorkouts.count)")
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let lastWorkout = completedWorkouts.last {
|
if let lastWorkout = completedWorkouts.last {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Last workout:")
|
Text("Last workout:")
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
Text(lastWorkout.workoutStartTime)
|
Text(lastWorkout.workoutStartTime)
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("View All Workouts", action: {
|
Button("View All Workouts", action: {
|
||||||
showCompletedWorkouts = true
|
showCompletedWorkouts = true
|
||||||
})
|
})
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
.foregroundColor(.blue)
|
.glassEffect(.regular.interactive())
|
||||||
.background(.yellow)
|
.tint(WerkoutTheme.accent)
|
||||||
.cornerRadius(8)
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
Text("loading completed workouts")
|
if let loadError = loadError {
|
||||||
|
Text(loadError)
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.danger)
|
||||||
|
} else {
|
||||||
|
Text("loading completed workouts")
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textMuted)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(WerkoutTheme.md)
|
||||||
|
.background(WerkoutTheme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.cardRadius, style: .continuous))
|
||||||
.onAppear{
|
.onAppear{
|
||||||
fetchCompletedWorkouts()
|
fetchCompletedWorkouts()
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
CompletedWorkoutFetchable().fetch(completion: { result in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
switch result {
|
||||||
|
case .success(let model):
|
||||||
|
self.completedWorkouts = model
|
||||||
|
self.loadError = nil
|
||||||
|
case .failure(let failure):
|
||||||
|
self.loadError = "Unable to load workout history: \(failure.localizedDescription)"
|
||||||
|
}
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showCompletedWorkouts) {
|
.sheet(isPresented: $showCompletedWorkouts) {
|
||||||
if let completedWorkouts = completedWorkouts {
|
if let completedWorkouts = completedWorkouts {
|
||||||
WorkoutHistoryView(completedWorkouts: completedWorkouts)
|
WorkoutHistoryView(completedWorkouts: completedWorkouts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchCompletedWorkouts() {
|
func fetchCompletedWorkouts() {
|
||||||
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)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,17 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CountdownView: View {
|
struct CountdownView: View {
|
||||||
@StateObject var bridgeModule = BridgeModule.shared
|
let currentExerciseDuration: Int?
|
||||||
|
let currentExerciseTimeLeft: Int
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let duration = bridgeModule.currentExerciseInfo.currentExercise?.duration,
|
if let duration = currentExerciseDuration,
|
||||||
duration > 0 {
|
duration > 0 {
|
||||||
HStack {
|
HStack {
|
||||||
if bridgeModule.currentExerciseTimeLeft >= 0 && duration > bridgeModule.currentExerciseTimeLeft {
|
if currentExerciseTimeLeft >= 0 && duration > currentExerciseTimeLeft {
|
||||||
ProgressView(value: Float(bridgeModule.currentExerciseTimeLeft), total: Float(duration))
|
ProgressView(value: Float(currentExerciseTimeLeft), total: Float(duration))
|
||||||
.tint(Color("appColor"))
|
.tint(currentExerciseTimeLeft < 5 ? WerkoutTheme.danger : WerkoutTheme.accent)
|
||||||
|
.animation(.easeInOut, value: currentExerciseTimeLeft < 5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,6 +27,6 @@ struct CountdownView: View {
|
|||||||
|
|
||||||
struct CountdownView_Previews: PreviewProvider {
|
struct CountdownView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
CountdownView()
|
CountdownView(currentExerciseDuration: 30, currentExerciseTimeLeft: 15)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
87
iphone/Werkout_ios/subview/CreateWorkoutSupersetView.swift
Normal file
87
iphone/Werkout_ios/subview/CreateWorkoutSupersetView.swift
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
//
|
||||||
|
// CreateWorkoutSupersetView.swift
|
||||||
|
// Werkout_ios
|
||||||
|
//
|
||||||
|
// Created by Trey Tartt on 12/19/24.
|
||||||
|
//
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CreateWorkoutSupersetView: View {
|
||||||
|
@Binding var selectedCreateWorkoutSuperSet: CreateWorkoutSuperSet?
|
||||||
|
@Binding var showAddExercise: Bool
|
||||||
|
@ObservedObject var superset: CreateWorkoutSuperSet
|
||||||
|
@ObservedObject var viewModel: WorkoutViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section(content: {
|
||||||
|
AddSupersetView(
|
||||||
|
createWorkoutSuperSet: superset,
|
||||||
|
viewModel: viewModel,
|
||||||
|
selectedCreateWorkoutSuperSet: $selectedCreateWorkoutSuperSet)
|
||||||
|
}, header: {
|
||||||
|
VStack(spacing: WerkoutTheme.sm) {
|
||||||
|
HStack {
|
||||||
|
TextField("Superset Title", text: $superset.title)
|
||||||
|
.font(WerkoutTheme.sectionTitle)
|
||||||
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: WerkoutTheme.sm) {
|
||||||
|
HStack {
|
||||||
|
Text("Exercises: \(superset.exercises.count)")
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
selectedCreateWorkoutSuperSet = superset
|
||||||
|
showAddExercise = true
|
||||||
|
}, label: {
|
||||||
|
Image(systemName: "dumbbell.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
|
})
|
||||||
|
.accessibilityLabel("Add exercise")
|
||||||
|
.accessibilityHint("Adds an exercise to this superset")
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
viewModel.delete(superset: superset)
|
||||||
|
}, label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(WerkoutTheme.danger)
|
||||||
|
})
|
||||||
|
.accessibilityLabel("Delete superset")
|
||||||
|
.accessibilityHint("Removes this superset")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
|
|
||||||
|
Stepper(label: {
|
||||||
|
HStack {
|
||||||
|
Text("Rounds: ")
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
|
||||||
|
Text("\(superset.numberOfRounds)")
|
||||||
|
.foregroundColor(superset.numberOfRounds > 0 ? WerkoutTheme.textPrimary : WerkoutTheme.danger)
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
}, onIncrement: {
|
||||||
|
superset.increaseNumberOfRounds()
|
||||||
|
}, onDecrement: {
|
||||||
|
superset.decreaseNumberOfRounds()
|
||||||
|
})
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, WerkoutTheme.xs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,50 +8,55 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ExtCountdownView: View {
|
struct ExtCountdownView: View {
|
||||||
@StateObject var bridgeModule = BridgeModule.shared
|
@ObservedObject var bridgeModule = BridgeModule.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { metrics in
|
GeometryReader { metrics in
|
||||||
VStack {
|
VStack {
|
||||||
if let currenExercise = bridgeModule.currentExerciseInfo.currentExercise {
|
if let currenExercise = bridgeModule.currentWorkoutInfo.currentExercise {
|
||||||
HStack {
|
HStack {
|
||||||
Text(currenExercise.exercise.extName)
|
Text(currenExercise.exercise.extName)
|
||||||
.font(.system(size: 200))
|
.font(.system(size: 200, weight: .black, design: .rounded))
|
||||||
|
.foregroundColor(WerkoutTheme.textPrimary)
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.minimumScaleFactor(0.01)
|
.minimumScaleFactor(0.01)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.frame(height: metrics.size.height * 0.5)
|
.frame(height: metrics.size.height * 0.5)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if let duration = currenExercise.duration,
|
if let duration = currenExercise.duration,
|
||||||
duration > 0 {
|
duration > 0 {
|
||||||
ProgressView(value: Float(bridgeModule.currentExerciseTimeLeft), total: Float(duration))
|
ProgressView(value: Float(bridgeModule.currentExerciseTimeLeft), total: Float(duration))
|
||||||
.scaleEffect(x: 1, y: 6, anchor: .center)
|
.scaleEffect(x: 1, y: 6, anchor: .center)
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
Text("\(bridgeModule.currentExerciseTimeLeft)")
|
Text("\(bridgeModule.currentExerciseTimeLeft)")
|
||||||
.font(Font.system(size: 100))
|
.font(WerkoutTheme.stat)
|
||||||
|
.foregroundColor(WerkoutTheme.accent)
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.minimumScaleFactor(0.01)
|
.minimumScaleFactor(0.01)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.padding(.leading)
|
.padding(.leading)
|
||||||
.padding(.trailing, 100)
|
.padding(.trailing, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let reps = currenExercise.reps,
|
if let reps = currenExercise.reps,
|
||||||
reps > 0 {
|
reps > 0 {
|
||||||
Text(" X \(reps)")
|
Text(" X \(reps)")
|
||||||
.font(Font.system(size: 100))
|
.font(.system(size: 100, weight: .heavy, design: .monospaced))
|
||||||
|
.foregroundColor(WerkoutTheme.textPrimary)
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.minimumScaleFactor(0.01)
|
.minimumScaleFactor(0.01)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let weight = currenExercise.weight,
|
if let weight = currenExercise.weight,
|
||||||
weight > 0 {
|
weight > 0 {
|
||||||
Text(" @ \(weight)")
|
Text(" @ \(weight)")
|
||||||
.font(Font.system(size: 100))
|
.font(.system(size: 100, weight: .heavy, design: .monospaced))
|
||||||
|
.foregroundColor(WerkoutTheme.textSecondary)
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.minimumScaleFactor(0.01)
|
.minimumScaleFactor(0.01)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
struct ExtExerciseList: View {
|
struct ExtExerciseList: View {
|
||||||
var workout: Workout
|
var workout: Workout
|
||||||
var allSupersetExecerciseIndex: Int
|
var allSupersetExecerciseIndex: Int
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let allSupersetExecercise = workout.allSupersetExecercise {
|
if let allSupersetExecercise = workout.allSupersetExecercise {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -21,49 +21,41 @@ struct ExtExerciseList: View {
|
|||||||
HStack {
|
HStack {
|
||||||
if supersetExecerciseIdx == allSupersetExecerciseIndex {
|
if supersetExecerciseIdx == allSupersetExecerciseIndex {
|
||||||
Image(systemName: "figure.run")
|
Image(systemName: "figure.run")
|
||||||
.foregroundColor(Color("appColor"))
|
.foregroundColor(WerkoutTheme.accent)
|
||||||
.font(Font.system(size: 55))
|
.font(Font.system(size: 55))
|
||||||
.minimumScaleFactor(0.01)
|
.minimumScaleFactor(0.01)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(supersetExecercise.exercise.name)
|
Text(supersetExecercise.exercise.name)
|
||||||
.font(Font.system(size: 55))
|
.font(Font.system(size: 55, weight: supersetExecerciseIdx == allSupersetExecerciseIndex ? .bold : .medium))
|
||||||
|
.foregroundColor(supersetExecerciseIdx == allSupersetExecerciseIndex ? WerkoutTheme.textPrimary : WerkoutTheme.textSecondary)
|
||||||
.minimumScaleFactor(0.01)
|
.minimumScaleFactor(0.01)
|
||||||
.lineLimit(3)
|
.lineLimit(3)
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.id(supersetExecerciseIdx)
|
.id(supersetExecerciseIdx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: allSupersetExecerciseIndex, perform: { newValue in
|
.onChange(of: allSupersetExecerciseIndex) {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
proxy.scrollTo(allSupersetExecerciseIndex, anchor: .top)
|
proxy.scrollTo(allSupersetExecerciseIndex, anchor: .top)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
VStack {
|
VStack {
|
||||||
Text("\(allSupersetExecerciseIndex+1)/\(workout.allSupersetExecercise?.count ?? 0)")
|
Text("\(allSupersetExecerciseIndex+1)/\(workout.allSupersetExecercise?.count ?? 0)")
|
||||||
.font(Font.system(size: 55))
|
.font(Font.system(size: 55, weight: .bold, design: .monospaced))
|
||||||
.minimumScaleFactor(0.01)
|
.minimumScaleFactor(0.01)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.padding()
|
.padding()
|
||||||
.bold()
|
.foregroundColor(WerkoutTheme.textPrimary)
|
||||||
.foregroundColor(.white)
|
.glassEffect(.regular.interactive())
|
||||||
.background(
|
.tint(WerkoutTheme.accent)
|
||||||
Capsule()
|
|
||||||
.strokeBorder(Color.black, lineWidth: 0.8)
|
|
||||||
.background(Color(uiColor: UIColor(red: 148/255,
|
|
||||||
green: 0,
|
|
||||||
blue: 211/255,
|
|
||||||
alpha: 0.5)))
|
|
||||||
.clipped()
|
|
||||||
)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
iphone/Werkout_ios/subview/FilterAllView.swift
Normal file
32
iphone/Werkout_ios/subview/FilterAllView.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
//
|
||||||
|
// FilterChip.swift
|
||||||
|
// Werkout_ios
|
||||||
|
//
|
||||||
|
// Created by Trey Tartt on 11/25/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FilterChip: View {
|
||||||
|
let label: String
|
||||||
|
let color: Color
|
||||||
|
let onRemove: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: WerkoutTheme.xs) {
|
||||||
|
Text(label)
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
|
||||||
|
Button(action: onRemove) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(color.opacity(0.7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, WerkoutTheme.sm + 2)
|
||||||
|
.padding(.vertical, WerkoutTheme.xs + 2)
|
||||||
|
.background(color.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,28 +10,27 @@ import SwiftUI
|
|||||||
struct InfoView: View {
|
struct InfoView: View {
|
||||||
@ObservedObject var bridgeModule = BridgeModule.shared
|
@ObservedObject var bridgeModule = BridgeModule.shared
|
||||||
var workout: Workout
|
var workout: Workout
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(alignment: .leading, spacing: WerkoutTheme.sm) {
|
||||||
Text(workout.name)
|
Text(workout.name)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.font(WerkoutTheme.sectionTitle)
|
||||||
.font(.title3)
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
.padding()
|
|
||||||
|
|
||||||
if let desc = workout.description {
|
if let desc = workout.description {
|
||||||
Text(desc)
|
Text(desc)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.font(WerkoutTheme.bodyText)
|
||||||
.font(.body)
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
.padding([.leading, .trailing])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let estimatedTime = workout.estimatedTime {
|
if let estimatedTime = workout.estimatedTime {
|
||||||
Text(estimatedTime.asString(style: .abbreviated))
|
Text(estimatedTime.asString(style: .abbreviated))
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.font(WerkoutTheme.bodyText)
|
||||||
.font(.body)
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
.padding([.leading, .trailing])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,31 +9,37 @@ import SwiftUI
|
|||||||
|
|
||||||
struct Logoutview: View {
|
struct Logoutview: View {
|
||||||
@ObservedObject var userStore = UserStore.shared
|
@ObservedObject var userStore = UserStore.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
GlassEffectContainer {
|
||||||
Button("Logout", action: {
|
HStack {
|
||||||
userStore.logout()
|
Button("Logout", action: {
|
||||||
})
|
userStore.logout()
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
})
|
||||||
.frame(height: 44)
|
.font(.system(size: 16, weight: .bold))
|
||||||
.foregroundColor(.white)
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
.background(.red)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.cornerRadius(8)
|
.frame(height: 44)
|
||||||
.padding()
|
.glassEffect(.regular.interactive())
|
||||||
.frame(maxWidth: .infinity)
|
.tint(WerkoutTheme.danger)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
Button(action: {
|
.padding()
|
||||||
userStore.refreshUserData()
|
.frame(maxWidth: .infinity)
|
||||||
}, label: {
|
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
Button(action: {
|
||||||
})
|
userStore.refreshUserData()
|
||||||
.frame(width: 44, height: 44)
|
}, label: {
|
||||||
.foregroundColor(.white)
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
.background(.green)
|
})
|
||||||
.cornerRadius(8)
|
.font(.title)
|
||||||
.padding()
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(width: 44, height: 44)
|
||||||
|
.glassEffect(.regular.interactive())
|
||||||
|
.tint(WerkoutTheme.success)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,21 +9,26 @@ import SwiftUI
|
|||||||
|
|
||||||
struct NameView: View {
|
struct NameView: View {
|
||||||
@ObservedObject var userStore = UserStore.shared
|
@ObservedObject var userStore = UserStore.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let registeredUser = userStore.registeredUser {
|
if let registeredUser = userStore.registeredUser {
|
||||||
if let nickName = registeredUser.nickName {
|
if let nickName = registeredUser.nickName {
|
||||||
Text(nickName)
|
Text(nickName)
|
||||||
.font(.title)
|
.font(WerkoutTheme.heroTitle)
|
||||||
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(registeredUser.firstName ?? "-")
|
Text(registeredUser.firstName ?? "-")
|
||||||
Text(registeredUser.lastName ?? "-")
|
Text(registeredUser.lastName ?? "-")
|
||||||
}
|
}
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
|
|
||||||
if let email = registeredUser.email {
|
if let email = registeredUser.email {
|
||||||
Text(email)
|
Text(email)
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import SwiftUI
|
|||||||
struct OvalTextFieldStyle: TextFieldStyle {
|
struct OvalTextFieldStyle: TextFieldStyle {
|
||||||
func _body(configuration: TextField<Self._Label>) -> some View {
|
func _body(configuration: TextField<Self._Label>) -> some View {
|
||||||
configuration
|
configuration
|
||||||
.padding(10)
|
.padding(12)
|
||||||
.background(LinearGradient(gradient: Gradient(colors: [Color(uiColor: .secondarySystemBackground), Color(uiColor: .secondarySystemBackground)]), startPoint: .topLeading, endPoint: .bottomTrailing))
|
.background(WerkoutTheme.surfaceCard)
|
||||||
.cornerRadius(20)
|
.clipShape(RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous))
|
||||||
.shadow(color: Color(red: 120/255, green: 120/255, blue: 120/255, opacity: 1), radius: 5)
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: WerkoutTheme.buttonRadius, style: .continuous)
|
||||||
|
.strokeBorder(WerkoutTheme.accent.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,47 +10,56 @@ import SwiftUI
|
|||||||
struct PlannedWorkoutView: View {
|
struct PlannedWorkoutView: View {
|
||||||
let workouts: [PlannedWorkout]
|
let workouts: [PlannedWorkout]
|
||||||
@Binding var selectedPlannedWorkout: Workout?
|
@Binding var selectedPlannedWorkout: Workout?
|
||||||
|
|
||||||
|
private var sortedWorkouts: [PlannedWorkout] {
|
||||||
|
workouts.sorted(by: { $0.onDate < $1.onDate })
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
ScrollView {
|
||||||
ForEach(workouts, id:\.workout.name) { plannedWorkout in
|
LazyVStack(spacing: WerkoutTheme.sm) {
|
||||||
HStack {
|
ForEach(sortedWorkouts, id: \.id) { plannedWorkout in
|
||||||
VStack(alignment: .leading) {
|
Button(action: {
|
||||||
Text(plannedWorkout.onDate.plannedDate?.weekDay ?? "-")
|
|
||||||
.font(.title)
|
|
||||||
|
|
||||||
Text(plannedWorkout.onDate.plannedDate?.monthString ?? "-")
|
|
||||||
.font(.title)
|
|
||||||
|
|
||||||
Text(plannedWorkout.onDate.plannedDate?.dateString ?? "-")
|
|
||||||
.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)
|
|
||||||
|
|
||||||
Text(plannedWorkout.onDate)
|
|
||||||
.font(.footnote)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
selectedPlannedWorkout = plannedWorkout.workout
|
selectedPlannedWorkout = plannedWorkout.workout
|
||||||
}
|
}, label: {
|
||||||
|
HStack(spacing: WerkoutTheme.md) {
|
||||||
|
VStack(spacing: WerkoutTheme.xs) {
|
||||||
|
Text(plannedWorkout.onDate.plannedDate?.weekDay ?? "-")
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
|
|
||||||
|
Text(plannedWorkout.onDate.plannedDate?.dateString ?? "-")
|
||||||
|
.font(.system(size: 28, weight: .black, design: .monospaced))
|
||||||
|
.foregroundStyle(WerkoutTheme.accent)
|
||||||
|
|
||||||
|
Text(plannedWorkout.onDate.plannedDate?.monthString ?? "-")
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
|
}
|
||||||
|
.frame(width: 60)
|
||||||
|
|
||||||
|
WerkoutTheme.divider.frame(width: 0.5)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: WerkoutTheme.xs) {
|
||||||
|
Text(plannedWorkout.workout.name)
|
||||||
|
.font(WerkoutTheme.cardTitle)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
|
|
||||||
|
Text(plannedWorkout.workout.description ?? "")
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.werkoutCard()
|
||||||
|
})
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Open planned workout \(plannedWorkout.workout.name)")
|
||||||
|
.accessibilityHint("Shows workout details")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, WerkoutTheme.sm)
|
||||||
}
|
}
|
||||||
|
.scrollEdgeEffectStyle(.soft, for: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// PlannedWorkoutView()
|
|
||||||
//}
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -45,6 +46,7 @@ class PlayerUIView: UIView {
|
|||||||
private func playerSetup(player: AVPlayer) {
|
private func playerSetup(player: AVPlayer) {
|
||||||
playerLayer.player = player
|
playerLayer.player = player
|
||||||
player.actionAtItemEnd = .none
|
player.actionAtItemEnd = .none
|
||||||
|
player.isMuted = true
|
||||||
layer.addSublayer(playerLayer)
|
layer.addSublayer(playerLayer)
|
||||||
|
|
||||||
self.setObserver()
|
self.setObserver()
|
||||||
@@ -61,6 +63,7 @@ class PlayerUIView: UIView {
|
|||||||
if let playerItem = notification.object as? AVPlayerItem {
|
if let playerItem = notification.object as? AVPlayerItem {
|
||||||
playerItem.seek(to: .zero, completionHandler: nil)
|
playerItem.seek(to: .zero, completionHandler: nil)
|
||||||
self.playerLayer.player?.play()
|
self.playerLayer.player?.play()
|
||||||
|
self.playerLayer.player?.isMuted = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,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 {
|
||||||
|
|||||||
@@ -9,33 +9,28 @@ import SwiftUI
|
|||||||
|
|
||||||
struct RateWorkoutView: View {
|
struct RateWorkoutView: View {
|
||||||
@Binding var difficulty: Float
|
@Binding var difficulty: Float
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
Divider()
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("No Rate")
|
Text("No Rate")
|
||||||
.foregroundColor(.black)
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.textMuted)
|
||||||
Text("Easy")
|
Text("Easy")
|
||||||
.foregroundColor(.green)
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.success)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Death")
|
Text("Death")
|
||||||
.foregroundColor(.red)
|
.font(WerkoutTheme.caption)
|
||||||
|
.foregroundStyle(WerkoutTheme.danger)
|
||||||
}
|
}
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
LinearGradient(
|
|
||||||
gradient: Gradient(colors: [.black, .green, .red]),
|
|
||||||
startPoint: .leading,
|
|
||||||
endPoint: .trailing
|
|
||||||
)
|
|
||||||
.mask(Slider(value: $difficulty, in: 0...5, step: 1))
|
|
||||||
|
|
||||||
// Dummy replicated slider, to allow sliding
|
|
||||||
Slider(value: $difficulty, in: 0...5, step: 1)
|
Slider(value: $difficulty, in: 0...5, step: 1)
|
||||||
.opacity(0.05) // Opacity is the trick here.
|
.tint(WerkoutTheme.accent)
|
||||||
.accentColor(.clear)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ struct ShowNextUpView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Toggle(isOn: $extShowNextVideo, label: {
|
Toggle(isOn: $extShowNextVideo, label: {
|
||||||
Text("Show next up video")
|
Text("Show next up video")
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
})
|
})
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ struct ThotPreferenceView: View {
|
|||||||
@AppStorage(Constants.phoneThotStyle) private var phoneThotStyle: ThotStyle = .never
|
@AppStorage(Constants.phoneThotStyle) private var phoneThotStyle: ThotStyle = .never
|
||||||
@AppStorage(Constants.extThotStyle) private var extThotStyle: ThotStyle = .never
|
@AppStorage(Constants.extThotStyle) private var extThotStyle: ThotStyle = .never
|
||||||
@AppStorage(Constants.thotGenderOption) private var thotGenderOption: String = "female"
|
@AppStorage(Constants.thotGenderOption) private var thotGenderOption: String = "female"
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if userStore.registeredUser?.NSFWValue ?? false {
|
if userStore.registeredUser?.NSFWValue ?? false {
|
||||||
Group {
|
Group {
|
||||||
Text("Phone THOT Style:")
|
Text("Phone THOT Style:")
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
Picker("Phone THOT Style:", selection: $phoneThotStyle) {
|
Picker("Phone THOT Style:", selection: $phoneThotStyle) {
|
||||||
ForEach(ThotStyle.allCases, id: \.self) { style in
|
ForEach(ThotStyle.allCases, id: \.self) { style in
|
||||||
Text(style.stringValue())
|
Text(style.stringValue())
|
||||||
@@ -24,10 +26,14 @@ struct ThotPreferenceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
|
|
||||||
Text("External THOT Style:")
|
Text("External THOT Style:")
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
Picker("External THOT Style:", selection: $extThotStyle) {
|
Picker("External THOT Style:", selection: $extThotStyle) {
|
||||||
ForEach(ThotStyle.allCases, id: \.self) { style in
|
ForEach(ThotStyle.allCases, id: \.self) { style in
|
||||||
Text(style.stringValue())
|
Text(style.stringValue())
|
||||||
@@ -35,10 +41,14 @@ struct ThotPreferenceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
|
|
||||||
if let genderOptions = DataStore.shared.nsfwGenderOptions {
|
if let genderOptions = DataStore.shared.nsfwGenderOptions {
|
||||||
Divider()
|
Divider()
|
||||||
|
.overlay(WerkoutTheme.divider)
|
||||||
Text("Video Gender:")
|
Text("Video Gender:")
|
||||||
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
Picker("Video Gender:", selection: $thotGenderOption) {
|
Picker("Video Gender:", selection: $thotGenderOption) {
|
||||||
ForEach(genderOptions, id: \.self) { option in
|
ForEach(genderOptions, id: \.self) { option in
|
||||||
Text(option.capitalized)
|
Text(option.capitalized)
|
||||||
@@ -46,6 +56,7 @@ struct ThotPreferenceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
|
.tint(WerkoutTheme.accent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,20 @@ import SwiftUI
|
|||||||
|
|
||||||
struct TitleView: View {
|
struct TitleView: View {
|
||||||
@ObservedObject var bridgeModule = BridgeModule.shared
|
@ObservedObject var bridgeModule = BridgeModule.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
if let workout = bridgeModule.currentExerciseInfo.workout {
|
if let workout = bridgeModule.currentWorkoutInfo.workout {
|
||||||
Text(workout.name)
|
Text(workout.name)
|
||||||
.font(Font.system(size: 100))
|
.font(.system(size: 100, weight: .black, design: .rounded))
|
||||||
|
.foregroundColor(WerkoutTheme.textPrimary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
if bridgeModule.currentWorkoutRunTimeInSeconds > -1 {
|
if bridgeModule.currentWorkoutRunTimeInSeconds > -1 {
|
||||||
Text("\(bridgeModule.currentWorkoutRunTimeInSeconds)")
|
Text("\(bridgeModule.currentWorkoutRunTimeInSeconds)")
|
||||||
.font(Font.system(size: 100))
|
.font(WerkoutTheme.stat)
|
||||||
|
.foregroundColor(WerkoutTheme.accent)
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
.padding(.trailing, 100)
|
.padding(.trailing, 100)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,25 +9,20 @@ import SwiftUI
|
|||||||
|
|
||||||
struct WorkoutInfoView: View {
|
struct WorkoutInfoView: View {
|
||||||
let workout: Workout
|
let workout: Workout
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(alignment: .leading, spacing: WerkoutTheme.sm) {
|
||||||
Text(workout.name)
|
Text(workout.name)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.font(WerkoutTheme.sectionTitle)
|
||||||
.font(.title3)
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
.padding(.top
|
|
||||||
)
|
|
||||||
if let desc = workout.description {
|
if let desc = workout.description {
|
||||||
Text(desc)
|
Text(desc)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.font(WerkoutTheme.bodyText)
|
||||||
.font(.body)
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
.padding(.top)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// WorkoutInfoView()
|
|
||||||
//}
|
|
||||||
|
|||||||
@@ -9,58 +9,47 @@ import SwiftUI
|
|||||||
|
|
||||||
struct WorkoutOverviewView: View {
|
struct WorkoutOverviewView: View {
|
||||||
let workout: Workout
|
let workout: Workout
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack(alignment: .leading, spacing: WerkoutTheme.xs) {
|
||||||
HStack {
|
Text(workout.name)
|
||||||
VStack {
|
.font(WerkoutTheme.cardTitle)
|
||||||
Text(workout.name)
|
.foregroundStyle(WerkoutTheme.textPrimary)
|
||||||
.font(.title2)
|
.lineLimit(1)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
|
if let description = workout.description, !description.isEmpty {
|
||||||
Text(workout.description ?? "")
|
Text(description)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.font(WerkoutTheme.bodyText)
|
||||||
|
.foregroundStyle(WerkoutTheme.textSecondary)
|
||||||
if let estimatedTime = workout.estimatedTime {
|
.lineLimit(2)
|
||||||
Text("Time: " + estimatedTime.asString(style: .abbreviated))
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
HStack(spacing: 12) {
|
||||||
|
if let estimatedTime = workout.estimatedTime {
|
||||||
if let createdAt = workout.createdAt {
|
Label(estimatedTime.asString(style: .abbreviated), systemImage: "clock")
|
||||||
Text(createdAt, style: .date)
|
|
||||||
.font(.footnote)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let exerciseCount = workout.exercise_count {
|
if let exerciseCount = workout.exercise_count {
|
||||||
VStack {
|
Label("\(exerciseCount) exercises", systemImage: "list.bullet")
|
||||||
Text("\(exerciseCount)")
|
}
|
||||||
.font(.body.bold())
|
|
||||||
Text("exercises")
|
if let muscles = workout.muscles, !muscles.isEmpty {
|
||||||
.font(.footnote)
|
Label(muscleSummary(muscles), systemImage: "figure.strengthtraining.traditional")
|
||||||
.foregroundColor(Color(uiColor: .systemGray2))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.font(WerkoutTheme.caption)
|
||||||
if let muscles = workout.muscles,
|
.foregroundStyle(WerkoutTheme.textMuted)
|
||||||
muscles.joined(separator: ", ").count > 0{
|
|
||||||
Divider()
|
|
||||||
Text(muscles.joined(separator: ", "))
|
|
||||||
.font(.footnote)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let equipment = workout.equipment,
|
|
||||||
equipment.joined(separator: ", ").count > 0 {
|
|
||||||
Divider()
|
|
||||||
Text(equipment.joined(separator: ", "))
|
|
||||||
.font(.footnote)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color(uiColor: .secondarySystemBackground))
|
.werkoutCard()
|
||||||
.cornerRadius(15)
|
}
|
||||||
|
|
||||||
|
private func muscleSummary(_ muscles: [String]) -> String {
|
||||||
|
guard let first = muscles.first else { return "" }
|
||||||
|
if muscles.count <= 2 {
|
||||||
|
return muscles.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
return "\(first), \(muscles[1]) +\(muscles.count - 2)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user