Compare commits
4 Commits
13313a32d3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a881e841b | |||
| 8b6250e4d6 | |||
| c65040e756 | |||
| 461c66a093 |
@@ -10,7 +10,10 @@
|
|||||||
"Bash(xcrun simctl install:*)",
|
"Bash(xcrun simctl install:*)",
|
||||||
"Bash(xcrun simctl launch:*)",
|
"Bash(xcrun simctl launch:*)",
|
||||||
"Bash(xcrun simctl get_app_container:*)",
|
"Bash(xcrun simctl get_app_container:*)",
|
||||||
"Bash(log show:*)"
|
"Bash(log show:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git push:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images": [
|
||||||
{
|
{
|
||||||
"idiom" : "universal",
|
"filename": "icon-1024.png",
|
||||||
"platform" : "watchos",
|
"idiom": "universal",
|
||||||
"size" : "1024x1024"
|
"platform": "watchos",
|
||||||
|
"size": "1024x1024"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info": {
|
||||||
"author" : "xcode",
|
"author": "xcode",
|
||||||
"version" : 1
|
"version": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 34 KiB |
455
Workouts Watch App/Connectivity/WatchConnectivityManager.swift
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
//
|
||||||
|
// WatchConnectivityManager.swift
|
||||||
|
// Workouts Watch App
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
class WatchConnectivityManager: NSObject, ObservableObject {
|
||||||
|
static let shared = WatchConnectivityManager()
|
||||||
|
|
||||||
|
private var session: WCSession?
|
||||||
|
private var viewContext: NSManagedObjectContext?
|
||||||
|
|
||||||
|
@Published var lastSyncDate: Date?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
if WCSession.isSupported() {
|
||||||
|
session = WCSession.default
|
||||||
|
session?.delegate = self
|
||||||
|
session?.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setViewContext(_ context: NSManagedObjectContext) {
|
||||||
|
self.viewContext = context
|
||||||
|
|
||||||
|
// Process any pending application context
|
||||||
|
if let session = session, !session.receivedApplicationContext.isEmpty {
|
||||||
|
processApplicationContext(session.receivedApplicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Send Data to iOS
|
||||||
|
|
||||||
|
func syncToiOS() {
|
||||||
|
guard let session = session else {
|
||||||
|
print("[WC-Watch] No WCSession")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[WC-Watch] Syncing to iOS - state: \(session.activationState.rawValue), reachable: \(session.isReachable)")
|
||||||
|
|
||||||
|
guard session.activationState == .activated else {
|
||||||
|
print("[WC-Watch] Session not activated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let context = viewContext else {
|
||||||
|
print("[WC-Watch] No view context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.perform {
|
||||||
|
do {
|
||||||
|
let workoutsData = try self.encodeAllWorkouts(context: context)
|
||||||
|
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"type": "syncFromWatch",
|
||||||
|
"workouts": workoutsData,
|
||||||
|
"timestamp": Date().timeIntervalSince1970
|
||||||
|
]
|
||||||
|
|
||||||
|
if session.isReachable {
|
||||||
|
session.sendMessage(payload, replyHandler: nil) { error in
|
||||||
|
print("[WC-Watch] Failed to send sync: \(error)")
|
||||||
|
}
|
||||||
|
print("[WC-Watch] Sent \(workoutsData.count) workouts to iOS via message")
|
||||||
|
} else {
|
||||||
|
// Use transferUserInfo for background delivery
|
||||||
|
session.transferUserInfo(payload)
|
||||||
|
print("[WC-Watch] Queued \(workoutsData.count) workouts to iOS via transferUserInfo")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("[WC-Watch] Failed to encode data: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeAllWorkouts(context: NSManagedObjectContext) throws -> [[String: Any]] {
|
||||||
|
let request = Workout.fetchRequest()
|
||||||
|
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
|
||||||
|
let workouts = try context.fetch(request)
|
||||||
|
return workouts.map { encodeWorkout($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeWorkout(_ workout: Workout) -> [String: Any] {
|
||||||
|
var data: [String: Any] = [
|
||||||
|
"start": workout.start.timeIntervalSince1970,
|
||||||
|
"status": workout.status.rawValue
|
||||||
|
]
|
||||||
|
|
||||||
|
if let end = workout.end {
|
||||||
|
data["end"] = end.timeIntervalSince1970
|
||||||
|
}
|
||||||
|
|
||||||
|
if let split = workout.split {
|
||||||
|
data["splitName"] = split.name
|
||||||
|
}
|
||||||
|
|
||||||
|
data["logs"] = workout.logsArray.map { encodeWorkoutLog($0) }
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeWorkoutLog(_ log: WorkoutLog) -> [String: Any] {
|
||||||
|
var data: [String: Any] = [
|
||||||
|
"exerciseName": log.exerciseName,
|
||||||
|
"order": log.order,
|
||||||
|
"sets": log.sets,
|
||||||
|
"reps": log.reps,
|
||||||
|
"weight": log.weight,
|
||||||
|
"status": log.status.rawValue,
|
||||||
|
"currentStateIndex": log.currentStateIndex,
|
||||||
|
"completed": log.completed,
|
||||||
|
"loadType": log.loadType
|
||||||
|
]
|
||||||
|
|
||||||
|
if let duration = log.duration {
|
||||||
|
data["duration"] = duration.timeIntervalSince1970
|
||||||
|
}
|
||||||
|
|
||||||
|
if let notes = log.notes {
|
||||||
|
data["notes"] = notes
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Request Sync from iOS
|
||||||
|
|
||||||
|
func requestSync() {
|
||||||
|
guard let session = session else {
|
||||||
|
print("[WC-Watch] No WCSession")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[WC-Watch] Session state: \(session.activationState.rawValue), reachable: \(session.isReachable)")
|
||||||
|
|
||||||
|
guard session.isReachable else {
|
||||||
|
print("[WC-Watch] iPhone not reachable, checking pending context...")
|
||||||
|
// Try to process any pending application context
|
||||||
|
if !session.receivedApplicationContext.isEmpty {
|
||||||
|
print("[WC-Watch] Found pending context, processing...")
|
||||||
|
processApplicationContext(session.receivedApplicationContext)
|
||||||
|
} else {
|
||||||
|
print("[WC-Watch] No pending context")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.sendMessage(["type": "requestSync"], replyHandler: nil) { error in
|
||||||
|
print("[WC-Watch] Failed to request sync: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Process Incoming Data
|
||||||
|
|
||||||
|
private func processApplicationContext(_ context: [String: Any]) {
|
||||||
|
guard let viewContext = viewContext else {
|
||||||
|
print("View context not set")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewContext.perform {
|
||||||
|
do {
|
||||||
|
// Process splits first (workouts reference them)
|
||||||
|
if let splitsData = context["splits"] as? [[String: Any]] {
|
||||||
|
// Get all split names from iOS
|
||||||
|
let iosSplitNames = Set(splitsData.compactMap { $0["name"] as? String })
|
||||||
|
|
||||||
|
// Delete splits not on iOS
|
||||||
|
let existingSplits = (try? viewContext.fetch(Split.fetchRequest())) ?? []
|
||||||
|
for split in existingSplits {
|
||||||
|
if !iosSplitNames.contains(split.name) {
|
||||||
|
viewContext.delete(split)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for splitData in splitsData {
|
||||||
|
self.importSplit(splitData, context: viewContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process workouts
|
||||||
|
if let workoutsData = context["workouts"] as? [[String: Any]] {
|
||||||
|
// Get all workout start dates from iOS
|
||||||
|
let iosStartDates = Set(workoutsData.compactMap { $0["start"] as? TimeInterval })
|
||||||
|
|
||||||
|
// Delete workouts not on iOS
|
||||||
|
let existingWorkouts = (try? viewContext.fetch(Workout.fetchRequest())) ?? []
|
||||||
|
for workout in existingWorkouts {
|
||||||
|
let startInterval = workout.start.timeIntervalSince1970
|
||||||
|
// Check if this workout exists on iOS (within 1 second tolerance)
|
||||||
|
let existsOnIOS = iosStartDates.contains { abs($0 - startInterval) < 1 }
|
||||||
|
if !existsOnIOS {
|
||||||
|
viewContext.delete(workout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for workoutData in workoutsData {
|
||||||
|
self.importWorkout(workoutData, context: viewContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try viewContext.save()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.lastSyncDate = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Successfully imported data from iPhone")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("Failed to import data: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Import Methods
|
||||||
|
|
||||||
|
private func importSplit(_ data: [String: Any], context: NSManagedObjectContext) {
|
||||||
|
guard let idString = data["id"] as? String,
|
||||||
|
let name = data["name"] as? String else { return }
|
||||||
|
|
||||||
|
// Find existing or create new
|
||||||
|
let split = findOrCreateSplit(idString: idString, name: name, context: context)
|
||||||
|
|
||||||
|
split.name = name
|
||||||
|
split.color = data["color"] as? String ?? "blue"
|
||||||
|
split.systemImage = data["systemImage"] as? String ?? "dumbbell.fill"
|
||||||
|
split.order = Int32(data["order"] as? Int ?? 0)
|
||||||
|
|
||||||
|
// Import exercises
|
||||||
|
if let exercisesData = data["exercises"] as? [[String: Any]] {
|
||||||
|
// Get all exercise names from iOS
|
||||||
|
let iosExerciseNames = Set(exercisesData.compactMap { $0["name"] as? String })
|
||||||
|
|
||||||
|
// Delete exercises not on iOS
|
||||||
|
for exercise in split.exercisesArray {
|
||||||
|
if !iosExerciseNames.contains(exercise.name) {
|
||||||
|
context.delete(exercise)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import/update exercises from iOS
|
||||||
|
for exerciseData in exercisesData {
|
||||||
|
importExercise(exerciseData, split: split, context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func importExercise(_ data: [String: Any], split: Split, context: NSManagedObjectContext) {
|
||||||
|
guard let idString = data["id"] as? String,
|
||||||
|
let name = data["name"] as? String else { return }
|
||||||
|
|
||||||
|
let exercise = findOrCreateExercise(idString: idString, name: name, split: split, context: context)
|
||||||
|
|
||||||
|
exercise.name = name
|
||||||
|
exercise.order = Int32(data["order"] as? Int ?? 0)
|
||||||
|
exercise.sets = Int32(data["sets"] as? Int ?? 3)
|
||||||
|
exercise.reps = Int32(data["reps"] as? Int ?? 10)
|
||||||
|
exercise.weight = Int32(data["weight"] as? Int ?? 0)
|
||||||
|
exercise.loadType = Int32(data["loadType"] as? Int ?? 1)
|
||||||
|
|
||||||
|
if let durationInterval = data["duration"] as? TimeInterval {
|
||||||
|
exercise.duration = Date(timeIntervalSince1970: durationInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
exercise.split = split
|
||||||
|
}
|
||||||
|
|
||||||
|
private func importWorkout(_ data: [String: Any], context: NSManagedObjectContext) {
|
||||||
|
guard let idString = data["id"] as? String,
|
||||||
|
let startInterval = data["start"] as? TimeInterval else { return }
|
||||||
|
|
||||||
|
let startDate = Date(timeIntervalSince1970: startInterval)
|
||||||
|
let workout = findOrCreateWorkout(idString: idString, startDate: startDate, context: context)
|
||||||
|
|
||||||
|
workout.start = startDate
|
||||||
|
|
||||||
|
if let endInterval = data["end"] as? TimeInterval {
|
||||||
|
workout.end = Date(timeIntervalSince1970: endInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let statusRaw = data["status"] as? String,
|
||||||
|
let status = WorkoutStatus(rawValue: statusRaw) {
|
||||||
|
workout.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link to split
|
||||||
|
if let splitName = data["splitName"] as? String {
|
||||||
|
workout.split = findSplitByName(splitName, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import logs
|
||||||
|
if let logsData = data["logs"] as? [[String: Any]] {
|
||||||
|
// Get all exercise names from iOS
|
||||||
|
let iosExerciseNames = Set(logsData.compactMap { $0["exerciseName"] as? String })
|
||||||
|
|
||||||
|
// Delete logs not on iOS
|
||||||
|
for log in workout.logsArray {
|
||||||
|
if !iosExerciseNames.contains(log.exerciseName) {
|
||||||
|
context.delete(log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import/update logs from iOS
|
||||||
|
for logData in logsData {
|
||||||
|
importWorkoutLog(logData, workout: workout, context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func importWorkoutLog(_ data: [String: Any], workout: Workout, context: NSManagedObjectContext) {
|
||||||
|
guard let idString = data["id"] as? String,
|
||||||
|
let exerciseName = data["exerciseName"] as? String else { return }
|
||||||
|
|
||||||
|
let log = findOrCreateWorkoutLog(idString: idString, exerciseName: exerciseName, workout: workout, context: context)
|
||||||
|
|
||||||
|
log.exerciseName = exerciseName
|
||||||
|
log.date = Date(timeIntervalSince1970: data["date"] as? TimeInterval ?? Date().timeIntervalSince1970)
|
||||||
|
log.order = Int32(data["order"] as? Int ?? 0)
|
||||||
|
log.sets = Int32(data["sets"] as? Int ?? 3)
|
||||||
|
log.reps = Int32(data["reps"] as? Int ?? 10)
|
||||||
|
log.weight = Int32(data["weight"] as? Int ?? 0)
|
||||||
|
log.currentStateIndex = Int32(data["currentStateIndex"] as? Int ?? 0)
|
||||||
|
log.completed = data["completed"] as? Bool ?? false
|
||||||
|
log.loadType = Int32(data["loadType"] as? Int ?? 1)
|
||||||
|
|
||||||
|
if let statusRaw = data["status"] as? String,
|
||||||
|
let status = WorkoutStatus(rawValue: statusRaw) {
|
||||||
|
log.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
if let durationInterval = data["duration"] as? TimeInterval {
|
||||||
|
log.duration = Date(timeIntervalSince1970: durationInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.notes = data["notes"] as? String
|
||||||
|
log.workout = workout
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Find or Create Helpers
|
||||||
|
|
||||||
|
private func findOrCreateSplit(idString: String, name: String, context: NSManagedObjectContext) -> Split {
|
||||||
|
// Try to find by name first (more reliable than object ID across devices)
|
||||||
|
let request = Split.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "name == %@", name)
|
||||||
|
request.fetchLimit = 1
|
||||||
|
|
||||||
|
if let existing = try? context.fetch(request).first {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
return Split(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findOrCreateExercise(idString: String, name: String, split: Split, context: NSManagedObjectContext) -> Exercise {
|
||||||
|
// Find by name within split
|
||||||
|
if let existing = split.exercisesArray.first(where: { $0.name == name }) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
return Exercise(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findOrCreateWorkout(idString: String, startDate: Date, context: NSManagedObjectContext) -> Workout {
|
||||||
|
// Find by start date (should be unique per workout)
|
||||||
|
let request = Workout.fetchRequest()
|
||||||
|
// Match within 1 second to account for any floating point differences
|
||||||
|
let startInterval = startDate.timeIntervalSince1970
|
||||||
|
request.predicate = NSPredicate(
|
||||||
|
format: "start >= %@ AND start <= %@",
|
||||||
|
Date(timeIntervalSince1970: startInterval - 1) as NSDate,
|
||||||
|
Date(timeIntervalSince1970: startInterval + 1) as NSDate
|
||||||
|
)
|
||||||
|
request.fetchLimit = 1
|
||||||
|
|
||||||
|
if let existing = try? context.fetch(request).first {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
return Workout(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findOrCreateWorkoutLog(idString: String, exerciseName: String, workout: Workout, context: NSManagedObjectContext) -> WorkoutLog {
|
||||||
|
// Find existing log in this workout with same exercise name
|
||||||
|
if let existing = workout.logsArray.first(where: { $0.exerciseName == exerciseName }) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
return WorkoutLog(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findSplitByName(_ name: String, context: NSManagedObjectContext) -> Split? {
|
||||||
|
let request = Split.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "name == %@", name)
|
||||||
|
request.fetchLimit = 1
|
||||||
|
return try? context.fetch(request).first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WCSessionDelegate
|
||||||
|
|
||||||
|
extension WatchConnectivityManager: WCSessionDelegate {
|
||||||
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||||
|
if let error = error {
|
||||||
|
print("[WC-Watch] Activation failed: \(error)")
|
||||||
|
} else {
|
||||||
|
print("[WC-Watch] Activated with state: \(activationState.rawValue)")
|
||||||
|
|
||||||
|
// Check for any pending context
|
||||||
|
let context = session.receivedApplicationContext
|
||||||
|
print("[WC-Watch] Pending context keys: \(context.keys)")
|
||||||
|
if !context.isEmpty {
|
||||||
|
print("[WC-Watch] Processing pending context...")
|
||||||
|
processApplicationContext(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive application context updates
|
||||||
|
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||||
|
print("[WC-Watch] Received application context with keys: \(applicationContext.keys)")
|
||||||
|
if let workouts = applicationContext["workouts"] as? [[String: Any]] {
|
||||||
|
print("[WC-Watch] Contains \(workouts.count) workouts")
|
||||||
|
}
|
||||||
|
processApplicationContext(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive immediate messages
|
||||||
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||||
|
if let type = message["type"] as? String {
|
||||||
|
switch type {
|
||||||
|
case "workoutUpdate":
|
||||||
|
if let workoutData = message["workout"] as? [String: Any],
|
||||||
|
let context = viewContext {
|
||||||
|
context.perform {
|
||||||
|
self.importWorkout(workoutData, context: context)
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,13 +15,7 @@ struct ContentView: View {
|
|||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
WorkoutLogsView()
|
||||||
Image(systemName: "dumbbell.fill")
|
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
Text("Workouts")
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,29 @@ public class Exercise: NSManagedObject, Identifiable {
|
|||||||
get { LoadType(rawValue: Int(loadType)) ?? .weight }
|
get { LoadType(rawValue: Int(loadType)) ?? .weight }
|
||||||
set { loadType = Int32(newValue.rawValue) }
|
set { loadType = Int32(newValue.rawValue) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Duration helpers for minutes/seconds conversion
|
||||||
|
var durationMinutes: Int {
|
||||||
|
get {
|
||||||
|
guard let duration = duration else { return 0 }
|
||||||
|
return Int(duration.timeIntervalSince1970) / 60
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let seconds = durationSeconds
|
||||||
|
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var durationSeconds: Int {
|
||||||
|
get {
|
||||||
|
guard let duration = duration else { return 0 }
|
||||||
|
return Int(duration.timeIntervalSince1970) % 60
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let minutes = durationMinutes
|
||||||
|
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch Request
|
// MARK: - Fetch Request
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import CoreData
|
|||||||
public class Workout: NSManagedObject, Identifiable {
|
public class Workout: NSManagedObject, Identifiable {
|
||||||
@NSManaged public var start: Date
|
@NSManaged public var start: Date
|
||||||
@NSManaged public var end: Date?
|
@NSManaged public var end: Date?
|
||||||
@NSManaged private var statusRaw: String
|
|
||||||
|
|
||||||
@NSManaged public var split: Split?
|
@NSManaged public var split: Split?
|
||||||
@NSManaged public var logs: NSSet?
|
@NSManaged public var logs: NSSet?
|
||||||
@@ -13,13 +12,26 @@ public class Workout: NSManagedObject, Identifiable {
|
|||||||
public var id: NSManagedObjectID { objectID }
|
public var id: NSManagedObjectID { objectID }
|
||||||
|
|
||||||
var status: WorkoutStatus {
|
var status: WorkoutStatus {
|
||||||
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
|
get {
|
||||||
set { statusRaw = newValue.rawValue }
|
willAccessValue(forKey: "status")
|
||||||
|
let raw = primitiveValue(forKey: "status") as? String ?? "notStarted"
|
||||||
|
didAccessValue(forKey: "status")
|
||||||
|
return WorkoutStatus(rawValue: raw) ?? .notStarted
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
willChangeValue(forKey: "status")
|
||||||
|
setPrimitiveValue(newValue.rawValue, forKey: "status")
|
||||||
|
didChangeValue(forKey: "status")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
if status == .completed, let endDate = end {
|
if status == .completed, let endDate = end {
|
||||||
return "\(start.formattedDate())—\(endDate.formattedDate())"
|
if start.isSameDay(as: endDate) {
|
||||||
|
return "\(start.formattedDate())—\(endDate.formattedTime())"
|
||||||
|
} else {
|
||||||
|
return "\(start.formattedDate())—\(endDate.formattedDate())"
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return start.formattedDate()
|
return start.formattedDate()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,23 +7,59 @@ public class WorkoutLog: NSManagedObject, Identifiable {
|
|||||||
@NSManaged public var sets: Int32
|
@NSManaged public var sets: Int32
|
||||||
@NSManaged public var reps: Int32
|
@NSManaged public var reps: Int32
|
||||||
@NSManaged public var weight: Int32
|
@NSManaged public var weight: Int32
|
||||||
@NSManaged private var statusRaw: String?
|
|
||||||
@NSManaged public var order: Int32
|
@NSManaged public var order: Int32
|
||||||
@NSManaged public var exerciseName: String
|
@NSManaged public var exerciseName: String
|
||||||
@NSManaged public var currentStateIndex: Int32
|
@NSManaged public var currentStateIndex: Int32
|
||||||
@NSManaged public var elapsedSeconds: Int32
|
@NSManaged public var elapsedSeconds: Int32
|
||||||
@NSManaged public var completed: Bool
|
@NSManaged public var completed: Bool
|
||||||
|
@NSManaged public var loadType: Int32
|
||||||
|
@NSManaged public var duration: Date?
|
||||||
|
@NSManaged public var notes: String?
|
||||||
|
|
||||||
@NSManaged public var workout: Workout?
|
@NSManaged public var workout: Workout?
|
||||||
|
|
||||||
public var id: NSManagedObjectID { objectID }
|
public var id: NSManagedObjectID { objectID }
|
||||||
|
|
||||||
var status: WorkoutStatus? {
|
var status: WorkoutStatus {
|
||||||
get {
|
get {
|
||||||
guard let raw = statusRaw else { return nil }
|
willAccessValue(forKey: "status")
|
||||||
return WorkoutStatus(rawValue: raw)
|
let raw = primitiveValue(forKey: "status") as? String ?? "notStarted"
|
||||||
|
didAccessValue(forKey: "status")
|
||||||
|
return WorkoutStatus(rawValue: raw) ?? .notStarted
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
willChangeValue(forKey: "status")
|
||||||
|
setPrimitiveValue(newValue.rawValue, forKey: "status")
|
||||||
|
didChangeValue(forKey: "status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadTypeEnum: LoadType {
|
||||||
|
get { LoadType(rawValue: Int(loadType)) ?? .weight }
|
||||||
|
set { loadType = Int32(newValue.rawValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration helpers for minutes/seconds conversion
|
||||||
|
var durationMinutes: Int {
|
||||||
|
get {
|
||||||
|
guard let duration = duration else { return 0 }
|
||||||
|
return Int(duration.timeIntervalSince1970) / 60
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let seconds = durationSeconds
|
||||||
|
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var durationSeconds: Int {
|
||||||
|
get {
|
||||||
|
guard let duration = duration else { return 0 }
|
||||||
|
return Int(duration.timeIntervalSince1970) % 60
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let minutes = durationMinutes
|
||||||
|
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
|
||||||
}
|
}
|
||||||
set { statusRaw = newValue?.rawValue }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ struct PersistenceController {
|
|||||||
// CloudKit container identifier - same as iOS app for sync
|
// CloudKit container identifier - same as iOS app for sync
|
||||||
static let cloudKitContainerIdentifier = "iCloud.dev.rzen.indie.Workouts"
|
static let cloudKitContainerIdentifier = "iCloud.dev.rzen.indie.Workouts"
|
||||||
|
|
||||||
|
// App Group identifier for shared storage between iOS and Watch
|
||||||
|
static let appGroupIdentifier = "group.dev.rzen.indie.Workouts"
|
||||||
|
|
||||||
var viewContext: NSManagedObjectContext {
|
var viewContext: NSManagedObjectContext {
|
||||||
container.viewContext
|
container.viewContext
|
||||||
}
|
}
|
||||||
@@ -57,28 +60,37 @@ struct PersistenceController {
|
|||||||
if inMemory {
|
if inMemory {
|
||||||
description.url = URL(fileURLWithPath: "/dev/null")
|
description.url = URL(fileURLWithPath: "/dev/null")
|
||||||
description.cloudKitContainerOptions = nil
|
description.cloudKitContainerOptions = nil
|
||||||
} else if cloudKitEnabled {
|
} else {
|
||||||
// Check if CloudKit is available before enabling
|
// Use App Group container for shared storage between iOS and Watch
|
||||||
let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil
|
if let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier) {
|
||||||
|
let storeURL = appGroupURL.appendingPathComponent("Workouts.sqlite")
|
||||||
if cloudKitAvailable {
|
description.url = storeURL
|
||||||
// Set CloudKit container options
|
print("Using shared App Group store at: \(storeURL)")
|
||||||
let cloudKitOptions = NSPersistentCloudKitContainerOptions(
|
|
||||||
containerIdentifier: Self.cloudKitContainerIdentifier
|
|
||||||
)
|
|
||||||
description.cloudKitContainerOptions = cloudKitOptions
|
|
||||||
} else {
|
|
||||||
// CloudKit not available (not signed in, etc.)
|
|
||||||
description.cloudKitContainerOptions = nil
|
|
||||||
print("CloudKit not available - using local storage only")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable persistent history tracking (useful even without CloudKit)
|
if cloudKitEnabled {
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
// Check if CloudKit is available before enabling
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil
|
||||||
} else {
|
|
||||||
// CloudKit explicitly disabled
|
if cloudKitAvailable {
|
||||||
description.cloudKitContainerOptions = nil
|
// Set CloudKit container options
|
||||||
|
let cloudKitOptions = NSPersistentCloudKitContainerOptions(
|
||||||
|
containerIdentifier: Self.cloudKitContainerIdentifier
|
||||||
|
)
|
||||||
|
description.cloudKitContainerOptions = cloudKitOptions
|
||||||
|
} else {
|
||||||
|
// CloudKit not available (not signed in, etc.)
|
||||||
|
description.cloudKitContainerOptions = nil
|
||||||
|
print("CloudKit not available - using local storage only")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable persistent history tracking (useful even without CloudKit)
|
||||||
|
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||||
|
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
||||||
|
} else {
|
||||||
|
// CloudKit explicitly disabled
|
||||||
|
description.cloudKitContainerOptions = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
container.loadPersistentStores { storeDescription, error in
|
container.loadPersistentStores { storeDescription, error in
|
||||||
|
|||||||
309
Workouts Watch App/Views/ExerciseProgressView.swift
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
//
|
||||||
|
// ExerciseProgressView.swift
|
||||||
|
// Workouts Watch App
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WatchKit
|
||||||
|
|
||||||
|
struct ExerciseProgressView: View {
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@ObservedObject var workoutLog: WorkoutLog
|
||||||
|
|
||||||
|
@State private var currentPage: Int = 0
|
||||||
|
@State private var showingCancelConfirm = false
|
||||||
|
|
||||||
|
private var totalSets: Int {
|
||||||
|
max(1, Int(workoutLog.sets))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalPages: Int {
|
||||||
|
// Set 1, Rest 1, Set 2, Rest 2, ..., Set N, Done
|
||||||
|
// = N sets + (N-1) rests + 1 done = 2N
|
||||||
|
totalSets * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private var firstUnfinishedSetPage: Int {
|
||||||
|
// currentStateIndex is the number of completed sets
|
||||||
|
let completedSets = Int(workoutLog.currentStateIndex)
|
||||||
|
if completedSets >= totalSets {
|
||||||
|
// All done, go to done page
|
||||||
|
return totalPages - 1
|
||||||
|
}
|
||||||
|
// Each set is at page index * 2 (Set1=0, Set2=2, Set3=4...)
|
||||||
|
return completedSets * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $currentPage) {
|
||||||
|
ForEach(0..<totalPages, id: \.self) { index in
|
||||||
|
pageView(for: index)
|
||||||
|
.tag(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button {
|
||||||
|
showingCancelConfirm = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) {
|
||||||
|
Button("Cancel Exercise", role: .destructive) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
Button("Continue", role: .cancel) { }
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Skip to first unfinished set
|
||||||
|
currentPage = firstUnfinishedSetPage
|
||||||
|
}
|
||||||
|
.onChange(of: currentPage) { _, newPage in
|
||||||
|
updateProgress(for: newPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func pageView(for index: Int) -> some View {
|
||||||
|
let lastPageIndex = totalPages - 1
|
||||||
|
|
||||||
|
if index == lastPageIndex {
|
||||||
|
// Done page
|
||||||
|
DonePageView {
|
||||||
|
completeExercise()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
} else if index % 2 == 0 {
|
||||||
|
// Set page (0, 2, 4, ...)
|
||||||
|
let setNumber = (index / 2) + 1
|
||||||
|
SetPageView(
|
||||||
|
setNumber: setNumber,
|
||||||
|
totalSets: totalSets,
|
||||||
|
reps: Int(workoutLog.reps),
|
||||||
|
isTimeBased: workoutLog.loadTypeEnum == .duration,
|
||||||
|
durationMinutes: workoutLog.durationMinutes,
|
||||||
|
durationSeconds: workoutLog.durationSeconds
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Rest page (1, 3, 5, ...)
|
||||||
|
let restNumber = (index / 2) + 1
|
||||||
|
RestPageView(restNumber: restNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateProgress(for pageIndex: Int) {
|
||||||
|
// Calculate which set we're on based on page index
|
||||||
|
// Pages: Set1(0), Rest1(1), Set2(2), Rest2(3), Set3(4), Done(5)
|
||||||
|
// After completing Set 1 and moving to Rest 1, progress should be 1
|
||||||
|
let setIndex = (pageIndex + 1) / 2
|
||||||
|
let clampedProgress = min(setIndex, totalSets)
|
||||||
|
|
||||||
|
if clampedProgress != Int(workoutLog.currentStateIndex) {
|
||||||
|
workoutLog.currentStateIndex = Int32(clampedProgress)
|
||||||
|
|
||||||
|
if clampedProgress >= totalSets {
|
||||||
|
workoutLog.status = .completed
|
||||||
|
workoutLog.completed = true
|
||||||
|
} else if clampedProgress > 0 {
|
||||||
|
workoutLog.status = .inProgress
|
||||||
|
workoutLog.completed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWorkoutStatus()
|
||||||
|
try? viewContext.save()
|
||||||
|
|
||||||
|
// Sync to iOS
|
||||||
|
WatchConnectivityManager.shared.syncToiOS()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeExercise() {
|
||||||
|
workoutLog.currentStateIndex = Int32(totalSets)
|
||||||
|
workoutLog.status = .completed
|
||||||
|
workoutLog.completed = true
|
||||||
|
updateWorkoutStatus()
|
||||||
|
try? viewContext.save()
|
||||||
|
|
||||||
|
// Sync to iOS
|
||||||
|
WatchConnectivityManager.shared.syncToiOS()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateWorkoutStatus() {
|
||||||
|
guard let workout = workoutLog.workout else { return }
|
||||||
|
let logs = workout.logsArray
|
||||||
|
let allCompleted = logs.allSatisfy { $0.status == .completed }
|
||||||
|
let anyInProgress = logs.contains { $0.status == .inProgress }
|
||||||
|
let allNotStarted = logs.allSatisfy { $0.status == .notStarted }
|
||||||
|
|
||||||
|
if allCompleted {
|
||||||
|
workout.status = .completed
|
||||||
|
workout.end = Date()
|
||||||
|
} else if anyInProgress || !allNotStarted {
|
||||||
|
workout.status = .inProgress
|
||||||
|
} else {
|
||||||
|
workout.status = .notStarted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Set Page View
|
||||||
|
|
||||||
|
struct SetPageView: View {
|
||||||
|
let setNumber: Int
|
||||||
|
let totalSets: Int
|
||||||
|
let reps: Int
|
||||||
|
let isTimeBased: Bool
|
||||||
|
let durationMinutes: Int
|
||||||
|
let durationSeconds: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("Set \(setNumber) of \(totalSets)")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text("\(setNumber)")
|
||||||
|
.font(.system(size: 72, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.green)
|
||||||
|
|
||||||
|
if isTimeBased {
|
||||||
|
Text(formattedDuration)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("\(reps) reps")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.onAppear {
|
||||||
|
WKInterfaceDevice.current().play(.start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedDuration: String {
|
||||||
|
if durationMinutes > 0 && durationSeconds > 0 {
|
||||||
|
return "\(durationMinutes)m \(durationSeconds)s"
|
||||||
|
} else if durationMinutes > 0 {
|
||||||
|
return "\(durationMinutes) min"
|
||||||
|
} else {
|
||||||
|
return "\(durationSeconds) sec"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rest Page View
|
||||||
|
|
||||||
|
struct RestPageView: View {
|
||||||
|
let restNumber: Int
|
||||||
|
|
||||||
|
@State private var elapsedSeconds: Int = 0
|
||||||
|
@State private var timer: Timer?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("Rest")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text(formattedTime)
|
||||||
|
.font(.system(size: 56, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
|
||||||
|
Text("Swipe to continue")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.onAppear {
|
||||||
|
startTimer()
|
||||||
|
WKInterfaceDevice.current().play(.start)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
stopTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedTime: String {
|
||||||
|
let minutes = elapsedSeconds / 60
|
||||||
|
let seconds = elapsedSeconds % 60
|
||||||
|
return String(format: "%d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTimer() {
|
||||||
|
elapsedSeconds = 0
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||||
|
elapsedSeconds += 1
|
||||||
|
checkHapticPing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopTimer() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkHapticPing() {
|
||||||
|
// Haptic ping every 10 seconds with pattern:
|
||||||
|
// 10s: single, 20s: double, 30s: triple, 40s: single, 50s: double, etc.
|
||||||
|
guard elapsedSeconds > 0 && elapsedSeconds % 10 == 0 else { return }
|
||||||
|
|
||||||
|
let cyclePosition = (elapsedSeconds / 10) % 3
|
||||||
|
let pingCount: Int
|
||||||
|
switch cyclePosition {
|
||||||
|
case 1: pingCount = 1 // 10s, 40s, 70s...
|
||||||
|
case 2: pingCount = 2 // 20s, 50s, 80s...
|
||||||
|
case 0: pingCount = 3 // 30s, 60s, 90s...
|
||||||
|
default: pingCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
playHapticPings(count: pingCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playHapticPings(count: Int) {
|
||||||
|
for i in 0..<count {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.2) {
|
||||||
|
WKInterfaceDevice.current().play(.click)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Done Page View
|
||||||
|
|
||||||
|
struct DonePageView: View {
|
||||||
|
let onDone: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundColor(.green)
|
||||||
|
|
||||||
|
Text("Done!")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Text("Tap to finish")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
WKInterfaceDevice.current().play(.success)
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
WKInterfaceDevice.current().play(.success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
229
Workouts Watch App/Views/WorkoutLogListView.swift
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
//
|
||||||
|
// WorkoutLogListView.swift
|
||||||
|
// Workouts Watch App
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
struct WorkoutLogListView: View {
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
|
||||||
|
@ObservedObject var workout: Workout
|
||||||
|
|
||||||
|
@State private var showingExercisePicker = false
|
||||||
|
@State private var selectedLog: WorkoutLog?
|
||||||
|
|
||||||
|
var sortedWorkoutLogs: [WorkoutLog] {
|
||||||
|
workout.logsArray
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section(header: Text(workout.label)) {
|
||||||
|
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
|
||||||
|
Button {
|
||||||
|
selectedLog = log
|
||||||
|
} label: {
|
||||||
|
WorkoutLogRowLabel(log: log)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
showingExercisePicker = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Text("Add Exercise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if sortedWorkoutLogs.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Exercises",
|
||||||
|
systemImage: "figure.strengthtraining.traditional",
|
||||||
|
description: Text("Tap + to add exercises.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(workout.split?.name ?? Split.unnamed)
|
||||||
|
.navigationDestination(item: $selectedLog) { log in
|
||||||
|
ExerciseProgressView(workoutLog: log)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingExercisePicker) {
|
||||||
|
ExercisePickerView(workout: workout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Workout Log Row Label
|
||||||
|
|
||||||
|
struct WorkoutLogRowLabel: View {
|
||||||
|
@ObservedObject var log: WorkoutLog
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
statusIcon
|
||||||
|
.foregroundColor(statusColor)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(log.exerciseName)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusIcon: Image {
|
||||||
|
switch log.status {
|
||||||
|
case .completed:
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
case .inProgress:
|
||||||
|
Image(systemName: "circle.dotted")
|
||||||
|
case .notStarted:
|
||||||
|
Image(systemName: "circle")
|
||||||
|
case .skipped:
|
||||||
|
Image(systemName: "xmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch log.status {
|
||||||
|
case .completed:
|
||||||
|
.green
|
||||||
|
case .inProgress:
|
||||||
|
.orange
|
||||||
|
case .notStarted:
|
||||||
|
.secondary
|
||||||
|
case .skipped:
|
||||||
|
.secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var subtitle: String {
|
||||||
|
if log.loadTypeEnum == .duration {
|
||||||
|
let mins = log.durationMinutes
|
||||||
|
let secs = log.durationSeconds
|
||||||
|
if mins > 0 && secs > 0 {
|
||||||
|
return "\(log.sets) × \(mins)m \(secs)s"
|
||||||
|
} else if mins > 0 {
|
||||||
|
return "\(log.sets) × \(mins) min"
|
||||||
|
} else {
|
||||||
|
return "\(log.sets) × \(secs) sec"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "\(log.sets) × \(log.reps) × \(log.weight) lbs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Exercise Picker View
|
||||||
|
|
||||||
|
struct ExercisePickerView: View {
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@ObservedObject var workout: Workout
|
||||||
|
|
||||||
|
private var availableExercises: [Exercise] {
|
||||||
|
guard let split = workout.split else { return [] }
|
||||||
|
let existingNames = Set(workout.logsArray.map { $0.exerciseName })
|
||||||
|
return split.exercisesArray.filter { !existingNames.contains($0.name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
if availableExercises.isEmpty {
|
||||||
|
Text("All exercises added")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(availableExercises, id: \.objectID) { exercise in
|
||||||
|
Button {
|
||||||
|
addExercise(exercise)
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(exercise.name)
|
||||||
|
.font(.headline)
|
||||||
|
Text(exerciseSubtitle(exercise))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Add Exercise")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addExercise(_ exercise: Exercise) {
|
||||||
|
let log = WorkoutLog(context: viewContext)
|
||||||
|
log.exerciseName = exercise.name
|
||||||
|
log.date = Date()
|
||||||
|
log.order = Int32(workout.logsArray.count)
|
||||||
|
log.sets = exercise.sets
|
||||||
|
log.reps = exercise.reps
|
||||||
|
log.weight = exercise.weight
|
||||||
|
log.loadType = exercise.loadType
|
||||||
|
log.duration = exercise.duration
|
||||||
|
log.status = .notStarted
|
||||||
|
log.workout = workout
|
||||||
|
|
||||||
|
// Update workout start if first exercise
|
||||||
|
if workout.logsArray.count == 1 {
|
||||||
|
workout.start = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
try? viewContext.save()
|
||||||
|
|
||||||
|
// Sync to iOS
|
||||||
|
WatchConnectivityManager.shared.syncToiOS()
|
||||||
|
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exerciseSubtitle(_ exercise: Exercise) -> String {
|
||||||
|
let loadType = LoadType(rawValue: Int(exercise.loadType)) ?? .weight
|
||||||
|
if loadType == .duration {
|
||||||
|
let mins = exercise.durationMinutes
|
||||||
|
let secs = exercise.durationSeconds
|
||||||
|
if mins > 0 && secs > 0 {
|
||||||
|
return "\(exercise.sets) × \(mins)m \(secs)s"
|
||||||
|
} else if mins > 0 {
|
||||||
|
return "\(exercise.sets) × \(mins) min"
|
||||||
|
} else {
|
||||||
|
return "\(exercise.sets) × \(secs) sec"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "\(exercise.sets) × \(exercise.reps) × \(exercise.weight) lbs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
WorkoutLogListView(workout: Workout())
|
||||||
|
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||||
|
}
|
||||||
99
Workouts Watch App/Views/WorkoutLogsView.swift
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// WorkoutLogsView.swift
|
||||||
|
// Workouts Watch App
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
struct WorkoutLogsView: View {
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
@EnvironmentObject var connectivityManager: WatchConnectivityManager
|
||||||
|
|
||||||
|
@FetchRequest(
|
||||||
|
sortDescriptors: [NSSortDescriptor(keyPath: \Workout.start, ascending: false)],
|
||||||
|
animation: .default
|
||||||
|
)
|
||||||
|
private var workouts: FetchedResults<Workout>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(workouts, id: \.objectID) { workout in
|
||||||
|
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
|
||||||
|
WorkoutRow(workout: workout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if workouts.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Workouts",
|
||||||
|
systemImage: "list.bullet.clipboard",
|
||||||
|
description: Text("Tap sync or start a workout from iPhone.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Workouts")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
connectivityManager.requestSync()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Workout Row
|
||||||
|
|
||||||
|
struct WorkoutRow: View {
|
||||||
|
@ObservedObject var workout: Workout
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(workout.split?.name ?? Split.unnamed)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(workout.start.formatDate())
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
statusIndicator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var statusIndicator: some View {
|
||||||
|
switch workout.status {
|
||||||
|
case .completed:
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
case .inProgress:
|
||||||
|
Image(systemName: "circle.dotted")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
case .notStarted:
|
||||||
|
Image(systemName: "circle")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
case .skipped:
|
||||||
|
Image(systemName: "xmark.circle")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
WorkoutLogsView()
|
||||||
|
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||||
|
}
|
||||||
@@ -5,12 +5,16 @@
|
|||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
<array>
|
<array/>
|
||||||
<string>iCloud.dev.rzen.indie.Workouts</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.icloud-services</key>
|
<key>com.apple.developer.icloud-services</key>
|
||||||
<array>
|
<array>
|
||||||
<string>CloudKit</string>
|
<string>CloudKit</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.rzen.indie.Workouts</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -31,8 +31,11 @@
|
|||||||
<attribute name="completed" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
<attribute name="completed" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
<attribute name="currentStateIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="currentStateIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="date" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
<attribute name="date" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="duration" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
||||||
<attribute name="elapsedSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="elapsedSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="exerciseName" attributeType="String" defaultValueString=""/>
|
<attribute name="exerciseName" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="loadType" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="notes" optional="YES" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
|||||||
@@ -14,11 +14,18 @@ import CoreData
|
|||||||
@main
|
@main
|
||||||
struct WorkoutsWatchApp: App {
|
struct WorkoutsWatchApp: App {
|
||||||
let persistenceController = PersistenceController.shared
|
let persistenceController = PersistenceController.shared
|
||||||
|
let connectivityManager = WatchConnectivityManager.shared
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Set up iPhone connectivity with Core Data context
|
||||||
|
connectivityManager.setViewContext(persistenceController.viewContext)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
.environment(\.managedObjectContext, persistenceController.viewContext)
|
||||||
|
.environmentObject(connectivityManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,6 +376,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
|
||||||
@@ -396,7 +397,7 @@
|
|||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 11.2;
|
WATCHOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -405,6 +406,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
|
||||||
@@ -426,7 +428,7 @@
|
|||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 11.2;
|
WATCHOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -449,7 +451,7 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
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 = 18.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -483,7 +485,7 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
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 = 18.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 923 B After Width: | Height: | Size: 1023 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
345
Workouts/Connectivity/WatchConnectivityManager.swift
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
//
|
||||||
|
// WatchConnectivityManager.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
class WatchConnectivityManager: NSObject, ObservableObject {
|
||||||
|
static let shared = WatchConnectivityManager()
|
||||||
|
|
||||||
|
private var session: WCSession?
|
||||||
|
private var viewContext: NSManagedObjectContext?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
if WCSession.isSupported() {
|
||||||
|
session = WCSession.default
|
||||||
|
session?.delegate = self
|
||||||
|
session?.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setViewContext(_ context: NSManagedObjectContext) {
|
||||||
|
self.viewContext = context
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Send Data to Watch
|
||||||
|
|
||||||
|
func syncAllData() {
|
||||||
|
guard let session = session else {
|
||||||
|
print("[WC-iOS] No WCSession")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[WC-iOS] Session state: \(session.activationState.rawValue), reachable: \(session.isReachable), paired: \(session.isPaired), installed: \(session.isWatchAppInstalled)")
|
||||||
|
|
||||||
|
guard session.activationState == .activated else {
|
||||||
|
print("[WC-iOS] Session not activated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let context = viewContext else {
|
||||||
|
print("[WC-iOS] No view context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.perform {
|
||||||
|
do {
|
||||||
|
let workoutsData = try self.encodeAllWorkouts(context: context)
|
||||||
|
let splitsData = try self.encodeAllSplits(context: context)
|
||||||
|
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"workouts": workoutsData,
|
||||||
|
"splits": splitsData,
|
||||||
|
"timestamp": Date().timeIntervalSince1970
|
||||||
|
]
|
||||||
|
|
||||||
|
// Use updateApplicationContext for persistent state
|
||||||
|
try session.updateApplicationContext(payload)
|
||||||
|
print("[WC-iOS] Synced \(workoutsData.count) workouts, \(splitsData.count) splits to Watch")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("Failed to sync data: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendWorkoutUpdate(_ workout: Workout) {
|
||||||
|
guard let session = session, session.activationState == .activated else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let workoutData = try encodeWorkout(workout)
|
||||||
|
let message: [String: Any] = [
|
||||||
|
"type": "workoutUpdate",
|
||||||
|
"workout": workoutData
|
||||||
|
]
|
||||||
|
|
||||||
|
if session.isReachable {
|
||||||
|
session.sendMessage(message, replyHandler: nil) { error in
|
||||||
|
print("Failed to send workout update: \(error)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Queue for later via application context
|
||||||
|
syncAllData()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to encode workout: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encoding
|
||||||
|
|
||||||
|
private func encodeAllWorkouts(context: NSManagedObjectContext) throws -> [[String: Any]] {
|
||||||
|
let request = Workout.fetchRequest()
|
||||||
|
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
|
||||||
|
let workouts = try context.fetch(request)
|
||||||
|
return try workouts.map { try encodeWorkout($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeAllSplits(context: NSManagedObjectContext) throws -> [[String: Any]] {
|
||||||
|
let request = Split.fetchRequest()
|
||||||
|
request.sortDescriptors = [NSSortDescriptor(keyPath: \Split.order, ascending: true)]
|
||||||
|
let splits = try context.fetch(request)
|
||||||
|
return try splits.map { try encodeSplit($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeWorkout(_ workout: Workout) throws -> [String: Any] {
|
||||||
|
var data: [String: Any] = [
|
||||||
|
"id": workout.objectID.uriRepresentation().absoluteString,
|
||||||
|
"start": workout.start.timeIntervalSince1970,
|
||||||
|
"status": workout.status.rawValue
|
||||||
|
]
|
||||||
|
|
||||||
|
if let end = workout.end {
|
||||||
|
data["end"] = end.timeIntervalSince1970
|
||||||
|
}
|
||||||
|
|
||||||
|
if let split = workout.split {
|
||||||
|
data["splitId"] = split.objectID.uriRepresentation().absoluteString
|
||||||
|
data["splitName"] = split.name
|
||||||
|
}
|
||||||
|
|
||||||
|
data["logs"] = workout.logsArray.map { encodeWorkoutLog($0) }
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeWorkoutLog(_ log: WorkoutLog) -> [String: Any] {
|
||||||
|
var data: [String: Any] = [
|
||||||
|
"id": log.objectID.uriRepresentation().absoluteString,
|
||||||
|
"exerciseName": log.exerciseName,
|
||||||
|
"date": log.date.timeIntervalSince1970,
|
||||||
|
"order": log.order,
|
||||||
|
"sets": log.sets,
|
||||||
|
"reps": log.reps,
|
||||||
|
"weight": log.weight,
|
||||||
|
"status": log.status.rawValue,
|
||||||
|
"currentStateIndex": log.currentStateIndex,
|
||||||
|
"completed": log.completed,
|
||||||
|
"loadType": log.loadType
|
||||||
|
]
|
||||||
|
|
||||||
|
if let duration = log.duration {
|
||||||
|
data["duration"] = duration.timeIntervalSince1970
|
||||||
|
}
|
||||||
|
|
||||||
|
if let notes = log.notes {
|
||||||
|
data["notes"] = notes
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeSplit(_ split: Split) throws -> [String: Any] {
|
||||||
|
var data: [String: Any] = [
|
||||||
|
"id": split.objectID.uriRepresentation().absoluteString,
|
||||||
|
"name": split.name,
|
||||||
|
"color": split.color,
|
||||||
|
"systemImage": split.systemImage,
|
||||||
|
"order": split.order
|
||||||
|
]
|
||||||
|
|
||||||
|
data["exercises"] = split.exercisesArray.map { encodeExercise($0) }
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeExercise(_ exercise: Exercise) -> [String: Any] {
|
||||||
|
var data: [String: Any] = [
|
||||||
|
"id": exercise.objectID.uriRepresentation().absoluteString,
|
||||||
|
"name": exercise.name,
|
||||||
|
"order": exercise.order,
|
||||||
|
"sets": exercise.sets,
|
||||||
|
"reps": exercise.reps,
|
||||||
|
"weight": exercise.weight,
|
||||||
|
"loadType": exercise.loadType
|
||||||
|
]
|
||||||
|
|
||||||
|
if let duration = exercise.duration {
|
||||||
|
data["duration"] = duration.timeIntervalSince1970
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WCSessionDelegate
|
||||||
|
|
||||||
|
extension WatchConnectivityManager: WCSessionDelegate {
|
||||||
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||||
|
if let error = error {
|
||||||
|
print("[WC-iOS] Activation failed: \(error)")
|
||||||
|
} else {
|
||||||
|
print("[WC-iOS] Activated with state: \(activationState.rawValue), paired: \(session.isPaired), installed: \(session.isWatchAppInstalled)")
|
||||||
|
// Sync data when session activates
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.syncAllData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionDidBecomeInactive(_ session: WCSession) {
|
||||||
|
print("[WC-iOS] Session became inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionDidDeactivate(_ session: WCSession) {
|
||||||
|
print("[WC-iOS] Session deactivated")
|
||||||
|
// Reactivate for switching watches
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionReachabilityDidChange(_ session: WCSession) {
|
||||||
|
print("[WC-iOS] Reachability changed: \(session.isReachable)")
|
||||||
|
if session.isReachable {
|
||||||
|
syncAllData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive messages from Watch (for bidirectional sync)
|
||||||
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||||
|
print("[WC-iOS] Received message with keys: \(message.keys)")
|
||||||
|
if let type = message["type"] as? String {
|
||||||
|
switch type {
|
||||||
|
case "requestSync":
|
||||||
|
syncAllData()
|
||||||
|
case "syncFromWatch":
|
||||||
|
processWatchSync(message)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive user info transfers from Watch (background delivery)
|
||||||
|
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||||
|
print("[WC-iOS] Received userInfo with keys: \(userInfo.keys)")
|
||||||
|
if let type = userInfo["type"] as? String, type == "syncFromWatch" {
|
||||||
|
processWatchSync(userInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Process Watch Sync
|
||||||
|
|
||||||
|
private func processWatchSync(_ data: [String: Any]) {
|
||||||
|
guard let viewContext = viewContext else {
|
||||||
|
print("[WC-iOS] No view context for Watch sync")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let workoutsData = data["workouts"] as? [[String: Any]] else {
|
||||||
|
print("[WC-iOS] No workouts in Watch sync data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[WC-iOS] Processing \(workoutsData.count) workouts from Watch")
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
viewContext.perform {
|
||||||
|
for workoutData in workoutsData {
|
||||||
|
self.updateWorkoutFromWatch(workoutData, context: viewContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try viewContext.save()
|
||||||
|
print("[WC-iOS] Successfully saved Watch sync data")
|
||||||
|
|
||||||
|
// Refresh all objects to ensure SwiftUI observes changes
|
||||||
|
viewContext.refreshAllObjects()
|
||||||
|
} catch {
|
||||||
|
print("[WC-iOS] Failed to save Watch sync: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateWorkoutFromWatch(_ data: [String: Any], context: NSManagedObjectContext) {
|
||||||
|
guard let startInterval = data["start"] as? TimeInterval else { return }
|
||||||
|
|
||||||
|
// Find workout by start date
|
||||||
|
let request = Workout.fetchRequest()
|
||||||
|
let startDate = Date(timeIntervalSince1970: startInterval)
|
||||||
|
request.predicate = NSPredicate(
|
||||||
|
format: "start >= %@ AND start <= %@",
|
||||||
|
Date(timeIntervalSince1970: startInterval - 1) as NSDate,
|
||||||
|
Date(timeIntervalSince1970: startInterval + 1) as NSDate
|
||||||
|
)
|
||||||
|
request.fetchLimit = 1
|
||||||
|
|
||||||
|
guard let workout = try? context.fetch(request).first else {
|
||||||
|
print("[WC-iOS] Workout not found for start date: \(startDate)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update workout status
|
||||||
|
if let statusRaw = data["status"] as? String,
|
||||||
|
let status = WorkoutStatus(rawValue: statusRaw) {
|
||||||
|
workout.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
if let endInterval = data["end"] as? TimeInterval {
|
||||||
|
workout.end = Date(timeIntervalSince1970: endInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update logs
|
||||||
|
if let logsData = data["logs"] as? [[String: Any]] {
|
||||||
|
for logData in logsData {
|
||||||
|
updateWorkoutLogFromWatch(logData, workout: workout, context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateWorkoutLogFromWatch(_ data: [String: Any], workout: Workout, context: NSManagedObjectContext) {
|
||||||
|
guard let exerciseName = data["exerciseName"] as? String else { return }
|
||||||
|
|
||||||
|
// Find log by exercise name in this workout
|
||||||
|
guard let log = workout.logsArray.first(where: { $0.exerciseName == exerciseName }) else {
|
||||||
|
print("[WC-iOS] Log not found for exercise: \(exerciseName)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status and progress
|
||||||
|
if let statusRaw = data["status"] as? String,
|
||||||
|
let status = WorkoutStatus(rawValue: statusRaw) {
|
||||||
|
log.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
if let currentStateIndex = data["currentStateIndex"] as? Int {
|
||||||
|
log.currentStateIndex = Int32(currentStateIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let completed = data["completed"] as? Bool {
|
||||||
|
log.completed = completed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update other fields that might have changed
|
||||||
|
if let notes = data["notes"] as? String {
|
||||||
|
log.notes = notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,22 +15,7 @@ struct ContentView: View {
|
|||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
WorkoutLogsView()
|
||||||
WorkoutLogsView()
|
|
||||||
.tabItem {
|
|
||||||
Label("Workout Logs", systemImage: "list.bullet.clipboard")
|
|
||||||
}
|
|
||||||
|
|
||||||
SplitsView()
|
|
||||||
.tabItem {
|
|
||||||
Label("Splits", systemImage: "dumbbell.fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsView()
|
|
||||||
.tabItem {
|
|
||||||
Label("Settings", systemImage: "gear")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,29 @@ public class Exercise: NSManagedObject, Identifiable {
|
|||||||
get { LoadType(rawValue: Int(loadType)) ?? .weight }
|
get { LoadType(rawValue: Int(loadType)) ?? .weight }
|
||||||
set { loadType = Int32(newValue.rawValue) }
|
set { loadType = Int32(newValue.rawValue) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Duration helpers for minutes/seconds conversion
|
||||||
|
var durationMinutes: Int {
|
||||||
|
get {
|
||||||
|
guard let duration = duration else { return 0 }
|
||||||
|
return Int(duration.timeIntervalSince1970) / 60
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let seconds = durationSeconds
|
||||||
|
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var durationSeconds: Int {
|
||||||
|
get {
|
||||||
|
guard let duration = duration else { return 0 }
|
||||||
|
return Int(duration.timeIntervalSince1970) % 60
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let minutes = durationMinutes
|
||||||
|
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch Request
|
// MARK: - Fetch Request
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ public class WorkoutLog: NSManagedObject, Identifiable {
|
|||||||
@NSManaged public var currentStateIndex: Int32
|
@NSManaged public var currentStateIndex: Int32
|
||||||
@NSManaged public var elapsedSeconds: Int32
|
@NSManaged public var elapsedSeconds: Int32
|
||||||
@NSManaged public var completed: Bool
|
@NSManaged public var completed: Bool
|
||||||
|
@NSManaged public var loadType: Int32
|
||||||
|
@NSManaged public var duration: Date?
|
||||||
|
@NSManaged public var notes: String?
|
||||||
|
|
||||||
@NSManaged public var workout: Workout?
|
@NSManaged public var workout: Workout?
|
||||||
|
|
||||||
@@ -30,6 +33,34 @@ public class WorkoutLog: NSManagedObject, Identifiable {
|
|||||||
didChangeValue(forKey: "status")
|
didChangeValue(forKey: "status")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var loadTypeEnum: LoadType {
|
||||||
|
get { LoadType(rawValue: Int(loadType)) ?? .weight }
|
||||||
|
set { loadType = Int32(newValue.rawValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration helpers for minutes/seconds conversion
|
||||||
|
var durationMinutes: Int {
|
||||||
|
get {
|
||||||
|
guard let duration = duration else { return 0 }
|
||||||
|
return Int(duration.timeIntervalSince1970) / 60
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let seconds = durationSeconds
|
||||||
|
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var durationSeconds: Int {
|
||||||
|
get {
|
||||||
|
guard let duration = duration else { return 0 }
|
||||||
|
return Int(duration.timeIntervalSince1970) % 60
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let minutes = durationMinutes
|
||||||
|
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fetch Request
|
// MARK: - Fetch Request
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ struct PersistenceController {
|
|||||||
// CloudKit container identifier
|
// CloudKit container identifier
|
||||||
static let cloudKitContainerIdentifier = "iCloud.dev.rzen.indie.Workouts"
|
static let cloudKitContainerIdentifier = "iCloud.dev.rzen.indie.Workouts"
|
||||||
|
|
||||||
|
// App Group identifier for shared storage between iOS and Watch
|
||||||
|
static let appGroupIdentifier = "group.dev.rzen.indie.Workouts"
|
||||||
|
|
||||||
var viewContext: NSManagedObjectContext {
|
var viewContext: NSManagedObjectContext {
|
||||||
container.viewContext
|
container.viewContext
|
||||||
}
|
}
|
||||||
@@ -57,28 +60,37 @@ struct PersistenceController {
|
|||||||
if inMemory {
|
if inMemory {
|
||||||
description.url = URL(fileURLWithPath: "/dev/null")
|
description.url = URL(fileURLWithPath: "/dev/null")
|
||||||
description.cloudKitContainerOptions = nil
|
description.cloudKitContainerOptions = nil
|
||||||
} else if cloudKitEnabled {
|
} else {
|
||||||
// Check if CloudKit is available before enabling
|
// Use App Group container for shared storage between iOS and Watch
|
||||||
let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil
|
if let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier) {
|
||||||
|
let storeURL = appGroupURL.appendingPathComponent("Workouts.sqlite")
|
||||||
if cloudKitAvailable {
|
description.url = storeURL
|
||||||
// Set CloudKit container options
|
print("Using shared App Group store at: \(storeURL)")
|
||||||
let cloudKitOptions = NSPersistentCloudKitContainerOptions(
|
|
||||||
containerIdentifier: Self.cloudKitContainerIdentifier
|
|
||||||
)
|
|
||||||
description.cloudKitContainerOptions = cloudKitOptions
|
|
||||||
} else {
|
|
||||||
// CloudKit not available (not signed in, etc.)
|
|
||||||
description.cloudKitContainerOptions = nil
|
|
||||||
print("CloudKit not available - using local storage only")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable persistent history tracking (useful even without CloudKit)
|
if cloudKitEnabled {
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
// Check if CloudKit is available before enabling
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil
|
||||||
} else {
|
|
||||||
// CloudKit explicitly disabled
|
if cloudKitAvailable {
|
||||||
description.cloudKitContainerOptions = nil
|
// Set CloudKit container options
|
||||||
|
let cloudKitOptions = NSPersistentCloudKitContainerOptions(
|
||||||
|
containerIdentifier: Self.cloudKitContainerIdentifier
|
||||||
|
)
|
||||||
|
description.cloudKitContainerOptions = cloudKitOptions
|
||||||
|
} else {
|
||||||
|
// CloudKit not available (not signed in, etc.)
|
||||||
|
description.cloudKitContainerOptions = nil
|
||||||
|
print("CloudKit not available - using local storage only")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable persistent history tracking (useful even without CloudKit)
|
||||||
|
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||||
|
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
||||||
|
} else {
|
||||||
|
// CloudKit explicitly disabled
|
||||||
|
description.cloudKitContainerOptions = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
container.loadPersistentStores { storeDescription, error in
|
container.loadPersistentStores { storeDescription, error in
|
||||||
|
|||||||
@@ -14,32 +14,39 @@ struct CheckboxListItem: View {
|
|||||||
var title: String
|
var title: String
|
||||||
var subtitle: String?
|
var subtitle: String?
|
||||||
var count: Int?
|
var count: Int?
|
||||||
|
var onCheckboxTap: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Image(systemName: status.systemName)
|
Button {
|
||||||
.resizable()
|
onCheckboxTap?()
|
||||||
.scaledToFit()
|
} label: {
|
||||||
.frame(width: 30)
|
Image(systemName: status.systemName)
|
||||||
.foregroundStyle(status.color)
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.foregroundStyle(status.color)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("\(title)")
|
Text("\(title)")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
HStack(alignment: .bottom) {
|
HStack(alignment: .bottom) {
|
||||||
if let subtitle = subtitle {
|
if let subtitle = subtitle {
|
||||||
Text("\(subtitle)")
|
Text("\(subtitle)")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
if let count = count {
|
if let count = count {
|
||||||
Spacer()
|
|
||||||
Text("\(count)")
|
Text("\(count)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,82 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import CoreData
|
||||||
import IndieAbout
|
import IndieAbout
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
|
||||||
|
@FetchRequest(
|
||||||
|
sortDescriptors: [
|
||||||
|
NSSortDescriptor(keyPath: \Split.order, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \Split.name, ascending: true)
|
||||||
|
],
|
||||||
|
animation: .default
|
||||||
|
)
|
||||||
|
private var splits: FetchedResults<Split>
|
||||||
|
|
||||||
|
@State private var showingAddSplitSheet = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
// MARK: - Splits Section
|
||||||
|
Section(header: Text("Splits")) {
|
||||||
|
if splits.isEmpty {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "dumbbell.fill")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("No Splits Yet")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("Create a split to organize your workout routine.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(splits, id: \.objectID) { split in
|
||||||
|
NavigationLink {
|
||||||
|
SplitDetailView(split: split)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: split.systemImage)
|
||||||
|
.foregroundColor(Color.color(from: split.color))
|
||||||
|
.frame(width: 24)
|
||||||
|
Text(split.name)
|
||||||
|
Spacer()
|
||||||
|
Text("\(split.exercisesArray.count)")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showingAddSplitSheet = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
Text("Add Split")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Account Section
|
||||||
Section(header: Text("Account")) {
|
Section(header: Text("Account")) {
|
||||||
Text("Settings coming soon")
|
Text("Settings coming soon")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - About Section
|
||||||
Section {
|
Section {
|
||||||
IndieAbout(configuration: AppInfoConfiguration(
|
IndieAbout(configuration: AppInfoConfiguration(
|
||||||
documents: [
|
documents: [
|
||||||
@@ -28,10 +93,14 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
|
.sheet(isPresented: $showingAddSplitSheet) {
|
||||||
|
SplitAddEditView(split: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
|
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||||
}
|
}
|
||||||
|
|||||||
233
Workouts/Views/WorkoutLogs/ExerciseView.swift
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
//
|
||||||
|
// ExerciseView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/18/25 at 5:44 PM.
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CoreData
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
struct ExerciseView: View {
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@ObservedObject var workoutLog: WorkoutLog
|
||||||
|
|
||||||
|
@State private var progress: Int = 0
|
||||||
|
@State private var showingPlanEdit = false
|
||||||
|
@State private var showingNotesEdit = false
|
||||||
|
|
||||||
|
let notStartedColor = Color.white
|
||||||
|
let completedColor = Color.green
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
// MARK: - Progress Section
|
||||||
|
Section(header: Text("Progress")) {
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: Int(workoutLog.sets)), spacing: 4) {
|
||||||
|
ForEach(1...max(1, Int(workoutLog.sets)), id: \.self) { index in
|
||||||
|
ZStack {
|
||||||
|
let completed = index <= progress
|
||||||
|
let color = completed ? completedColor : notStartedColor
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [color, color.darker(by: 0.2)]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.aspectRatio(0.618, contentMode: .fit)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
Text("\(index)")
|
||||||
|
.font(.title)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.colorInvert()
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
let totalSets = Int(workoutLog.sets)
|
||||||
|
let isLastTile = index == totalSets
|
||||||
|
let wasAlreadyAtThisProgress = progress == index
|
||||||
|
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
if wasAlreadyAtThisProgress {
|
||||||
|
progress = 0
|
||||||
|
} else {
|
||||||
|
progress = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLogStatus()
|
||||||
|
|
||||||
|
// If tapping the last tile to complete, go back to list
|
||||||
|
if isLastTile && !wasAlreadyAtThisProgress {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Plan Section (Read-only with Edit button)
|
||||||
|
Section {
|
||||||
|
PlanTilesView(workoutLog: workoutLog)
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Text("Plan")
|
||||||
|
Spacer()
|
||||||
|
Button("Edit") {
|
||||||
|
showingPlanEdit = true
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.textCase(.none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notes Section (Read-only with Edit button)
|
||||||
|
Section {
|
||||||
|
if let notes = workoutLog.notes, !notes.isEmpty {
|
||||||
|
Text(notes)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
} else {
|
||||||
|
Text("No notes")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.italic()
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Text("Notes")
|
||||||
|
Spacer()
|
||||||
|
Button("Edit") {
|
||||||
|
showingNotesEdit = true
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.textCase(.none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress Tracking Chart
|
||||||
|
Section(header: Text("Progress Tracking")) {
|
||||||
|
WeightProgressionChartView(exerciseName: workoutLog.exerciseName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(workoutLog.exerciseName)
|
||||||
|
.sheet(isPresented: $showingPlanEdit) {
|
||||||
|
PlanEditView(workoutLog: workoutLog)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingNotesEdit) {
|
||||||
|
NotesEditView(workoutLog: workoutLog)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
progress = Int(workoutLog.currentStateIndex)
|
||||||
|
}
|
||||||
|
.onChange(of: workoutLog.currentStateIndex) { _, newValue in
|
||||||
|
// Update local state when CoreData changes (e.g., from Watch sync)
|
||||||
|
if progress != Int(newValue) {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
progress = Int(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateLogStatus() {
|
||||||
|
workoutLog.currentStateIndex = Int32(progress)
|
||||||
|
if progress >= Int(workoutLog.sets) {
|
||||||
|
workoutLog.status = .completed
|
||||||
|
workoutLog.completed = true
|
||||||
|
} else if progress > 0 {
|
||||||
|
workoutLog.status = .inProgress
|
||||||
|
workoutLog.completed = false
|
||||||
|
} else {
|
||||||
|
workoutLog.status = .notStarted
|
||||||
|
workoutLog.completed = false
|
||||||
|
}
|
||||||
|
updateWorkoutStatus()
|
||||||
|
saveChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateWorkoutStatus() {
|
||||||
|
guard let workout = workoutLog.workout else { return }
|
||||||
|
let logs = workout.logsArray
|
||||||
|
let allCompleted = logs.allSatisfy { $0.status == .completed }
|
||||||
|
let anyInProgress = logs.contains { $0.status == .inProgress }
|
||||||
|
let allNotStarted = logs.allSatisfy { $0.status == .notStarted }
|
||||||
|
|
||||||
|
if allCompleted {
|
||||||
|
workout.status = .completed
|
||||||
|
workout.end = Date()
|
||||||
|
} else if anyInProgress || !allNotStarted {
|
||||||
|
workout.status = .inProgress
|
||||||
|
} else {
|
||||||
|
workout.status = .notStarted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveChanges() {
|
||||||
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Plan Tiles View
|
||||||
|
|
||||||
|
struct PlanTilesView: View {
|
||||||
|
@ObservedObject var workoutLog: WorkoutLog
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if workoutLog.loadTypeEnum == .duration {
|
||||||
|
// Duration layout: Sets | Duration
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
PlanTile(label: "Sets", value: "\(workoutLog.sets)")
|
||||||
|
PlanTile(label: "Duration", value: formattedDuration)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Weight layout: Sets | Reps | Weight
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
PlanTile(label: "Sets", value: "\(workoutLog.sets)")
|
||||||
|
PlanTile(label: "Reps", value: "\(workoutLog.reps)")
|
||||||
|
PlanTile(label: "Weight", value: "\(workoutLog.weight) lbs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedDuration: String {
|
||||||
|
let mins = workoutLog.durationMinutes
|
||||||
|
let secs = workoutLog.durationSeconds
|
||||||
|
if mins > 0 && secs > 0 {
|
||||||
|
return "\(mins)m \(secs)s"
|
||||||
|
} else if mins > 0 {
|
||||||
|
return "\(mins) min"
|
||||||
|
} else if secs > 0 {
|
||||||
|
return "\(secs) sec"
|
||||||
|
} else {
|
||||||
|
return "0 sec"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlanTile: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Workouts/Views/WorkoutLogs/NotesEditView.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//
|
||||||
|
// NotesEditView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
struct NotesEditView: View {
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@ObservedObject var workoutLog: WorkoutLog
|
||||||
|
|
||||||
|
@State private var notesText: String = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextEditor(text: $notesText)
|
||||||
|
.frame(minHeight: 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit Notes")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") {
|
||||||
|
saveChanges()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
notesText = workoutLog.notes ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveChanges() {
|
||||||
|
workoutLog.notes = notesText
|
||||||
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
|
}
|
||||||
|
}
|
||||||
168
Workouts/Views/WorkoutLogs/PlanEditView.swift
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
//
|
||||||
|
// PlanEditView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
struct PlanEditView: View {
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@ObservedObject var workoutLog: WorkoutLog
|
||||||
|
|
||||||
|
@State private var sets: Int = 3
|
||||||
|
@State private var reps: Int = 12
|
||||||
|
@State private var weight: Int = 0
|
||||||
|
@State private var durationMinutes: Int = 0
|
||||||
|
@State private var durationSeconds: Int = 0
|
||||||
|
@State private var selectedLoadType: LoadType = .weight
|
||||||
|
|
||||||
|
// Find the corresponding exercise in the split for syncing changes
|
||||||
|
private var correspondingExercise: Exercise? {
|
||||||
|
workoutLog.workout?.split?.exercisesArray.first { $0.name == workoutLog.exerciseName }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// Sets and Reps side by side
|
||||||
|
Section {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
VStack {
|
||||||
|
Text("Sets")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Picker("Sets", selection: $sets) {
|
||||||
|
ForEach(1...7, id: \.self) { num in
|
||||||
|
Text("\(num)").tag(num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.wheel)
|
||||||
|
.frame(height: 120)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
Text("Reps")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Picker("Reps", selection: $reps) {
|
||||||
|
ForEach(1...40, id: \.self) { num in
|
||||||
|
Text("\(num)").tag(num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.wheel)
|
||||||
|
.frame(height: 120)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Type Picker
|
||||||
|
Section {
|
||||||
|
Picker("Load Type", selection: $selectedLoadType) {
|
||||||
|
Text("Weight").tag(LoadType.weight)
|
||||||
|
Text("Time").tag(LoadType.duration)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weight or Time picker based on load type
|
||||||
|
Section {
|
||||||
|
if selectedLoadType == .weight {
|
||||||
|
VStack {
|
||||||
|
Text("Weight")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Picker("Weight", selection: $weight) {
|
||||||
|
ForEach(0...300, id: \.self) { num in
|
||||||
|
Text("\(num) lbs").tag(num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.wheel)
|
||||||
|
.frame(height: 150)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
VStack {
|
||||||
|
Text("Mins")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Picker("Minutes", selection: $durationMinutes) {
|
||||||
|
ForEach(0...60, id: \.self) { num in
|
||||||
|
Text("\(num)").tag(num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.wheel)
|
||||||
|
.frame(height: 120)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
Text("Secs")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Picker("Seconds", selection: $durationSeconds) {
|
||||||
|
ForEach(0...59, id: \.self) { num in
|
||||||
|
Text("\(num)").tag(num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.wheel)
|
||||||
|
.frame(height: 120)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit Plan")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") {
|
||||||
|
saveChanges()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
sets = Int(workoutLog.sets)
|
||||||
|
reps = Int(workoutLog.reps)
|
||||||
|
weight = Int(workoutLog.weight)
|
||||||
|
durationMinutes = workoutLog.durationMinutes
|
||||||
|
durationSeconds = workoutLog.durationSeconds
|
||||||
|
selectedLoadType = workoutLog.loadTypeEnum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveChanges() {
|
||||||
|
workoutLog.sets = Int32(sets)
|
||||||
|
workoutLog.reps = Int32(reps)
|
||||||
|
workoutLog.weight = Int32(weight)
|
||||||
|
workoutLog.durationMinutes = durationMinutes
|
||||||
|
workoutLog.durationSeconds = durationSeconds
|
||||||
|
workoutLog.loadTypeEnum = selectedLoadType
|
||||||
|
|
||||||
|
// Sync to corresponding exercise
|
||||||
|
if let exercise = correspondingExercise {
|
||||||
|
exercise.sets = workoutLog.sets
|
||||||
|
exercise.reps = workoutLog.reps
|
||||||
|
exercise.weight = workoutLog.weight
|
||||||
|
exercise.loadType = workoutLog.loadType
|
||||||
|
exercise.duration = workoutLog.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
|
}
|
||||||
|
}
|
||||||
127
Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
//
|
||||||
|
// WeightProgressionChartView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created on 7/20/25.
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
struct WeightProgressionChartView: View {
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
|
||||||
|
let exerciseName: String
|
||||||
|
@State private var weightData: [WeightDataPoint] = []
|
||||||
|
@State private var isLoading: Bool = true
|
||||||
|
@State private var motivationalMessage: String = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView("Loading data...")
|
||||||
|
} else if weightData.isEmpty {
|
||||||
|
Text("No weight history available yet.")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
Text("Weight Progression")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
Chart {
|
||||||
|
ForEach(weightData) { dataPoint in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Date", dataPoint.date),
|
||||||
|
y: .value("Weight", dataPoint.weight)
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.blue.gradient)
|
||||||
|
.interpolationMethod(.catmullRom)
|
||||||
|
|
||||||
|
PointMark(
|
||||||
|
x: .value("Date", dataPoint.date),
|
||||||
|
y: .value("Weight", dataPoint.weight)
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYScale(domain: .automatic(includesZero: false))
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: .automatic) { _ in
|
||||||
|
AxisGridLine()
|
||||||
|
AxisValueLabel(format: .dateTime.month().day())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 200)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
if !motivationalMessage.isEmpty {
|
||||||
|
Text(motivationalMessage)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.padding()
|
||||||
|
.background(Color.blue.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.onAppear {
|
||||||
|
loadWeightData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadWeightData() {
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
let request: NSFetchRequest<WorkoutLog> = WorkoutLog.fetchRequest()
|
||||||
|
request.predicate = NSPredicate(format: "exerciseName == %@ AND completed == YES", exerciseName)
|
||||||
|
request.sortDescriptors = [NSSortDescriptor(keyPath: \WorkoutLog.date, ascending: true)]
|
||||||
|
|
||||||
|
if let logs = try? viewContext.fetch(request) {
|
||||||
|
weightData = logs.map { log in
|
||||||
|
WeightDataPoint(date: log.date, weight: Int(log.weight))
|
||||||
|
}
|
||||||
|
generateMotivationalMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateMotivationalMessage() {
|
||||||
|
guard weightData.count >= 2 else {
|
||||||
|
motivationalMessage = "Complete more workouts to track your progress!"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstWeight = weightData.first?.weight ?? 0
|
||||||
|
let currentWeight = weightData.last?.weight ?? 0
|
||||||
|
let weightDifference = currentWeight - firstWeight
|
||||||
|
|
||||||
|
if weightDifference > 0 {
|
||||||
|
let percentIncrease = Int((Double(weightDifference) / Double(firstWeight)) * 100)
|
||||||
|
if percentIncrease >= 20 {
|
||||||
|
motivationalMessage = "Amazing progress! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
|
||||||
|
} else if percentIncrease >= 10 {
|
||||||
|
motivationalMessage = "Great job! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
|
||||||
|
} else {
|
||||||
|
motivationalMessage = "You're making progress! Weight increased by \(weightDifference) lbs. Keep it up!"
|
||||||
|
}
|
||||||
|
} else if weightDifference == 0 {
|
||||||
|
motivationalMessage = "You're maintaining consistent weight. Focus on form and consider increasing when ready!"
|
||||||
|
} else {
|
||||||
|
motivationalMessage = "Your current weight is lower than when you started. Adjust your training as needed and keep pushing!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data structure for chart points
|
||||||
|
struct WeightDataPoint: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let date: Date
|
||||||
|
let weight: Int
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ struct WorkoutLogListView: View {
|
|||||||
|
|
||||||
@State private var showingAddSheet = false
|
@State private var showingAddSheet = false
|
||||||
@State private var itemToDelete: WorkoutLog? = nil
|
@State private var itemToDelete: WorkoutLog? = nil
|
||||||
|
@State private var newlyAddedLog: WorkoutLog? = nil
|
||||||
|
|
||||||
var sortedWorkoutLogs: [WorkoutLog] {
|
var sortedWorkoutLogs: [WorkoutLog] {
|
||||||
workout.logsArray
|
workout.logsArray
|
||||||
@@ -43,14 +44,16 @@ struct WorkoutLogListView: View {
|
|||||||
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
|
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
|
||||||
let workoutLogStatus = log.status.checkboxStatus
|
let workoutLogStatus = log.status.checkboxStatus
|
||||||
|
|
||||||
CheckboxListItem(
|
NavigationLink {
|
||||||
status: workoutLogStatus,
|
ExerciseView(workoutLog: log)
|
||||||
title: log.exerciseName,
|
} label: {
|
||||||
subtitle: "\(log.sets) × \(log.reps) reps × \(log.weight) lbs"
|
CheckboxListItem(
|
||||||
)
|
status: workoutLogStatus,
|
||||||
.contentShape(Rectangle())
|
title: log.exerciseName,
|
||||||
.onTapGesture {
|
subtitle: subtitleForLog(log)
|
||||||
cycleStatus(for: log)
|
) {
|
||||||
|
cycleStatus(for: log)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||||
Button {
|
Button {
|
||||||
@@ -74,6 +77,9 @@ struct WorkoutLogListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationDestination(item: $newlyAddedLog) { log in
|
||||||
|
ExerciseView(workoutLog: log)
|
||||||
|
}
|
||||||
.navigationTitle("\(workout.split?.name ?? Split.unnamed)")
|
.navigationTitle("\(workout.split?.name ?? Split.unnamed)")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
@@ -127,12 +133,14 @@ struct WorkoutLogListView: View {
|
|||||||
}
|
}
|
||||||
updateWorkoutStatus()
|
updateWorkoutStatus()
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func completeLog(_ log: WorkoutLog) {
|
private func completeLog(_ log: WorkoutLog) {
|
||||||
log.status = .completed
|
log.status = .completed
|
||||||
updateWorkoutStatus()
|
updateWorkoutStatus()
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateWorkoutStatus() {
|
private func updateWorkoutStatus() {
|
||||||
@@ -158,6 +166,7 @@ struct WorkoutLogListView: View {
|
|||||||
log.order = Int32(index)
|
log.order = Int32(index)
|
||||||
}
|
}
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addExerciseFromSplit(_ exercise: Exercise) {
|
private func addExerciseFromSplit(_ exercise: Exercise) {
|
||||||
@@ -176,10 +185,32 @@ struct WorkoutLogListView: View {
|
|||||||
log.sets = exercise.sets
|
log.sets = exercise.sets
|
||||||
log.reps = exercise.reps
|
log.reps = exercise.reps
|
||||||
log.weight = exercise.weight
|
log.weight = exercise.weight
|
||||||
|
log.loadType = exercise.loadType
|
||||||
|
log.duration = exercise.duration
|
||||||
log.status = .notStarted
|
log.status = .notStarted
|
||||||
log.workout = workout
|
log.workout = workout
|
||||||
|
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
|
|
||||||
|
// Navigate to the new exercise view
|
||||||
|
newlyAddedLog = log
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subtitleForLog(_ log: WorkoutLog) -> String {
|
||||||
|
if log.loadTypeEnum == .duration {
|
||||||
|
let mins = log.durationMinutes
|
||||||
|
let secs = log.durationSeconds
|
||||||
|
if mins > 0 && secs > 0 {
|
||||||
|
return "\(log.sets) × \(mins)m \(secs)s"
|
||||||
|
} else if mins > 0 {
|
||||||
|
return "\(log.sets) × \(mins) min"
|
||||||
|
} else {
|
||||||
|
return "\(log.sets) × \(secs) sec"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "\(log.sets) × \(log.reps) reps × \(log.weight) lbs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ struct WorkoutLogsView: View {
|
|||||||
private var workouts: FetchedResults<Workout>
|
private var workouts: FetchedResults<Workout>
|
||||||
|
|
||||||
@State private var showingSplitPicker = false
|
@State private var showingSplitPicker = false
|
||||||
|
@State private var showingSettings = false
|
||||||
@State private var itemToDelete: Workout? = nil
|
@State private var itemToDelete: Workout? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -55,12 +56,22 @@ struct WorkoutLogsView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Workout Logs")
|
.navigationTitle("Workout Logs")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button {
|
||||||
|
showingSettings = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "gearshape.2")
|
||||||
|
}
|
||||||
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Start New") {
|
Button("Start New") {
|
||||||
showingSplitPicker.toggle()
|
showingSplitPicker.toggle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingSettings) {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingSplitPicker) {
|
.sheet(isPresented: $showingSplitPicker) {
|
||||||
SplitPickerSheet()
|
SplitPickerSheet()
|
||||||
}
|
}
|
||||||
@@ -77,6 +88,7 @@ struct WorkoutLogsView: View {
|
|||||||
withAnimation {
|
withAnimation {
|
||||||
viewContext.delete(item)
|
viewContext.delete(item)
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
itemToDelete = nil
|
itemToDelete = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,11 +168,17 @@ struct SplitPickerSheet: View {
|
|||||||
workoutLog.sets = exercise.sets
|
workoutLog.sets = exercise.sets
|
||||||
workoutLog.reps = exercise.reps
|
workoutLog.reps = exercise.reps
|
||||||
workoutLog.weight = exercise.weight
|
workoutLog.weight = exercise.weight
|
||||||
|
workoutLog.loadType = exercise.loadType
|
||||||
|
workoutLog.duration = exercise.duration
|
||||||
workoutLog.status = .notStarted
|
workoutLog.status = .notStarted
|
||||||
workoutLog.workout = workout
|
workoutLog.workout = workout
|
||||||
}
|
}
|
||||||
|
|
||||||
try? viewContext.save()
|
try? viewContext.save()
|
||||||
|
|
||||||
|
// Sync to Watch
|
||||||
|
WatchConnectivityManager.shared.syncAllData()
|
||||||
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,9 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.rzen.indie.Workouts</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -31,8 +31,11 @@
|
|||||||
<attribute name="completed" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
<attribute name="completed" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
<attribute name="currentStateIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="currentStateIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="date" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
<attribute name="date" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="duration" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
||||||
<attribute name="elapsedSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="elapsedSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="exerciseName" attributeType="String" defaultValueString=""/>
|
<attribute name="exerciseName" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="loadType" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="notes" optional="YES" attributeType="String" defaultValueString=""/>
|
||||||
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
|
|||||||
@@ -14,11 +14,18 @@ import CoreData
|
|||||||
@main
|
@main
|
||||||
struct WorkoutsApp: App {
|
struct WorkoutsApp: App {
|
||||||
let persistenceController = PersistenceController.shared
|
let persistenceController = PersistenceController.shared
|
||||||
|
let connectivityManager = WatchConnectivityManager.shared
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Set up Watch connectivity with Core Data context
|
||||||
|
connectivityManager.setViewContext(persistenceController.viewContext)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
.environment(\.managedObjectContext, persistenceController.viewContext)
|
||||||
|
.environmentObject(connectivityManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||