Workouts 2.0: re-base persistence on iCloud Drive documents
Replace Core Data + NSPersistentCloudKitContainer + App-Group store + WatchConnectivity dictionary sync with the QuickRabbit iCloud-documents architecture: - iCloud Drive JSON documents are the sole source of truth (one file per aggregate: Splits/<ULID>.json, Workouts/YYYY/MM/<ULID>.json), with a rebuildable SwiftData cache populated only by an NSMetadataQuery observer and a connect-time reconcile. Soft-delete tombstones; hard iCloud gate. - Shared model layer (ULID, Codable *Documents + stateless mappers, @Model cache entities, SwiftData container) compiled into both targets. - New iPhone<->Watch bridge over WatchConnectivity keyed by ULIDs; the phone is the sole writer of iCloud Drive, the watch round-trips documents. - AppServices DI + iCloud-required root gate; Swift 6 strict concurrency. - Starter splits generated on demand from the bundled YAML catalogs. - Migrate to XcodeGen (project.yml), iOS 26 / watchOS 26; CloudDocuments entitlement (drop CloudKit/App Group/aps-environment). - Duration stored as Int seconds (was a Date epoch hack); fix workout end-on-create, undismissable delete dialog, toolbar-hiding nav stacks, and the Settings placeholder. - Add README/CHANGELOG/LICENSE, .gitignore, refreshed REQUIREMENTS, and the Scripts/ TestFlight pipeline (release.sh + ASC API scripts). MARKETING_VERSION 2.0.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftData
|
||||
import WatchConnectivity
|
||||
|
||||
/// Watch side of the iPhone↔Watch bridge. The watch never touches iCloud — it
|
||||
/// keeps a local SwiftData cache fed only by application-context pushes from the
|
||||
/// phone, updates it optimistically on local edits, and forwards changed workouts
|
||||
/// to the phone (which is the sole writer of iCloud Drive).
|
||||
@Observable
|
||||
@MainActor
|
||||
final class WatchConnectivityBridge: NSObject {
|
||||
private let container: ModelContainer
|
||||
private var session: WCSession?
|
||||
|
||||
/// Last time state was received from the phone (for a sync indicator).
|
||||
private(set) var lastSyncDate: Date?
|
||||
|
||||
private var context: ModelContext { container.mainContext }
|
||||
|
||||
init(container: ModelContainer) {
|
||||
self.container = container
|
||||
super.init()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
self.session = session
|
||||
// Apply whatever the phone last pushed, then ask for a fresh push.
|
||||
applyState(WCPayload.decodeSplits(session.receivedApplicationContext),
|
||||
workouts: WCPayload.decodeWorkouts(session.receivedApplicationContext))
|
||||
requestSync()
|
||||
}
|
||||
|
||||
func requestSync() {
|
||||
guard let session, session.activationState == .activated, session.isReachable else { return }
|
||||
session.sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)
|
||||
}
|
||||
|
||||
/// Optimistically applies a workout edit to the local cache and forwards it to
|
||||
/// the phone for durable persistence in iCloud Drive.
|
||||
func update(workout doc: WorkoutDocument) {
|
||||
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
|
||||
try? context.save()
|
||||
sendToPhone(doc)
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
private func sendToPhone(_ doc: WorkoutDocument) {
|
||||
guard let session, session.activationState == .activated else { return }
|
||||
let payload = WCPayload.encodeWorkoutUpdate(doc)
|
||||
if session.isReachable {
|
||||
session.sendMessage(payload, replyHandler: nil, errorHandler: { _ in
|
||||
session.transferUserInfo(payload) // fall back to guaranteed delivery
|
||||
})
|
||||
} else {
|
||||
session.transferUserInfo(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) {
|
||||
guard !splits.isEmpty || !workouts.isEmpty else { return }
|
||||
var liveSplitIDs = Set<String>()
|
||||
for s in splits {
|
||||
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
|
||||
liveSplitIDs.insert(s.id)
|
||||
}
|
||||
for w in workouts {
|
||||
CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
|
||||
}
|
||||
// Splits are sent in full → prune any the phone no longer has. Workouts are
|
||||
// sent as a recent window, so they're upserted but never pruned (avoids a
|
||||
// race deleting a workout just created on the watch).
|
||||
if let allSplits = try? context.fetch(FetchDescriptor<Split>()) {
|
||||
for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) }
|
||||
}
|
||||
try? context.save()
|
||||
lastSyncDate = Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension WatchConnectivityBridge: WCSessionDelegate {
|
||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
Task { @MainActor in self.requestSync() }
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
let splits = WCPayload.decodeSplits(applicationContext)
|
||||
let workouts = WCPayload.decodeWorkouts(applicationContext)
|
||||
Task { @MainActor in self.applyState(splits, workouts: workouts) }
|
||||
}
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,14 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// Workouts Watch App
|
||||
// ContentView.swift
|
||||
// Workouts Watch App
|
||||
//
|
||||
// Created by rzen on 8/13/25 at 11:10 AM.
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
var body: some View {
|
||||
WorkoutLogsView()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Exercise)
|
||||
public class Exercise: NSManagedObject, Identifiable {
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var loadType: Int32
|
||||
@NSManaged public var order: Int32
|
||||
@NSManaged public var sets: Int32
|
||||
@NSManaged public var reps: Int32
|
||||
@NSManaged public var weight: Int32
|
||||
@NSManaged public var duration: Date?
|
||||
@NSManaged public var weightLastUpdated: Date?
|
||||
@NSManaged public var weightReminderTimeIntervalWeeks: Int32
|
||||
|
||||
@NSManaged public var split: Split?
|
||||
|
||||
public var id: NSManagedObjectID { objectID }
|
||||
|
||||
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
|
||||
|
||||
extension Exercise {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Exercise> {
|
||||
return NSFetchRequest<Exercise>(entityName: "Exercise")
|
||||
}
|
||||
|
||||
static func orderedFetchRequest(for split: Split) -> NSFetchRequest<Exercise> {
|
||||
let request = fetchRequest()
|
||||
request.predicate = NSPredicate(format: "split == %@", split)
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Exercise.order, ascending: true)]
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum LoadType: Int, CaseIterable {
|
||||
case none = 0
|
||||
case weight = 1
|
||||
case duration = 2
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .none: "None"
|
||||
case .weight: "Weight"
|
||||
case .duration: "Duration"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
@objc(Split)
|
||||
public class Split: NSManagedObject, Identifiable {
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var color: String
|
||||
@NSManaged public var systemImage: String
|
||||
@NSManaged public var order: Int32
|
||||
|
||||
@NSManaged public var exercises: NSSet?
|
||||
@NSManaged public var workouts: NSSet?
|
||||
|
||||
public var id: NSManagedObjectID { objectID }
|
||||
|
||||
static let unnamed = "Unnamed Split"
|
||||
}
|
||||
|
||||
// MARK: - Convenience Accessors
|
||||
|
||||
extension Split {
|
||||
var exercisesArray: [Exercise] {
|
||||
let set = exercises as? Set<Exercise> ?? []
|
||||
return set.sorted { $0.order < $1.order }
|
||||
}
|
||||
|
||||
var workoutsArray: [Workout] {
|
||||
let set = workouts as? Set<Workout> ?? []
|
||||
return set.sorted { $0.start > $1.start }
|
||||
}
|
||||
|
||||
func addToExercises(_ exercise: Exercise) {
|
||||
let items = mutableSetValue(forKey: "exercises")
|
||||
items.add(exercise)
|
||||
}
|
||||
|
||||
func removeFromExercises(_ exercise: Exercise) {
|
||||
let items = mutableSetValue(forKey: "exercises")
|
||||
items.remove(exercise)
|
||||
}
|
||||
|
||||
func addToWorkouts(_ workout: Workout) {
|
||||
let items = mutableSetValue(forKey: "workouts")
|
||||
items.add(workout)
|
||||
}
|
||||
|
||||
func removeFromWorkouts(_ workout: Workout) {
|
||||
let items = mutableSetValue(forKey: "workouts")
|
||||
items.remove(workout)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fetch Request
|
||||
|
||||
extension Split {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Split> {
|
||||
return NSFetchRequest<Split>(entityName: "Split")
|
||||
}
|
||||
|
||||
static func orderedFetchRequest() -> NSFetchRequest<Split> {
|
||||
let request = fetchRequest()
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Split.order, ascending: true)]
|
||||
return request
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Workout)
|
||||
public class Workout: NSManagedObject, Identifiable {
|
||||
@NSManaged public var start: Date
|
||||
@NSManaged public var end: Date?
|
||||
|
||||
@NSManaged public var split: Split?
|
||||
@NSManaged public var logs: NSSet?
|
||||
|
||||
public var id: NSManagedObjectID { objectID }
|
||||
|
||||
var status: WorkoutStatus {
|
||||
get {
|
||||
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 {
|
||||
if status == .completed, let endDate = end {
|
||||
if start.isSameDay(as: endDate) {
|
||||
return "\(start.formattedDate())—\(endDate.formattedTime())"
|
||||
} else {
|
||||
return "\(start.formattedDate())—\(endDate.formattedDate())"
|
||||
}
|
||||
} else {
|
||||
return start.formattedDate()
|
||||
}
|
||||
}
|
||||
|
||||
var statusName: String {
|
||||
return status.displayName
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Accessors
|
||||
|
||||
extension Workout {
|
||||
var logsArray: [WorkoutLog] {
|
||||
let set = logs as? Set<WorkoutLog> ?? []
|
||||
return set.sorted { $0.order < $1.order }
|
||||
}
|
||||
|
||||
func addToLogs(_ log: WorkoutLog) {
|
||||
let items = mutableSetValue(forKey: "logs")
|
||||
items.add(log)
|
||||
}
|
||||
|
||||
func removeFromLogs(_ log: WorkoutLog) {
|
||||
let items = mutableSetValue(forKey: "logs")
|
||||
items.remove(log)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fetch Request
|
||||
|
||||
extension Workout {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Workout> {
|
||||
return NSFetchRequest<Workout>(entityName: "Workout")
|
||||
}
|
||||
|
||||
static func recentFetchRequest() -> NSFetchRequest<Workout> {
|
||||
let request = fetchRequest()
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
|
||||
return request
|
||||
}
|
||||
|
||||
static func fetchRequest(for split: Split) -> NSFetchRequest<Workout> {
|
||||
let request = fetchRequest()
|
||||
request.predicate = NSPredicate(format: "split == %@", split)
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
|
||||
return request
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(WorkoutLog)
|
||||
public class WorkoutLog: NSManagedObject, Identifiable {
|
||||
@NSManaged public var date: Date
|
||||
@NSManaged public var sets: Int32
|
||||
@NSManaged public var reps: Int32
|
||||
@NSManaged public var weight: Int32
|
||||
@NSManaged public var order: Int32
|
||||
@NSManaged public var exerciseName: String
|
||||
@NSManaged public var currentStateIndex: Int32
|
||||
@NSManaged public var elapsedSeconds: Int32
|
||||
@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?
|
||||
|
||||
public var id: NSManagedObjectID { objectID }
|
||||
|
||||
var status: WorkoutStatus {
|
||||
get {
|
||||
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 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
|
||||
|
||||
extension WorkoutLog {
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<WorkoutLog> {
|
||||
return NSFetchRequest<WorkoutLog>(entityName: "WorkoutLog")
|
||||
}
|
||||
|
||||
static func orderedFetchRequest(for workout: Workout) -> NSFetchRequest<WorkoutLog> {
|
||||
let request = fetchRequest()
|
||||
request.predicate = NSPredicate(format: "workout == %@", workout)
|
||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \WorkoutLog.order, ascending: true)]
|
||||
return request
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum WorkoutStatus: String, CaseIterable, Codable {
|
||||
case notStarted = "notStarted"
|
||||
case inProgress = "inProgress"
|
||||
case completed = "completed"
|
||||
case skipped = "skipped"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .notStarted:
|
||||
return "Not Started"
|
||||
case .inProgress:
|
||||
return "In Progress"
|
||||
case .completed:
|
||||
return "Completed"
|
||||
case .skipped:
|
||||
return "Skipped"
|
||||
}
|
||||
}
|
||||
|
||||
var name: String { displayName }
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import CoreData
|
||||
import CloudKit
|
||||
|
||||
struct PersistenceController {
|
||||
static let shared = PersistenceController()
|
||||
|
||||
let container: NSPersistentCloudKitContainer
|
||||
|
||||
// CloudKit container identifier - same as iOS app for sync
|
||||
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 {
|
||||
container.viewContext
|
||||
}
|
||||
|
||||
// MARK: - Preview Support
|
||||
|
||||
static var preview: PersistenceController = {
|
||||
let controller = PersistenceController(inMemory: true)
|
||||
let viewContext = controller.container.viewContext
|
||||
|
||||
// Create sample data for previews
|
||||
let split = Split(context: viewContext)
|
||||
split.name = "Upper Body"
|
||||
split.color = "blue"
|
||||
split.systemImage = "dumbbell.fill"
|
||||
split.order = 0
|
||||
|
||||
let exercise = Exercise(context: viewContext)
|
||||
exercise.name = "Bench Press"
|
||||
exercise.sets = 3
|
||||
exercise.reps = 10
|
||||
exercise.weight = 135
|
||||
exercise.order = 0
|
||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
||||
exercise.split = split
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
let nsError = error as NSError
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
|
||||
return controller
|
||||
}()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(inMemory: Bool = false, cloudKitEnabled: Bool = true) {
|
||||
container = NSPersistentCloudKitContainer(name: "Workouts")
|
||||
|
||||
guard let description = container.persistentStoreDescriptions.first else {
|
||||
fatalError("Failed to retrieve a persistent store description.")
|
||||
}
|
||||
|
||||
if inMemory {
|
||||
description.url = URL(fileURLWithPath: "/dev/null")
|
||||
description.cloudKitContainerOptions = nil
|
||||
} else {
|
||||
// Use App Group container for shared storage between iOS and Watch
|
||||
if let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier) {
|
||||
let storeURL = appGroupURL.appendingPathComponent("Workouts.sqlite")
|
||||
description.url = storeURL
|
||||
print("Using shared App Group store at: \(storeURL)")
|
||||
}
|
||||
|
||||
if cloudKitEnabled {
|
||||
// Check if CloudKit is available before enabling
|
||||
let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil
|
||||
|
||||
if cloudKitAvailable {
|
||||
// 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
|
||||
if let error = error as NSError? {
|
||||
// In production, handle this more gracefully
|
||||
print("CoreData error: \(error), \(error.userInfo)")
|
||||
#if DEBUG
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// Configure view context
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
|
||||
// Pin the viewContext to the current generation token
|
||||
do {
|
||||
try container.viewContext.setQueryGenerationFrom(.current)
|
||||
} catch {
|
||||
print("Failed to pin viewContext to the current generation: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Save Context
|
||||
|
||||
func save() {
|
||||
let context = container.viewContext
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
let nsError = error as NSError
|
||||
print("Save error: \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Workouts</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Workouts</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>dev.rzen.indie.Workouts</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,31 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
static func color(from name: String) -> Color {
|
||||
switch name.lowercased() {
|
||||
case "red": return .red
|
||||
case "orange": return .orange
|
||||
case "yellow": return .yellow
|
||||
case "green": return .green
|
||||
case "mint": return .mint
|
||||
case "teal": return .teal
|
||||
case "cyan": return .cyan
|
||||
case "blue": return .blue
|
||||
case "indigo": return .indigo
|
||||
case "purple": return .purple
|
||||
case "pink": return .pink
|
||||
case "brown": return .brown
|
||||
default: return .indigo
|
||||
}
|
||||
}
|
||||
|
||||
func darker(by percentage: CGFloat = 0.2) -> Color {
|
||||
return self.opacity(1.0 - percentage)
|
||||
}
|
||||
}
|
||||
|
||||
// Available colors for splits
|
||||
let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
|
||||
|
||||
// Available system images for splits
|
||||
let availableIcons = ["dumbbell.fill", "figure.strengthtraining.traditional", "figure.run", "figure.hiking", "figure.cooldown", "figure.boxing", "figure.wrestling", "figure.gymnastics", "figure.handball", "figure.core.training", "heart.fill", "bolt.fill"]
|
||||
@@ -1,56 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
func formattedDate() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
func formattedTime() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
func isSameDay(as other: Date) -> Bool {
|
||||
Calendar.current.isDate(self, inSameDayAs: other)
|
||||
}
|
||||
|
||||
func formatDate() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var abbreviatedMonth: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
var dayOfMonth: Int {
|
||||
Calendar.current.component(.day, from: self)
|
||||
}
|
||||
|
||||
var abbreviatedWeekday: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEE"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
func humanTimeInterval(to other: Date) -> String {
|
||||
let interval = other.timeIntervalSince(self)
|
||||
let hours = Int(interval) / 3600
|
||||
let minutes = (Int(interval) % 3600) / 60
|
||||
|
||||
if hours > 0 {
|
||||
return "\(hours)h \(minutes)m"
|
||||
} else {
|
||||
return "\(minutes)m"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,25 @@ import SwiftUI
|
||||
import WatchKit
|
||||
|
||||
struct ExerciseProgressView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@ObservedObject var workoutLog: WorkoutLog
|
||||
/// The shared working workout document owned by the parent. We mutate the
|
||||
/// matching log in place and ask the parent to forward each change through the
|
||||
/// bridge — driving the UI from this doc (not the cache) avoids losing rapid
|
||||
/// taps to the read-after-write race.
|
||||
@Binding var doc: WorkoutDocument
|
||||
let logID: String
|
||||
let onChange: () -> Void
|
||||
|
||||
@State private var currentPage: Int = 0
|
||||
@State private var showingCancelConfirm = false
|
||||
|
||||
private var log: WorkoutLogDocument? {
|
||||
doc.logs.first(where: { $0.id == logID })
|
||||
}
|
||||
|
||||
private var totalSets: Int {
|
||||
max(1, Int(workoutLog.sets))
|
||||
max(1, log?.sets ?? 1)
|
||||
}
|
||||
|
||||
private var totalPages: Int {
|
||||
@@ -29,7 +38,7 @@ struct ExerciseProgressView: View {
|
||||
|
||||
private var firstUnfinishedSetPage: Int {
|
||||
// currentStateIndex is the number of completed sets
|
||||
let completedSets = Int(workoutLog.currentStateIndex)
|
||||
let completedSets = log?.currentStateIndex ?? 0
|
||||
if completedSets >= totalSets {
|
||||
// All done, go to done page
|
||||
return totalPages - 1
|
||||
@@ -86,10 +95,10 @@ struct ExerciseProgressView: View {
|
||||
SetPageView(
|
||||
setNumber: setNumber,
|
||||
totalSets: totalSets,
|
||||
reps: Int(workoutLog.reps),
|
||||
isTimeBased: workoutLog.loadTypeEnum == .duration,
|
||||
durationMinutes: workoutLog.durationMinutes,
|
||||
durationSeconds: workoutLog.durationSeconds
|
||||
reps: log?.reps ?? 0,
|
||||
isTimeBased: LoadType(rawValue: log?.loadType ?? LoadType.weight.rawValue) == .duration,
|
||||
durationMinutes: (log?.durationSeconds ?? 0) / 60,
|
||||
durationSeconds: (log?.durationSeconds ?? 0) % 60
|
||||
)
|
||||
} else {
|
||||
// Rest page (1, 3, 5, ...)
|
||||
@@ -105,50 +114,50 @@ struct ExerciseProgressView: View {
|
||||
let setIndex = (pageIndex + 1) / 2
|
||||
let clampedProgress = min(setIndex, totalSets)
|
||||
|
||||
if clampedProgress != Int(workoutLog.currentStateIndex) {
|
||||
workoutLog.currentStateIndex = Int32(clampedProgress)
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||
guard clampedProgress != doc.logs[i].currentStateIndex else { return }
|
||||
|
||||
if clampedProgress >= totalSets {
|
||||
workoutLog.status = .completed
|
||||
workoutLog.completed = true
|
||||
} else if clampedProgress > 0 {
|
||||
workoutLog.status = .inProgress
|
||||
workoutLog.completed = false
|
||||
}
|
||||
doc.logs[i].currentStateIndex = clampedProgress
|
||||
|
||||
updateWorkoutStatus()
|
||||
try? viewContext.save()
|
||||
|
||||
// Sync to iOS
|
||||
WatchConnectivityManager.shared.syncToiOS()
|
||||
if clampedProgress >= totalSets {
|
||||
doc.logs[i].status = WorkoutStatus.completed.rawValue
|
||||
doc.logs[i].completed = true
|
||||
} else if clampedProgress > 0 {
|
||||
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
||||
doc.logs[i].completed = false
|
||||
}
|
||||
|
||||
recomputeWorkoutStatus()
|
||||
doc.updatedAt = Date()
|
||||
onChange()
|
||||
}
|
||||
|
||||
private func completeExercise() {
|
||||
workoutLog.currentStateIndex = Int32(totalSets)
|
||||
workoutLog.status = .completed
|
||||
workoutLog.completed = true
|
||||
updateWorkoutStatus()
|
||||
try? viewContext.save()
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||
doc.logs[i].currentStateIndex = totalSets
|
||||
doc.logs[i].status = WorkoutStatus.completed.rawValue
|
||||
doc.logs[i].completed = true
|
||||
|
||||
// Sync to iOS
|
||||
WatchConnectivityManager.shared.syncToiOS()
|
||||
recomputeWorkoutStatus()
|
||||
doc.updatedAt = Date()
|
||||
onChange()
|
||||
}
|
||||
|
||||
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 }
|
||||
private func recomputeWorkoutStatus() {
|
||||
let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
|
||||
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
|
||||
let anyInProgress = statuses.contains { $0 == .inProgress }
|
||||
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
|
||||
|
||||
if allCompleted {
|
||||
workout.status = .completed
|
||||
workout.end = Date()
|
||||
doc.status = WorkoutStatus.completed.rawValue
|
||||
doc.end = Date()
|
||||
} else if anyInProgress || !allNotStarted {
|
||||
workout.status = .inProgress
|
||||
doc.status = WorkoutStatus.inProgress.rawValue
|
||||
doc.end = nil
|
||||
} else {
|
||||
workout.status = .notStarted
|
||||
doc.status = WorkoutStatus.notStarted.rawValue
|
||||
doc.end = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,7 +215,8 @@ struct RestPageView: View {
|
||||
let restNumber: Int
|
||||
|
||||
@State private var elapsedSeconds: Int = 0
|
||||
@State private var timer: Timer?
|
||||
|
||||
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
@@ -224,11 +234,12 @@ struct RestPageView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear {
|
||||
startTimer()
|
||||
elapsedSeconds = 0
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
}
|
||||
.onDisappear {
|
||||
stopTimer()
|
||||
.onReceive(ticker) { _ in
|
||||
elapsedSeconds += 1
|
||||
checkHapticPing()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,19 +249,6 @@ struct RestPageView: View {
|
||||
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.
|
||||
|
||||
@@ -6,26 +6,51 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
|
||||
struct WorkoutLogListView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(WatchConnectivityBridge.self) private var bridge
|
||||
|
||||
@ObservedObject var workout: Workout
|
||||
/// The split this workout came from (read-only on the watch), used to offer
|
||||
/// additional exercises that aren't logged yet.
|
||||
@Query private var matchingSplits: [Split]
|
||||
|
||||
/// Working copy of the workout. We drive the UI from this and mutate it on
|
||||
/// every edit (then forward through the bridge) to avoid the read-after-write
|
||||
/// race against the cache, which lags local writes by a beat.
|
||||
@State private var doc: WorkoutDocument
|
||||
|
||||
@State private var showingExercisePicker = false
|
||||
@State private var selectedLog: WorkoutLog?
|
||||
@State private var selectedLogID: String?
|
||||
|
||||
var sortedWorkoutLogs: [WorkoutLog] {
|
||||
workout.logsArray
|
||||
init(workout: Workout) {
|
||||
_doc = State(initialValue: WorkoutDocument(from: workout))
|
||||
if let splitID = workout.splitID {
|
||||
_matchingSplits = Query(filter: #Predicate<Split> { $0.id == splitID })
|
||||
} else {
|
||||
// No source split: never match anything.
|
||||
_matchingSplits = Query(filter: #Predicate<Split> { _ in false })
|
||||
}
|
||||
}
|
||||
|
||||
private var split: Split? { matchingSplits.first }
|
||||
|
||||
private var sortedLogs: [WorkoutLogDocument] {
|
||||
doc.logs.sorted { $0.order < $1.order }
|
||||
}
|
||||
|
||||
private var availableExercises: [Exercise] {
|
||||
guard let split else { return [] }
|
||||
let existingNames = Set(doc.logs.map { $0.exerciseName })
|
||||
return split.exercisesArray.filter { !existingNames.contains($0.name) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(header: Text(workout.label)) {
|
||||
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
|
||||
Section(header: Text(label)) {
|
||||
ForEach(sortedLogs) { log in
|
||||
Button {
|
||||
selectedLog = log
|
||||
selectedLogID = log.id
|
||||
} label: {
|
||||
WorkoutLogRowLabel(log: log)
|
||||
}
|
||||
@@ -33,42 +58,81 @@ struct WorkoutLogListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
showingExercisePicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Add Exercise")
|
||||
if !availableExercises.isEmpty {
|
||||
Section {
|
||||
Button {
|
||||
showingExercisePicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Add Exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if sortedWorkoutLogs.isEmpty {
|
||||
if sortedLogs.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Exercises",
|
||||
systemImage: "figure.strengthtraining.traditional",
|
||||
description: Text("Tap + to add exercises.")
|
||||
description: Text(availableExercises.isEmpty
|
||||
? "No exercises in this workout."
|
||||
: "Tap + to add exercises.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle(workout.split?.name ?? Split.unnamed)
|
||||
.navigationDestination(item: $selectedLog) { log in
|
||||
ExerciseProgressView(workoutLog: log)
|
||||
.navigationTitle(doc.splitName ?? Split.unnamed)
|
||||
.navigationDestination(item: $selectedLogID) { logID in
|
||||
ExerciseProgressView(doc: $doc, logID: logID, onChange: { bridge.update(workout: doc) })
|
||||
}
|
||||
.sheet(isPresented: $showingExercisePicker) {
|
||||
ExercisePickerView(workout: workout)
|
||||
ExercisePickerView(exercises: availableExercises) { exercise in
|
||||
addExercise(exercise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
let start = doc.start
|
||||
if doc.status == WorkoutStatus.completed.rawValue, let end = doc.end {
|
||||
if start.isSameDay(as: end) {
|
||||
return "\(start.formattedDate())—\(end.formattedTime())"
|
||||
} else {
|
||||
return "\(start.formattedDate())—\(end.formattedDate())"
|
||||
}
|
||||
}
|
||||
return start.formattedDate()
|
||||
}
|
||||
|
||||
private func addExercise(_ exercise: Exercise) {
|
||||
let newLog = WorkoutLogDocument(
|
||||
id: ULID.make(),
|
||||
exerciseName: exercise.name,
|
||||
order: doc.logs.count,
|
||||
sets: exercise.sets,
|
||||
reps: exercise.reps,
|
||||
weight: exercise.weight,
|
||||
loadType: exercise.loadType,
|
||||
durationSeconds: exercise.durationTotalSeconds,
|
||||
currentStateIndex: 0,
|
||||
completed: false,
|
||||
status: WorkoutStatus.notStarted.rawValue,
|
||||
notes: nil,
|
||||
date: doc.start
|
||||
)
|
||||
doc.logs.append(newLog)
|
||||
doc.updatedAt = Date()
|
||||
bridge.update(workout: doc)
|
||||
showingExercisePicker = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout Log Row Label
|
||||
|
||||
struct WorkoutLogRowLabel: View {
|
||||
@ObservedObject var log: WorkoutLog
|
||||
let log: WorkoutLogDocument
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
@@ -89,8 +153,12 @@ struct WorkoutLogRowLabel: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var status: WorkoutStatus {
|
||||
WorkoutStatus(rawValue: log.status) ?? .notStarted
|
||||
}
|
||||
|
||||
private var statusIcon: Image {
|
||||
switch log.status {
|
||||
switch status {
|
||||
case .completed:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
case .inProgress:
|
||||
@@ -103,7 +171,7 @@ struct WorkoutLogRowLabel: View {
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch log.status {
|
||||
switch status {
|
||||
case .completed:
|
||||
.green
|
||||
case .inProgress:
|
||||
@@ -116,9 +184,9 @@ struct WorkoutLogRowLabel: View {
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if log.loadTypeEnum == .duration {
|
||||
let mins = log.durationMinutes
|
||||
let secs = log.durationSeconds
|
||||
if LoadType(rawValue: log.loadType) == .duration {
|
||||
let mins = log.durationSeconds / 60
|
||||
let secs = log.durationSeconds % 60
|
||||
if mins > 0 && secs > 0 {
|
||||
return "\(log.sets) × \(mins)m \(secs)s"
|
||||
} else if mins > 0 {
|
||||
@@ -135,27 +203,22 @@ struct WorkoutLogRowLabel: View {
|
||||
// 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) }
|
||||
}
|
||||
let exercises: [Exercise]
|
||||
let onSelect: (Exercise) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if availableExercises.isEmpty {
|
||||
if exercises.isEmpty {
|
||||
Text("All exercises added")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(availableExercises, id: \.objectID) { exercise in
|
||||
ForEach(exercises) { exercise in
|
||||
Button {
|
||||
addExercise(exercise)
|
||||
onSelect(exercise)
|
||||
dismiss()
|
||||
} label: {
|
||||
VStack(alignment: .leading) {
|
||||
Text(exercise.name)
|
||||
@@ -179,35 +242,8 @@ struct ExercisePickerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if exercise.loadTypeEnum == .duration {
|
||||
let mins = exercise.durationMinutes
|
||||
let secs = exercise.durationSeconds
|
||||
if mins > 0 && secs > 0 {
|
||||
@@ -222,8 +258,3 @@ struct ExercisePickerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WorkoutLogListView(workout: Workout())
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||
}
|
||||
|
||||
@@ -6,22 +6,17 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
|
||||
struct WorkoutLogsView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@EnvironmentObject var connectivityManager: WatchConnectivityManager
|
||||
@Environment(WatchConnectivityBridge.self) private var bridge
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \Workout.start, ascending: false)],
|
||||
animation: .default
|
||||
)
|
||||
private var workouts: FetchedResults<Workout>
|
||||
@Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(workouts, id: \.objectID) { workout in
|
||||
ForEach(workouts) { workout in
|
||||
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
|
||||
WorkoutRow(workout: workout)
|
||||
}
|
||||
@@ -40,12 +35,17 @@ struct WorkoutLogsView: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
connectivityManager.requestSync()
|
||||
bridge.requestSync()
|
||||
} label: {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if workouts.isEmpty {
|
||||
bridge.requestSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,11 +53,11 @@ struct WorkoutLogsView: View {
|
||||
// MARK: - Workout Row
|
||||
|
||||
struct WorkoutRow: View {
|
||||
@ObservedObject var workout: Workout
|
||||
let workout: Workout
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(workout.split?.name ?? Split.unnamed)
|
||||
Text(workout.splitName ?? Split.unnamed)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -92,8 +92,3 @@ struct WorkoutRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WorkoutLogsView()
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftData
|
||||
|
||||
/// Composition root for the watch app. Owns the local SwiftData cache and the
|
||||
/// WatchConnectivity bridge. The watch has no iCloud access; all data arrives from
|
||||
/// the phone via the bridge.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class WatchAppServices {
|
||||
let container: ModelContainer
|
||||
let bridge: WatchConnectivityBridge
|
||||
|
||||
init() {
|
||||
let container = WorkoutsModelContainer.make()
|
||||
self.container = container
|
||||
self.bridge = WatchConnectivityBridge(container: container)
|
||||
}
|
||||
|
||||
func activate() {
|
||||
bridge.activate()
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array/>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</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>
|
||||
</plist>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>_XCCurrentVersionName</key>
|
||||
<string>Workouts.xcdatamodel</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,46 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23231" systemVersion="24D5034f" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Split" representedClassName="Split" syncable="YES">
|
||||
<attribute name="color" attributeType="String" defaultValueString="indigo"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="systemImage" attributeType="String" defaultValueString="dumbbell.fill"/>
|
||||
<relationship name="exercises" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Exercise" inverseName="split" inverseEntity="Exercise"/>
|
||||
<relationship name="workouts" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Workout" inverseName="split" inverseEntity="Workout"/>
|
||||
</entity>
|
||||
<entity name="Exercise" representedClassName="Exercise" syncable="YES">
|
||||
<attribute name="duration" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
||||
<attribute name="loadType" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="order" 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="weight" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="weightLastUpdated" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
||||
<attribute name="weightReminderTimeIntervalWeeks" attributeType="Integer 32" defaultValueString="2" usesScalarValueType="YES"/>
|
||||
<relationship name="split" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Split" inverseName="exercises" inverseEntity="Split"/>
|
||||
</entity>
|
||||
<entity name="Workout" representedClassName="Workout" syncable="YES">
|
||||
<attribute name="end" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="start" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
||||
<attribute name="status" attributeType="String" defaultValueString="notStarted"/>
|
||||
<relationship name="logs" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="WorkoutLog" inverseName="workout" inverseEntity="WorkoutLog"/>
|
||||
<relationship name="split" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Split" inverseName="workouts" inverseEntity="Split"/>
|
||||
</entity>
|
||||
<entity name="WorkoutLog" representedClassName="WorkoutLog" syncable="YES">
|
||||
<attribute name="completed" attributeType="Boolean" defaultValueString="NO" 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="duration" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
||||
<attribute name="elapsedSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<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="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="status" optional="YES" attributeType="String" defaultValueString="notStarted"/>
|
||||
<attribute name="weight" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="workout" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Workout" inverseName="logs" inverseEntity="Workout"/>
|
||||
</entity>
|
||||
</model>
|
||||
@@ -1,31 +1,23 @@
|
||||
//
|
||||
// WorkoutsApp.swift
|
||||
// Workouts Watch App
|
||||
// WorkoutsApp.swift
|
||||
// Workouts Watch App
|
||||
//
|
||||
// Created by rzen on 8/13/25 at 11:10 AM.
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct WorkoutsWatchApp: App {
|
||||
let persistenceController = PersistenceController.shared
|
||||
let connectivityManager = WatchConnectivityManager.shared
|
||||
|
||||
init() {
|
||||
// Set up iPhone connectivity with Core Data context
|
||||
connectivityManager.setViewContext(persistenceController.viewContext)
|
||||
}
|
||||
@State private var services = WatchAppServices()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
||||
.environmentObject(connectivityManager)
|
||||
.environment(services.bridge)
|
||||
.modelContainer(services.container)
|
||||
.task { services.activate() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user