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:
2026-06-19 14:25:27 -04:00
parent 9a881e841b
commit 85d0eaddbb
86 changed files with 3873 additions and 3755 deletions
+4 -1
View File
@@ -13,7 +13,10 @@
"Bash(log show:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)"
"Bash(git push:*)",
"Bash(git rev-list:*)",
"Bash(git describe:*)",
"Bash(git rev-parse:*)"
],
"deny": [],
"ask": []
+11
View File
@@ -0,0 +1,11 @@
# Copy to .env.release (gitignored) and fill in. Used by Scripts/release.sh.
#
# Apple Developer team that signs the build (same as DEVELOPMENT_TEAM).
APPLE_TEAM_ID=
# App Store Connect API key (App Store Connect > Users and Access > Integrations > App Store Connect API).
# Create a key with "App Manager" access, download the .p8 ONCE, and store it somewhere safe.
ASC_KEY_ID=
ASC_ISSUER_ID=
# Absolute path to the downloaded AuthKey_XXXXXXXXXX.p8 file.
ASC_KEY_PATH=
+21
View File
@@ -0,0 +1,21 @@
## XcodeGen output (generated from project.yml)
*.xcodeproj/
## macOS / Xcode
.DS_Store
xcuserdata/
*.xcuserstate
DerivedData/
build/
## Swift Package Manager
.swiftpm/
Packages/
Package.resolved
## Release / TestFlight
.env.release
*.p8
## Misc
*.log
+20
View File
@@ -0,0 +1,20 @@
# Changelog
All notable changes to this project are documented here.
## June 2026
- **2.0** — Re-platformed persistence onto an iCloud Drive document architecture:
JSON files in iCloud Drive are now the sole source of truth, with a rebuildable
SwiftData cache populated by an `NSMetadataQuery` observer. Removed
CloudKit/`NSPersistentCloudKitContainer` and the App-Group store.
- Rebuilt the Apple Watch sync on a new WatchConnectivity bridge keyed by stable
ULIDs (the phone is the sole writer of iCloud Drive).
- Migrated the project to XcodeGen; iOS 26 / watchOS 26, Swift 6 strict
concurrency.
- Splits ship as on-demand starter data generated from the bundled exercise
catalogs.
- Stored exercise/log durations as integer seconds (was a `Date` epoch hack).
- Fixed: workout marked complete on creation, an undismissable delete dialog,
toolbar buttons hidden by nested navigation stacks, and a placeholder
"Settings coming soon" row.
+3
View File
@@ -0,0 +1,3 @@
# License
Copyright 2026 Rouslan Zenetl. All rights reserved.
+42
View File
@@ -0,0 +1,42 @@
# Workouts
A workout tracking app for iPhone and Apple Watch. Build workout splits, run
sessions, and track your progress — with your data stored as plain JSON files in
your own iCloud Drive.
## Key Features
- **Workout splits** — organize exercises into reusable routines with custom
colors and icons. Start with built-in starter splits (Upper Body / Core /
Lower Body) generated from a bundled exercise catalog.
- **Exercise library** — a bundled catalog of starter exercises (bodyweight and
machine-based) to populate your splits.
- **Run a workout** — start a session from a split, track sets/reps/weight or
timed exercises, and mark exercises complete.
- **Progress tracking** — weight-progression charts per exercise across past
sessions.
- **Apple Watch companion** — start and run workouts from the wrist; changes sync
back to the phone.
- **iCloud Drive sync** — your data lives as human-readable JSON in your iCloud
Drive, synced across devices and visible in the Files app. iCloud is required.
## Architecture
iCloud Drive JSON documents are the **sole source of truth**; a local SwiftData
store is a rebuildable read-through cache populated exclusively by an
`NSMetadataQuery` observer (one-way flow: files → observer → cache). The phone is
the only device that touches iCloud Drive; the Apple Watch is a thin remote that
round-trips workout changes through the phone via WatchConnectivity.
See `REQUIREMENTS.md` for the data model and `CLAUDE.md` for project guidance.
## Building
The Xcode project is generated with [XcodeGen](https://github.com/yonaskolb/XcodeGen):
```sh
xcodegen generate
open Workouts.xcodeproj
```
Requires Xcode 26 (iOS 26 / watchOS 26, Swift 6).
+58 -173
View File
@@ -1,187 +1,72 @@
# Workouts App Requirements
## Overview
A workout tracking iOS application with Apple Watch companion that helps users manage workout splits, track exercise logs, and sync data across devices using CloudKit.
A workout tracking iOS app with an Apple Watch companion that helps users manage
workout splits, run sessions, and track progress. Data is stored as JSON
documents in the user's iCloud Drive and synced across devices.
## Platform Requirements
- iOS app (iPhone)
- watchOS app (Apple Watch companion)
- CloudKit sync between devices
- SwiftData for persistence with CloudKit automatic sync
- iOS app (iPhone) — iOS 26+
- watchOS app (Apple Watch companion) — watchOS 26+
- Swift 6, strict concurrency; SwiftUI throughout
- Project generated with XcodeGen (`project.yml`)
## Core Data Models
## Persistence Architecture
- **iCloud Drive JSON documents are the sole source of truth.** One JSON file per
aggregate root under the app's iCloud container `Documents/`:
- `Splits/<ULID>.json` — a `SplitDocument` embedding its `[ExerciseDocument]`
- `Workouts/YYYY/MM/<ULID>.json` — a `WorkoutDocument` embedding its `[WorkoutLogDocument]`
- `Stubs/<ULID>.json` — soft-delete tombstones (30-day GC)
- **SwiftData is a rebuildable read-through cache.** App code never writes the
cache directly: every save/delete writes a file; an `NSMetadataQuery` observer
(and a connect-time reconcile) is the sole cache mutator. The cache is wiped and
rebuilt on schema-version bump or iCloud-account change.
- **No CloudKit.** Container: `iCloud.dev.rzen.indie.Workouts` (CloudDocuments).
- **iCloud is required** — without it the app shows a blocking "iCloud Required"
gate (no local-only fallback).
### Split
- `name: String` - Name of the workout split
- `color: String` - Color theme for the split
- `systemImage: String` - SF Symbol icon
- `order: Int` - Display order
- Relationship: One-to-many with Exercise
## Data Model
- **Split**: `id` (ULID), `name`, `color`, `systemImage`, `order`, timestamps;
embeds Exercises.
- **Exercise**: `id`, `name`, `order`, `sets`, `reps`, `weight`, `loadType`
(none/weight/duration), `durationTotalSeconds`, `weightLastUpdated`,
`weightReminderTimeIntervalWeeks`.
- **Workout**: `id` (ULID), `splitID`/`splitName` (denormalized), `start`, `end?`,
`status` (notStarted/inProgress/completed/skipped), timestamps; embeds logs.
- **WorkoutLog**: `id`, `exerciseName`, `order`, `sets`, `reps`, `weight`,
`loadType`, `durationTotalSeconds`, `currentStateIndex`, `completed`, `status`,
`notes?`, `date`.
### Exercise
- `name: String` - Exercise name
- `order: Int` - Order within split
- `sets: Int` - Number of sets
- `reps: Int` - Number of repetitions
- `weight: Int` - Weight amount
- `loadType: Int` - Type of load (weight units)
- Relationship: Many-to-one with Split
Identity is a ULID string (stable across cache rebuilds). Duration is stored as
integer seconds.
### Workout
- `start: Date` - Workout start time
- `end: Date?` - Workout end time (optional)
- `status: WorkoutStatus` - Enum (notStarted, inProgress, completed, skipped)
- Relationship: Many-to-one with Split (optional)
- Relationship: One-to-many with WorkoutLog
## Apple Watch
The phone is the only device that touches iCloud Drive. The watch keeps its own
local SwiftData cache fed only by WatchConnectivity:
- Phone → Watch: pushes all splits + recent workouts (application context) on
every cache change.
- Watch → Phone: sends an updated `WorkoutDocument` (keyed by ULID); the phone
applies it through the file write path (the sole writer of iCloud Drive).
### WorkoutLog
- `exerciseName: String` - Name of the exercise
- `date: Date` - When performed
- `order: Int` - Order in workout
- `sets: Int` - Number of sets completed
- `reps: Int` - Number of reps completed
- `weight: Int` - Weight used
- `status: WorkoutStatus?` - Completion status
- `completed: Bool` - Whether exercise was completed
- Relationship: Many-to-one with Workout
## Seed Data
Starter splits (Upper Body / Core / Lower Body) are generated on demand from the
bundled YAML exercise catalogs (`Workouts/Resources/*.exercises.yaml`) — never
auto-seeded. The catalogs also back the in-workout exercise picker.
## iOS App Features
### Main Navigation (TabView)
1. **Workouts Tab**
- List all workouts sorted by start date (newest first)
- Create new workout from split
- Edit/delete existing workouts
- Navigate to workout logs
2. **Logs Tab**
- Historical exercise completion records
- Grouped by workout
3. **Reports Tab**
- Workout statistics and progress tracking
4. **Achievements Tab**
- User achievements and milestones
### Split Management
- Create/edit/delete workout splits
- Assign colors and system images
- Reorder splits
- Add/remove/reorder exercises within splits
### Exercise Management
- Add exercises to splits
- Set default sets, reps, weight
- Reorderable list within each split
- Support for different load types
### Workout Flow
- Start workout from selected split
- Auto-populate exercises from split template
- Track exercise completion status
- Update sets/reps/weight during workout
- Mark exercises as completed/skipped
- Auto-prevent device sleep during workouts
## Watch App Features
### Main View
- List active workouts
- Show "No Workouts" state when empty
- Sync button to pull from CloudKit
- Display sync status and last sync time
### Workout Display
- Show workout name and status
- List exercises with completion indicators
- Quick completion toggles
### CloudKit Sync
- Manual sync trigger
- Hybrid approach: SwiftData for local storage + direct CloudKit API for sync
- Sync from `com.apple.coredata.cloudkit.zone`
- Fetch and save Splits, Exercises, Workouts, and WorkoutLogs
## Data Management
### Exercise Library
- YAML-based exercise definitions in `Resources/` directory
- `ExerciseListLoader` to parse YAML files
- Preset libraries:
- Bodyweight exercises (`bodyweight-exercises.yaml`)
- Planet Fitness starter (`pf-starter-exercises.yaml`)
### CloudKit Configuration
- Container ID: `iCloud.com.dev.rzen.indie.WorkoutsV2`
- Automatic sync via `ModelConfiguration(cloudKitDatabase: .automatic)`
- Development environment for debug builds
- Production environment for release/TestFlight builds
## UI/UX Requirements
### Design Patterns
- SwiftUI throughout
- NavigationStack for hierarchical navigation
- Form-based editing interfaces
- Sheet presentations for modal operations
- Swipe gestures for edit/delete/complete actions
### Visual Design
- Custom color system via Color extensions
- Consistent use of SF Symbols
- Reorderable lists using SwiftUIReorderableForEach
- Calendar-style list items for workouts
- Checkbox components for completion tracking
### Key UI Components
- `CalendarListItem` - Formatted date/time display
- `Checkbox` - Visual completion indicators
- `SplitListItemView` - Split display with icon and color
- `ExerciseListItemView` - Exercise display with sets/reps/weight
- `WorkoutLogListItemView` - Log entry display
## Technical Architecture
### Project Structure
```
Workouts/
├── Models/ # SwiftData models
├── Schema/ # Database schema and migrations
├── Views/ # UI components by feature
│ ├── Splits/
│ ├── Exercises/
│ ├── Workouts/
│ └── WorkoutLog/
├── Utils/ # Shared utilities
└── Resources/ # YAML exercise definitions
Workouts Watch App/
├── Schema/ # Watch-specific container
├── Views/ # Watch UI components
└── CloudKitSyncManager.swift # Direct CloudKit sync
```
### Key Technical Decisions
- SwiftData with CloudKit automatic sync
- Versioned schema with migration support
- Single file per model convention
- No circular relationships in data model
- Platform-specific implementations for iOS/watchOS
- Shared model definitions between platforms
## Features
- Create/edit/delete/reorder workout splits with custom colors and SF Symbol icons.
- Add exercises to splits (from the bundled catalog or manually); set default
sets/reps/weight or a duration.
- Start a workout from a split; track per-exercise set/rest/done progress; mark
complete/skipped; edit the plan during a session (mirrored back to the split).
- Weight-progression charts per exercise across past sessions.
- Apple Watch: run workouts from the wrist; changes sync back to the phone.
## Dependencies
- **Yams**: YAML parsing for exercise definitions
- **SwiftUIReorderableForEach**: Drag-to-reorder lists
- **Yams** YAML parsing for the exercise catalogs.
- **IndieAbout** — About section in Settings (bundles LICENSE.md / CHANGELOG.md).
## Entitlements Required
- CloudKit
- Push Notifications (aps-environment)
- App Sandbox
- File access (read-only for user-selected files)
## Build Configuration
- Swift Package Manager for dependencies
- Separate targets for iOS and watchOS
- Shared CloudKit container between apps
- Automatic provisioning and code signing
## Release
- TestFlight / App Store via `Scripts/release.sh` (xcodebuild archive +
App Store Connect API upload; credentials in `.env.release`). The iOS archive
embeds the watch app.
+23
View File
@@ -0,0 +1,23 @@
<?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>
<!-- TestFlight / App Store distribution for the iOS target. -->
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>C32Z8JNLG6</string>
<!-- destination=upload makes -exportArchive push the build straight to
App Store Connect instead of writing a local .ipa. -->
<key>destination</key>
<string>upload</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadSymbols</key>
<true/>
<key>stripSwiftSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env swift
//
// asc-metadata.swift push App Store listing metadata from local files to
// App Store Connect via the API. Run before Scripts/asc-submit.swift.
//
// Usage: source .env.release && swift Scripts/asc-metadata.swift [options]
//
// Options:
// --version <X> Marketing version to write (default: CFBundleShort-
// VersionString from Workouts/Resources/Info-iOS.plist). Created if absent.
// --bundle <id> Bundle identifier (default: dev.rzen.indie.Workouts).
// --dir <path> Metadata folder (default: Scripts/metadata).
// --dry-run Print what would change; make no writes.
//
// Folder layout (everything is optional only what's present is pushed):
//
// metadata/
// app/
// categories.json {"primary":"HEALTH_AND_FITNESS","secondary":null}
// <locale>/ e.g. en-US
// name.txt app name (30) -> appInfoLocalizations
// subtitle.txt subtitle (30)
// privacy_url.txt privacy policy URL
// version/
// review.json {"contactFirstName":..,"contactEmail":..,"notes":..}
// <locale>/
// description.txt description (4000) -> appStoreVersionLocalizations
// keywords.txt comma-separated (100)
// promotional_text.txt promo text (170)
// whats_new.txt release notes (4000) (omit for first version)
// marketing_url.txt
// support_url.txt
// screenshots/
// <locale>/
// <DISPLAY_TYPE>/ e.g. APP_IPHONE_67, APP_WATCH_ULTRA
// 01_first.png 02_second.png (sorted by name; the local set is
// authoritative existing remote
// shots in that set are replaced)
//
// What it pushes: app name / subtitle / privacy URL / categories, version
// description / keywords / promo / what's-new / URLs, screenshots, and the
// review contact details. NOT here (separate resource families): age rating,
// pricing/availability, and App Privacy labels.
//
// Reads ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH from the environment.
//
import Foundation
import CryptoKit
func die(_ msg: String) -> Never {
FileHandle.standardError.write((msg + "\n").data(using: .utf8)!)
exit(1)
}
func b64url(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
// ---- arguments --------------------------------------------------------------
var bundleID = "dev.rzen.indie.Workouts"
var versionArg: String?
var metaDir = "Scripts/metadata"
var dryRun = false
do {
let args = Array(CommandLine.arguments.dropFirst())
var i = 0
func value(_ flag: String) -> String {
i += 1
guard i < args.count else { die("\(flag) needs a value") }
return args[i]
}
while i < args.count {
switch args[i] {
case "--bundle": bundleID = value("--bundle")
case "--version": versionArg = value("--version")
case "--dir": metaDir = value("--dir")
case "--dry-run": dryRun = true
case "--help", "-h":
print("usage: swift Scripts/asc-metadata.swift [--version X] [--bundle id] [--dir path] [--dry-run]")
exit(0)
default: die("unknown argument: \(args[i])")
}
i += 1
}
}
let versionString: String = versionArg ?? {
let path = "Workouts/Resources/Info-iOS.plist"
guard let data = FileManager.default.contents(atPath: path),
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
let v = plist["CFBundleShortVersionString"] as? String else {
die("could not read CFBundleShortVersionString from \(path); pass --version")
}
return v
}()
// ---- ES256 JWT (fresh per request) ------------------------------------------
let env = ProcessInfo.processInfo.environment
guard let keyID = env["ASC_KEY_ID"], let issuer = env["ASC_ISSUER_ID"], let keyPath = env["ASC_KEY_PATH"] else {
die("missing ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH (source .env.release first)")
}
guard let pem = try? String(contentsOfFile: keyPath, encoding: .utf8) else { die("cannot read key at \(keyPath)") }
guard let signingKey = try? P256.Signing.PrivateKey(pemRepresentation: pem) else { die("could not parse EC key at \(keyPath)") }
func token() -> String {
let now = Int(Date().timeIntervalSince1970)
let header = "{\"alg\":\"ES256\",\"kid\":\"\(keyID)\",\"typ\":\"JWT\"}"
let payload = "{\"iss\":\"\(issuer)\",\"iat\":\(now),\"exp\":\(now + 1200),\"aud\":\"appstoreconnect-v1\"}"
let signingInput = b64url(Data(header.utf8)) + "." + b64url(Data(payload.utf8))
guard let sig = try? signingKey.signature(for: Data(signingInput.utf8)) else { die("signing failed") }
return signingInput + "." + b64url(sig.rawRepresentation)
}
// ---- API plumbing -----------------------------------------------------------
let api = "https://api.appstoreconnect.apple.com/v1"
@discardableResult
func request(_ method: String, _ urlStr: String, body: Data? = nil) -> (Int, Data) {
guard let url = URL(string: urlStr) else { die("bad url: \(urlStr)") }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer \(token())", forHTTPHeaderField: "Authorization")
if let body {
req.httpBody = body
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let sem = DispatchSemaphore(value: 0)
var status = 0
var data = Data()
URLSession.shared.dataTask(with: req) { d, resp, err in
if let err { die("network error: \(err)") }
status = (resp as? HTTPURLResponse)?.statusCode ?? 0
data = d ?? Data()
sem.signal()
}.resume()
sem.wait()
return (status, data)
}
func parse(_ data: Data) -> [String: Any]? { try? JSONSerialization.jsonObject(with: data) as? [String: Any] }
func bodyText(_ data: Data) -> String { String(data: data, encoding: .utf8) ?? "" }
func dataArray(_ data: Data) -> [[String: Any]] { (parse(data)?["data"] as? [[String: Any]]) ?? [] }
func dataObject(_ data: Data) -> [String: Any]? { parse(data)?["data"] as? [String: Any] }
func attrs(_ obj: [String: Any]) -> [String: Any] { (obj["attributes"] as? [String: Any]) ?? [:] }
func intval(_ any: Any?) -> Int { (any as? Int) ?? (any as? NSNumber)?.intValue ?? 0 }
/// Build a JSON:API request body. Text goes through JSONSerialization, so
/// quotes / newlines in a description are escaped correctly.
func apiBody(type: String, id: String? = nil, attributes: [String: Any]? = nil, relationships: [String: Any]? = nil) -> Data {
var data: [String: Any] = ["type": type]
if let id { data["id"] = id }
if let attributes, !attributes.isEmpty { data["attributes"] = attributes }
if let relationships, !relationships.isEmpty { data["relationships"] = relationships }
return (try? JSONSerialization.data(withJSONObject: ["data": data])) ?? Data()
}
func toOne(_ type: String, _ id: String) -> [String: Any] { ["data": ["type": type, "id": id]] }
// ---- local files ------------------------------------------------------------
let fm = FileManager.default
func fileText(_ path: String) -> String? {
guard let data = fm.contents(atPath: path), let s = String(data: data, encoding: .utf8) else { return nil }
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
func subdirs(_ path: String) -> [String] {
((try? fm.contentsOfDirectory(atPath: path)) ?? [])
.filter { var d: ObjCBool = false; return fm.fileExists(atPath: "\(path)/\($0)", isDirectory: &d) && d.boolValue }
.sorted()
}
func files(_ path: String) -> [String] {
((try? fm.contentsOfDirectory(atPath: path)) ?? [])
.filter { !$0.hasPrefix(".") }
.filter { var d: ObjCBool = false; return fm.fileExists(atPath: "\(path)/\($0)", isDirectory: &d) && !d.boolValue }
.sorted()
}
func md5hex(_ data: Data) -> String { Insecure.MD5.hash(data: data).map { String(format: "%02x", $0) }.joined() }
/// Collect non-nil attributes from (file -> attribute) pairs in a locale dir.
func attributesFrom(_ dir: String, _ map: [(file: String, attr: String)]) -> [String: Any] {
var out: [String: Any] = [:]
for m in map { if let v = fileText("\(dir)/\(m.file)") { out[m.attr] = v } }
return out
}
func patchOrCreate(_ label: String, type: String, existingID: String?,
attributes: [String: Any], relationships: [String: Any]) {
if attributes.isEmpty { return }
if dryRun {
print(" ~ \(label): \(attributes.keys.sorted().joined(separator: ", ")) \(existingID == nil ? "(create)" : "(update)")")
return
}
let (status, data): (Int, Data)
if let id = existingID {
(status, data) = request("PATCH", "\(api)/\(type)/\(id)", body: apiBody(type: type, id: id, attributes: attributes))
} else {
(status, data) = request("POST", "\(api)/\(type)", body: apiBody(type: type, attributes: attributes, relationships: relationships))
}
guard status == 200 || status == 201 else { die("\(label): write failed (status \(status)): \(bodyText(data))") }
print("\(label): \(attributes.keys.sorted().joined(separator: ", "))")
}
// ---- 1. find the app --------------------------------------------------------
let (appStatus, appData) = request("GET", "\(api)/apps?filter%5BbundleId%5D=\(bundleID)&fields%5Bapps%5D=bundleId,name")
guard appStatus == 200, let app = dataArray(appData).first, let appID = app["id"] as? String else {
die("app not found for bundle \(bundleID) (status \(appStatus)): \(bodyText(appData))")
}
print("App: \(bundleID) (\(appID)) • version \(versionString)\(dryRun ? " [DRY RUN]" : "")")
// ---- 2. resolve the editable appInfo (app-level metadata lives here) --------
let liveStates: Set<String> = ["READY_FOR_SALE", "REMOVED_FROM_SALE", "DEVELOPER_REMOVED_FROM_SALE", "REPLACED_WITH_NEW_INFO", "NOT_APPLICABLE"]
let (aiStatus, aiData) = request("GET", "\(api)/apps/\(appID)/appInfos")
guard aiStatus == 200 else { die("could not list appInfos (status \(aiStatus)): \(bodyText(aiData))") }
let appInfos = dataArray(aiData)
func appInfoState(_ o: [String: Any]) -> String { (attrs(o)["state"] ?? attrs(o)["appStoreState"]) as? String ?? "" }
guard let appInfo = appInfos.first(where: { !liveStates.contains(appInfoState($0)) }) ?? appInfos.first,
let appInfoID = appInfo["id"] as? String else {
die("no editable appInfo found")
}
// ---- 3. find or create the editable App Store version -----------------------
let (vStatus, vData) = request("GET", "\(api)/apps/\(appID)/appStoreVersions?filter%5Bplatform%5D=IOS&filter%5BversionString%5D=\(versionString)&fields%5BappStoreVersions%5D=versionString&limit=1")
guard vStatus == 200 else { die("could not query versions (status \(vStatus)): \(bodyText(vData))") }
var versionID = dataArray(vData).first?["id"] as? String
if versionID == nil {
if dryRun {
print("App Store version \(versionString): would create")
versionID = "<new>"
} else {
let (s, d) = request("POST", "\(api)/appStoreVersions",
body: apiBody(type: "appStoreVersions",
attributes: ["platform": "IOS", "versionString": versionString],
relationships: ["app": toOne("apps", appID)]))
guard s == 201 || s == 200, let id = dataObject(d)?["id"] as? String else {
die("could not create App Store version \(versionString) (status \(s)): \(bodyText(d))")
}
versionID = id
print("🆕 created App Store version \(versionString) (\(id))")
}
}
let vID = versionID!
// ---- 4. categories (app-level) ----------------------------------------------
if let cData = fm.contents(atPath: "\(metaDir)/app/categories.json"),
let cats = try? JSONSerialization.jsonObject(with: cData) as? [String: Any] {
var rel: [String: Any] = [:]
if let p = cats["primary"] as? String { rel["primaryCategory"] = toOne("appCategories", p) }
if let s = cats["secondary"] as? String { rel["secondaryCategory"] = toOne("appCategories", s) }
if !rel.isEmpty {
if dryRun {
print("Categories: \(rel.keys.sorted().joined(separator: ", ")) (update)")
} else {
let (s, d) = request("PATCH", "\(api)/appInfos/\(appInfoID)", body: apiBody(type: "appInfos", id: appInfoID, relationships: rel))
guard s == 200 else { die("category update failed (status \(s)): \(bodyText(d))") }
print("✅ categories set")
}
}
}
// ---- 5. app-level localizations (name, subtitle, privacy URL) ---------------
let appLocRoot = "\(metaDir)/app"
let (ailStatus, ailData) = request("GET", "\(api)/appInfos/\(appInfoID)/appInfoLocalizations?limit=200")
let existingAppLocs = ailStatus == 200 ? dataArray(ailData) : []
for locale in subdirs(appLocRoot) where locale != "categories.json" {
let dir = "\(appLocRoot)/\(locale)"
let attributes = attributesFrom(dir, [("name.txt", "name"), ("subtitle.txt", "subtitle"), ("privacy_url.txt", "privacyPolicyUrl")])
guard !attributes.isEmpty else { continue }
print("App info • \(locale)")
let existing = existingAppLocs.first { attrs($0)["locale"] as? String == locale }?["id"] as? String
var createAttrs = attributes
if existing == nil { createAttrs["locale"] = locale }
patchOrCreate("appInfoLocalizations[\(locale)]", type: "appInfoLocalizations", existingID: existing,
attributes: createAttrs, relationships: ["appInfo": toOne("appInfos", appInfoID)])
}
// ---- 6. version localizations (description, keywords, urls, promo, whats-new)
let verLocRoot = "\(metaDir)/version"
let (avlStatus, avlData) = request("GET", "\(api)/appStoreVersions/\(vID)/appStoreVersionLocalizations?limit=200")
let existingVerLocs = avlStatus == 200 ? dataArray(avlData) : []
for locale in subdirs(verLocRoot) {
let dir = "\(verLocRoot)/\(locale)"
let attributes = attributesFrom(dir, [
("description.txt", "description"),
("keywords.txt", "keywords"),
("promotional_text.txt", "promotionalText"),
("whats_new.txt", "whatsNew"),
("marketing_url.txt", "marketingUrl"),
("support_url.txt", "supportUrl"),
])
guard !attributes.isEmpty else { continue }
print("Version info • \(locale)")
let existing = existingVerLocs.first { attrs($0)["locale"] as? String == locale }?["id"] as? String
var createAttrs = attributes
if existing == nil { createAttrs["locale"] = locale }
patchOrCreate("appStoreVersionLocalizations[\(locale)]", type: "appStoreVersionLocalizations", existingID: existing,
attributes: createAttrs, relationships: ["appStoreVersion": toOne("appStoreVersions", vID)])
}
// ---- 7. review details ------------------------------------------------------
if let rData = fm.contents(atPath: "\(verLocRoot)/review.json"),
let review = try? JSONSerialization.jsonObject(with: rData) as? [String: Any], !review.isEmpty {
print("Review details")
let (rdStatus, rdData) = request("GET", "\(api)/appStoreVersions/\(vID)/appStoreReviewDetail")
let existing = rdStatus == 200 ? dataObject(rdData)?["id"] as? String : nil
patchOrCreate("appStoreReviewDetails", type: "appStoreReviewDetails", existingID: existing,
attributes: review, relationships: ["appStoreVersion": toOne("appStoreVersions", vID)])
}
// ---- 8. screenshots ---------------------------------------------------------
// The local folder is authoritative: for every display type present locally,
// the matching remote set is cleared and re-uploaded in filename order.
func uploadScreenshot(setID: String, fileURL: String) {
guard let data = fm.contents(atPath: fileURL) else { die("cannot read \(fileURL)") }
let name = (fileURL as NSString).lastPathComponent
// reserve
let (rs, rd) = request("POST", "\(api)/appScreenshots",
body: apiBody(type: "appScreenshots",
attributes: ["fileSize": data.count, "fileName": name],
relationships: ["appScreenshotSet": toOne("appScreenshotSets", setID)]))
guard rs == 201 || rs == 200, let obj = dataObject(rd), let shotID = obj["id"] as? String,
let ops = attrs(obj)["uploadOperations"] as? [[String: Any]] else {
die("reserve failed for \(name) (status \(rs)): \(bodyText(rd))")
}
// upload each chunk to Apple's pre-signed URLs (no bearer token use the
// headers Apple hands back)
for op in ops {
guard let urlStr = op["url"] as? String, let url = URL(string: urlStr) else { die("bad upload op for \(name)") }
let offset = intval(op["offset"]); let length = intval(op["length"])
let chunk = data.subdata(in: offset ..< (offset + length))
var req = URLRequest(url: url)
req.httpMethod = (op["method"] as? String) ?? "PUT"
for h in (op["requestHeaders"] as? [[String: Any]]) ?? [] {
if let n = h["name"] as? String, let v = h["value"] as? String { req.setValue(v, forHTTPHeaderField: n) }
}
req.httpBody = chunk
let sem = DispatchSemaphore(value: 0); var st = 0
URLSession.shared.dataTask(with: req) { _, resp, err in
if let err { die("upload error for \(name): \(err)") }
st = (resp as? HTTPURLResponse)?.statusCode ?? 0; sem.signal()
}.resume()
sem.wait()
guard (200...299).contains(st) else { die("chunk upload failed for \(name) (status \(st))") }
}
// commit
let (cs, cd) = request("PATCH", "\(api)/appScreenshots/\(shotID)",
body: apiBody(type: "appScreenshots", id: shotID,
attributes: ["uploaded": true, "sourceFileChecksum": md5hex(data)]))
guard cs == 200 else { die("commit failed for \(name) (status \(cs)): \(bodyText(cd))") }
print(" ⬆︎ \(name)")
}
let shotRoot = "\(metaDir)/screenshots"
for locale in subdirs(shotRoot) {
// existing screenshot sets for this locale's version localization
let locID = existingVerLocs.first { attrs($0)["locale"] as? String == locale }?["id"] as? String
for displayType in subdirs("\(shotRoot)/\(locale)") {
let imgDir = "\(shotRoot)/\(locale)/\(displayType)"
let images = files(imgDir)
guard !images.isEmpty else { continue }
print("Screenshots • \(locale)\(displayType) (\(images.count))")
if dryRun { images.forEach { print(" ⬆︎ \($0) (dry-run)") }; continue }
guard let locID else { die("no version localization for \(locale); add version/\(locale)/ text first") }
// find or create the set
let (ssStatus, ssData) = request("GET", "\(api)/appStoreVersionLocalizations/\(locID)/appScreenshotSets?limit=200")
var setID = (ssStatus == 200 ? dataArray(ssData) : []).first { attrs($0)["screenshotDisplayType"] as? String == displayType }?["id"] as? String
if setID == nil {
let (s, d) = request("POST", "\(api)/appScreenshotSets",
body: apiBody(type: "appScreenshotSets",
attributes: ["screenshotDisplayType": displayType],
relationships: ["appStoreVersionLocalization": toOne("appStoreVersionLocalizations", locID)]))
guard s == 201 || s == 200, let id = dataObject(d)?["id"] as? String else {
die("could not create screenshot set \(displayType) (status \(s)): \(bodyText(d))")
}
setID = id
} else {
// clear existing shots so the local set is authoritative
let (es, ed) = request("GET", "\(api)/appScreenshotSets/\(setID!)/appScreenshots?limit=200")
for shot in (es == 200 ? dataArray(ed) : []) {
if let id = shot["id"] as? String { request("DELETE", "\(api)/appScreenshots/\(id)") }
}
}
for img in images { uploadScreenshot(setID: setID!, fileURL: "\(imgDir)/\(img)") }
}
}
print("")
print(dryRun ? "✅ Dry run complete — no changes written." : "✅ Metadata pushed. Next: swift Scripts/asc-submit.swift")
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env swift
//
// asc-set-compliance.swift declare export compliance for builds that are
// missing it (usesNonExemptEncryption = NO) via the App Store Connect API.
//
// Usage: source .env.release && swift Scripts/asc-set-compliance.swift [bundleId]
//
// Only needed for builds uploaded WITHOUT ITSAppUsesNonExemptEncryption in
// Info.plist; builds that carry that key declare compliance automatically.
// Reads ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH from the environment.
//
import Foundation
import CryptoKit
func die(_ msg: String) -> Never {
FileHandle.standardError.write((msg + "\n").data(using: .utf8)!)
exit(1)
}
func b64url(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
let env = ProcessInfo.processInfo.environment
guard let keyID = env["ASC_KEY_ID"], let issuer = env["ASC_ISSUER_ID"], let keyPath = env["ASC_KEY_PATH"] else {
die("missing ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH (source .env.release first)")
}
let bundleID = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "dev.rzen.indie.Workouts"
// ---- short-lived ES256 JWT --------------------------------------------------
guard let pem = try? String(contentsOfFile: keyPath, encoding: .utf8) else { die("cannot read key at \(keyPath)") }
guard let key = try? P256.Signing.PrivateKey(pemRepresentation: pem) else { die("could not parse EC key at \(keyPath)") }
let now = Int(Date().timeIntervalSince1970)
let header = "{\"alg\":\"ES256\",\"kid\":\"\(keyID)\",\"typ\":\"JWT\"}"
let payload = "{\"iss\":\"\(issuer)\",\"iat\":\(now),\"exp\":\(now + 1200),\"aud\":\"appstoreconnect-v1\"}"
let signingInput = b64url(Data(header.utf8)) + "." + b64url(Data(payload.utf8))
guard let sig = try? key.signature(for: Data(signingInput.utf8)) else { die("signing failed") }
let jwt = signingInput + "." + b64url(sig.rawRepresentation)
// ---- API plumbing -----------------------------------------------------------
let api = "https://api.appstoreconnect.apple.com/v1"
func request(_ method: String, _ urlStr: String, body: Data? = nil) -> (Int, Data) {
guard let url = URL(string: urlStr) else { die("bad url: \(urlStr)") }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
if let body {
req.httpBody = body
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let sem = DispatchSemaphore(value: 0)
var status = 0
var data = Data()
URLSession.shared.dataTask(with: req) { d, resp, err in
if let err { die("network error: \(err)") }
status = (resp as? HTTPURLResponse)?.statusCode ?? 0
data = d ?? Data()
sem.signal()
}.resume()
sem.wait()
return (status, data)
}
func parse(_ data: Data) -> [String: Any]? { try? JSONSerialization.jsonObject(with: data) as? [String: Any] }
func bodyText(_ data: Data) -> String { String(data: data, encoding: .utf8) ?? "" }
// ---- find the app -----------------------------------------------------------
let (aStatus, aData) = request("GET", "\(api)/apps?filter%5BbundleId%5D=\(bundleID)&fields%5Bapps%5D=bundleId,name")
guard aStatus == 200,
let aArr = parse(aData)?["data"] as? [[String: Any]],
let app = aArr.first,
let appID = app["id"] as? String else {
die("app not found for bundle \(bundleID) (status \(aStatus)): \(bodyText(aData))")
}
print("App: \(bundleID) (\(appID))")
// ---- list builds and declare any missing compliance -------------------------
let (bStatus, bData) = request("GET", "\(api)/builds?filter%5Bapp%5D=\(appID)&fields%5Bbuilds%5D=version,usesNonExemptEncryption,processingState,uploadedDate&limit=200")
guard bStatus == 200, let builds = parse(bData)?["data"] as? [[String: Any]] else {
die("could not list builds (status \(bStatus)): \(bodyText(bData))")
}
if builds.isEmpty { print("No builds found yet (still processing?)."); exit(0) }
var patched = 0, already = 0, failed = 0
for build in builds {
guard let id = build["id"] as? String, let attrs = build["attributes"] as? [String: Any] else { continue }
let version = attrs["version"] as? String ?? "?"
let state = attrs["processingState"] as? String ?? "?"
let needs = (attrs["usesNonExemptEncryption"] ?? NSNull()) is NSNull
let uploaded = attrs["uploadedDate"] as? String ?? "?"
let encVal = attrs["usesNonExemptEncryption"] ?? NSNull()
if !needs {
print("• build \(version) [\(state)] uploaded \(uploaded): already declared (usesNonExemptEncryption=\(encVal))")
already += 1
continue
}
let body = "{\"data\":{\"type\":\"builds\",\"id\":\"\(id)\",\"attributes\":{\"usesNonExemptEncryption\":false}}}".data(using: .utf8)!
let (pStatus, pData) = request("PATCH", "\(api)/builds/\(id)", body: body)
if pStatus == 200 {
print("✅ build \(version) [\(state)]: export compliance set (uses non-exempt encryption = NO)")
patched += 1
} else {
print("❌ build \(version) [\(state)]: PATCH failed (status \(pStatus)): \(bodyText(pData))")
failed += 1
}
}
print("Done. \(patched) updated, \(already) already set, \(failed) failed.")
if failed > 0 { exit(1) }
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env swift
//
// asc-submit.swift submit an uploaded build for App Store review via the
// App Store Connect API. Picks up where Scripts/release.sh leaves off.
//
// Usage: source .env.release && swift Scripts/asc-submit.swift [options]
//
// Options:
// --version <X> Marketing version to submit (default: CFBundleShort-
// VersionString from Workouts/Resources/Info-iOS.plist).
// --build <N> Build number (CFBundleVersion) to attach (default: the
// most recent build uploaded for the app).
// --bundle <id> Bundle identifier (default: dev.rzen.indie.Workouts).
// --submit Actually submit for review. Without it the script
// STAGES everything (version, build, submission, item)
// and stops short of the final submit so you can review
// in App Store Connect first.
// --wait-seconds N How long to wait for the build to finish processing
// (default: 1200 = 20 min). Polls every 30s.
//
// What it does, in order:
// 1. find the app GET /apps?filter[bundleId]
// 2. wait for the build to be VALID GET /builds?filter[app]&filter[version]
// 3. declare export compliance if unset PATCH /builds/{id}
// 4. find or create the version POST /appStoreVersions
// 5. attach the build to the version PATCH /appStoreVersions/{id}/relationships/build
// 6. create/reuse review submission POST /reviewSubmissions
// + add the version as an item POST /reviewSubmissionItems
// 7. submit (only with --submit) PATCH /reviewSubmissions/{id} {submitted:true}
//
// Reads ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH from the environment.
// Metadata (description, keywords, screenshots, age rating, privacy, pricing)
// is NOT set here if any required field is missing, step 7's response lists
// exactly what App Store Connect is waiting on.
//
import Foundation
import CryptoKit
func die(_ msg: String) -> Never {
FileHandle.standardError.write((msg + "\n").data(using: .utf8)!)
exit(1)
}
func b64url(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
// ---- arguments --------------------------------------------------------------
var bundleID = "dev.rzen.indie.Workouts"
var versionArg: String?
var buildArg: String?
var doSubmit = false
var waitSeconds = 1200
do {
let args = Array(CommandLine.arguments.dropFirst())
var i = 0
func value(_ flag: String) -> String {
i += 1
guard i < args.count else { die("\(flag) needs a value") }
return args[i]
}
while i < args.count {
switch args[i] {
case "--bundle": bundleID = value("--bundle")
case "--version": versionArg = value("--version")
case "--build": buildArg = value("--build")
case "--submit": doSubmit = true
case "--wait-seconds": waitSeconds = Int(value("--wait-seconds")) ?? waitSeconds
case "--help", "-h":
print("usage: swift Scripts/asc-submit.swift [--version X] [--build N] [--bundle id] [--submit] [--wait-seconds N]")
exit(0)
default: die("unknown argument: \(args[i])")
}
i += 1
}
}
// Default marketing version comes from the iOS app's Info.plist (run from repo root).
let versionString: String = versionArg ?? {
let path = "Workouts/Resources/Info-iOS.plist"
guard let data = FileManager.default.contents(atPath: path),
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
let v = plist["CFBundleShortVersionString"] as? String else {
die("could not read CFBundleShortVersionString from \(path); pass --version")
}
return v
}()
// ---- ES256 JWT (minted fresh per request so the build-wait poll can't outlast it)
let env = ProcessInfo.processInfo.environment
guard let keyID = env["ASC_KEY_ID"], let issuer = env["ASC_ISSUER_ID"], let keyPath = env["ASC_KEY_PATH"] else {
die("missing ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH (source .env.release first)")
}
guard let pem = try? String(contentsOfFile: keyPath, encoding: .utf8) else { die("cannot read key at \(keyPath)") }
guard let signingKey = try? P256.Signing.PrivateKey(pemRepresentation: pem) else { die("could not parse EC key at \(keyPath)") }
func token() -> String {
let now = Int(Date().timeIntervalSince1970)
let header = "{\"alg\":\"ES256\",\"kid\":\"\(keyID)\",\"typ\":\"JWT\"}"
let payload = "{\"iss\":\"\(issuer)\",\"iat\":\(now),\"exp\":\(now + 1200),\"aud\":\"appstoreconnect-v1\"}"
let signingInput = b64url(Data(header.utf8)) + "." + b64url(Data(payload.utf8))
guard let sig = try? signingKey.signature(for: Data(signingInput.utf8)) else { die("signing failed") }
return signingInput + "." + b64url(sig.rawRepresentation)
}
// ---- API plumbing -----------------------------------------------------------
let api = "https://api.appstoreconnect.apple.com/v1"
@discardableResult
func request(_ method: String, _ urlStr: String, body: Data? = nil) -> (Int, Data) {
guard let url = URL(string: urlStr) else { die("bad url: \(urlStr)") }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer \(token())", forHTTPHeaderField: "Authorization")
if let body {
req.httpBody = body
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let sem = DispatchSemaphore(value: 0)
var status = 0
var data = Data()
URLSession.shared.dataTask(with: req) { d, resp, err in
if let err { die("network error: \(err)") }
status = (resp as? HTTPURLResponse)?.statusCode ?? 0
data = d ?? Data()
sem.signal()
}.resume()
sem.wait()
return (status, data)
}
func parse(_ data: Data) -> [String: Any]? { try? JSONSerialization.jsonObject(with: data) as? [String: Any] }
func bodyText(_ data: Data) -> String { String(data: data, encoding: .utf8) ?? "" }
func dataArray(_ data: Data) -> [[String: Any]] { (parse(data)?["data"] as? [[String: Any]]) ?? [] }
func dataObject(_ data: Data) -> [String: Any]? { parse(data)?["data"] as? [String: Any] }
func attrs(_ obj: [String: Any]) -> [String: Any] { (obj["attributes"] as? [String: Any]) ?? [:] }
// ---- 1. find the app --------------------------------------------------------
let (appStatus, appData) = request("GET", "\(api)/apps?filter%5BbundleId%5D=\(bundleID)&fields%5Bapps%5D=bundleId,name")
guard appStatus == 200, let app = dataArray(appData).first, let appID = app["id"] as? String else {
die("app not found for bundle \(bundleID) (status \(appStatus)): \(bodyText(appData))")
}
print("App: \(bundleID) (\(appID))")
print("Target: version \(versionString)\(buildArg.map { " • build \($0)" } ?? " • latest build")\(doSubmit ? " [WILL SUBMIT]" : " [stage only]")")
// ---- 2. resolve + wait for the build ---------------------------------------
// If no build was named, lock onto the most recently uploaded one, then poll
// that specific build version until it finishes processing.
var buildVersion = buildArg
if buildVersion == nil {
let (s, d) = request("GET", "\(api)/builds?filter%5Bapp%5D=\(appID)&sort=-uploadedDate&fields%5Bbuilds%5D=version&limit=1")
guard s == 200, let b = dataArray(d).first, let v = attrs(b)["version"] as? String else {
die("no builds found for \(bundleID); upload one first (Scripts/release.sh)")
}
buildVersion = v
}
let targetBuild = buildVersion!
var buildID: String?
let deadline = Date().addingTimeInterval(TimeInterval(waitSeconds))
while true {
let (s, d) = request("GET", "\(api)/builds?filter%5Bapp%5D=\(appID)&filter%5Bversion%5D=\(targetBuild)&fields%5Bbuilds%5D=version,processingState,usesNonExemptEncryption&limit=1")
guard s == 200 else { die("could not query builds (status \(s)): \(bodyText(d))") }
if let b = dataArray(d).first, let id = b["id"] as? String {
let a = attrs(b)
switch a["processingState"] as? String ?? "?" {
case "VALID":
buildID = id
// ---- 3. declare export compliance if the upload didn't carry it
if (a["usesNonExemptEncryption"] ?? NSNull()) is NSNull {
let body = "{\"data\":{\"type\":\"builds\",\"id\":\"\(id)\",\"attributes\":{\"usesNonExemptEncryption\":false}}}".data(using: .utf8)!
let (ps, pd) = request("PATCH", "\(api)/builds/\(id)", body: body)
guard ps == 200 else { die("could not set export compliance (status \(ps)): \(bodyText(pd))") }
print("✅ export compliance declared (usesNonExemptEncryption = NO)")
}
case "INVALID":
die("build \(targetBuild) is INVALID (processing failed) — check App Store Connect")
case let state:
print("⏳ build \(targetBuild): \(state); waiting…")
}
} else {
print("⏳ build \(targetBuild) not visible yet; waiting…")
}
if buildID != nil { break }
if Date() >= deadline { die("timed out after \(waitSeconds)s waiting for build \(targetBuild) to become VALID") }
Thread.sleep(forTimeInterval: 30)
}
print("Build VALID: \(targetBuild) (\(buildID!))")
// ---- 4. find or create the App Store version --------------------------------
let (vStatus, vData) = request("GET", "\(api)/apps/\(appID)/appStoreVersions?filter%5Bplatform%5D=IOS&filter%5BversionString%5D=\(versionString)&fields%5BappStoreVersions%5D=versionString,platform&limit=1")
guard vStatus == 200 else { die("could not query versions (status \(vStatus)): \(bodyText(vData))") }
var versionID = dataArray(vData).first?["id"] as? String
if let id = versionID {
print("✏️ reusing App Store version \(versionString) (\(id))")
} else {
let body = "{\"data\":{\"type\":\"appStoreVersions\",\"attributes\":{\"platform\":\"IOS\",\"versionString\":\"\(versionString)\"},\"relationships\":{\"app\":{\"data\":{\"type\":\"apps\",\"id\":\"\(appID)\"}}}}}".data(using: .utf8)!
let (s, d) = request("POST", "\(api)/appStoreVersions", body: body)
guard s == 201 || s == 200, let id = dataObject(d)?["id"] as? String else {
die("could not create App Store version \(versionString) (status \(s)): \(bodyText(d))")
}
versionID = id
print("🆕 created App Store version \(versionString) (\(id))")
}
// ---- 5. attach the build to the version -------------------------------------
let attachBody = "{\"data\":{\"type\":\"builds\",\"id\":\"\(buildID!)\"}}".data(using: .utf8)!
let (atStatus, atData) = request("PATCH", "\(api)/appStoreVersions/\(versionID!)/relationships/build", body: attachBody)
guard atStatus == 204 || atStatus == 200 else { die("could not attach build to version (status \(atStatus)): \(bodyText(atData))") }
print("🔗 build attached to version")
// ---- 6. find or create the review submission, then add the version ----------
let inProgress = ["WAITING_FOR_REVIEW", "WAITING_FOR_RELEASE", "IN_REVIEW", "UNRESOLVED_ISSUES", "COMPLETING", "CANCELING"]
let (subStatus, subData) = request("GET", "\(api)/reviewSubmissions?filter%5Bapp%5D=\(appID)&filter%5Bplatform%5D=IOS&fields%5BreviewSubmissions%5D=state,platform&limit=50")
guard subStatus == 200 else { die("could not list review submissions (status \(subStatus)): \(bodyText(subData))") }
let submissions = dataArray(subData)
var submissionID = submissions.first(where: { attrs($0)["state"] as? String == "READY_FOR_REVIEW" })?["id"] as? String
if let id = submissionID {
print("✏️ reusing open review submission (\(id))")
} else {
if let busy = submissions.first(where: { inProgress.contains(attrs($0)["state"] as? String ?? "") }) {
die("a review submission is already in progress (state=\(attrs(busy)["state"] as? String ?? "?")); resolve or wait for it before starting another")
}
let body = "{\"data\":{\"type\":\"reviewSubmissions\",\"attributes\":{\"platform\":\"IOS\"},\"relationships\":{\"app\":{\"data\":{\"type\":\"apps\",\"id\":\"\(appID)\"}}}}}".data(using: .utf8)!
let (s, d) = request("POST", "\(api)/reviewSubmissions", body: body)
guard s == 201 || s == 200, let id = dataObject(d)?["id"] as? String else {
die("could not create review submission (status \(s)): \(bodyText(d))")
}
submissionID = id
print("🆕 created review submission (\(id))")
}
// Add the version as an item, unless it's already on the submission.
// `fields` must request appStoreVersion or the relationship is omitted from
// the response and the already-added check can never match.
let (itStatus, itData) = request("GET", "\(api)/reviewSubmissions/\(submissionID!)/items?fields%5BreviewSubmissionItems%5D=state,appStoreVersion&limit=50")
let alreadyAdded = itStatus == 200 && dataArray(itData).contains { item in
((item["relationships"] as? [String: Any])?["appStoreVersion"] as? [String: Any])
.flatMap { $0["data"] as? [String: Any] }?["id"] as? String == versionID
}
if alreadyAdded {
print("✔︎ version already on the submission")
} else {
let body = "{\"data\":{\"type\":\"reviewSubmissionItems\",\"relationships\":{\"reviewSubmission\":{\"data\":{\"type\":\"reviewSubmissions\",\"id\":\"\(submissionID!)\"}},\"appStoreVersion\":{\"data\":{\"type\":\"appStoreVersions\",\"id\":\"\(versionID!)\"}}}}}".data(using: .utf8)!
let (s, d) = request("POST", "\(api)/reviewSubmissionItems", body: body)
if s == 409, bodyText(d).contains("already added to this reviewSubmission") {
// Belt and braces: the API is the authority on duplicates.
print("✔︎ version already on the submission")
} else {
guard s == 201 || s == 200 else { die("could not add version to submission (status \(s)): \(bodyText(d))") }
print(" version added to the submission")
}
}
// ---- 7. submit (guarded) ----------------------------------------------------
guard doSubmit else {
print("")
print("🅿️ Staged, not submitted. The review submission is READY_FOR_REVIEW.")
print(" Finish any metadata in App Store Connect, then submit there, or re-run:")
print(" source .env.release && swift Scripts/asc-submit.swift --submit")
exit(0)
}
let submitBody = "{\"data\":{\"type\":\"reviewSubmissions\",\"id\":\"\(submissionID!)\",\"attributes\":{\"submitted\":true}}}".data(using: .utf8)!
let (fStatus, fData) = request("PATCH", "\(api)/reviewSubmissions/\(submissionID!)", body: submitBody)
if fStatus == 200 {
let state = dataObject(fData).map { attrs($0)["state"] as? String ?? "WAITING_FOR_REVIEW" } ?? "WAITING_FOR_REVIEW"
print("")
print("🚀 Submitted for review — state: \(state)")
} else {
print("")
print("❌ Submit failed (status \(fStatus)). App Store Connect lists exactly what's")
print(" missing — screenshots, description, age rating, privacy, pricing, etc.:")
print(bodyText(fData))
exit(1)
}
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env swift
// Generic ASC API helper: asc.swift <get|post|patch|delete> <path-after-/v1/> [json-body]
import Foundation
import CryptoKit
func die(_ msg: String) -> Never {
FileHandle.standardError.write((msg + "\n").data(using: .utf8)!)
exit(1)
}
func b64url(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
let env = ProcessInfo.processInfo.environment
guard let keyID = env["ASC_KEY_ID"], let issuer = env["ASC_ISSUER_ID"], let keyPath = env["ASC_KEY_PATH"] else {
die("missing ASC env")
}
guard CommandLine.arguments.count >= 3 else { die("usage: asc.swift <method> <path> [body]") }
let method = CommandLine.arguments[1].uppercased()
let path = CommandLine.arguments[2]
let body = CommandLine.arguments.count > 3 ? CommandLine.arguments[3].data(using: .utf8) : nil
guard let pem = try? String(contentsOfFile: keyPath, encoding: .utf8),
let key = try? P256.Signing.PrivateKey(pemRepresentation: pem) else { die("key error") }
let now = Int(Date().timeIntervalSince1970)
let header = "{\"alg\":\"ES256\",\"kid\":\"\(keyID)\",\"typ\":\"JWT\"}"
let payload = "{\"iss\":\"\(issuer)\",\"iat\":\(now),\"exp\":\(now + 1200),\"aud\":\"appstoreconnect-v1\"}"
let signingInput = b64url(Data(header.utf8)) + "." + b64url(Data(payload.utf8))
guard let sig = try? key.signature(for: Data(signingInput.utf8)) else { die("signing failed") }
let jwt = signingInput + "." + b64url(sig.rawRepresentation)
let base = path.hasPrefix("v2/") ? "https://api.appstoreconnect.apple.com/" : "https://api.appstoreconnect.apple.com/v1/"
var req = URLRequest(url: URL(string: base + path)!)
req.httpMethod = method
req.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
if let body {
req.httpBody = body
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let sem = DispatchSemaphore(value: 0)
URLSession.shared.dataTask(with: req) { d, resp, err in
if let err { die("network: \(err)") }
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
print("HTTP \(status)")
if let d, !d.isEmpty {
if let obj = try? JSONSerialization.jsonObject(with: d),
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]) {
print(String(data: pretty, encoding: .utf8)!)
} else {
print(String(data: d, encoding: .utf8) ?? "")
}
}
sem.signal()
}.resume()
sem.wait()
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
#
# release.sh — archive Workouts (iOS app with the embedded watch app) and upload
# to TestFlight / App Store Connect. No third-party tooling: pure xcodebuild + the
# App Store Connect API key. Credentials live in .env.release (gitignored); see
# .env.release.example.
#
# What it does:
# 1. Regenerates the Xcode project with XcodeGen.
# 2. Stamps CFBundleVersion (CURRENT_PROJECT_VERSION) with the git commit count
# for both the iOS app and the embedded watch app.
# 3. xcodebuild archive (the watch app rides along in the same archive)
# 4. xcodebuild -exportArchive with destination=upload -> App Store Connect.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT"
PROJECT="Workouts.xcodeproj"
SCHEME="Workouts"
BUILD_DIR="$ROOT/build"
# ---- credentials ------------------------------------------------------------
ENV_FILE="$ROOT/.env.release"
[ -f "$ENV_FILE" ] && { set -a; . "$ENV_FILE"; set +a; }
: "${APPLE_TEAM_ID:?Set APPLE_TEAM_ID (copy .env.release.example -> .env.release)}"
: "${ASC_KEY_ID:?Set ASC_KEY_ID in .env.release}"
: "${ASC_ISSUER_ID:?Set ASC_ISSUER_ID in .env.release}"
: "${ASC_KEY_PATH:?Set ASC_KEY_PATH in .env.release}"
[ -f "$ASC_KEY_PATH" ] || { echo "❌ API key not found at: $ASC_KEY_PATH"; exit 1; }
AUTH=(
-authenticationKeyPath "$ASC_KEY_PATH"
-authenticationKeyID "$ASC_KEY_ID"
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
-allowProvisioningUpdates
)
# ---- regenerate project -----------------------------------------------------
if command -v xcodegen >/dev/null 2>&1; then
echo "🧩 Generating $PROJECT ..."
xcodegen generate
elif [ ! -d "$PROJECT" ]; then
echo "$PROJECT missing and xcodegen not installed."; exit 1
fi
# ---- versioning -------------------------------------------------------------
BUILD_NUMBER="$(git rev-list HEAD --count)"
MARKETING_VERSION="$(grep -m1 'MARKETING_VERSION:' project.yml | sed -E 's/.*"([^"]+)".*/\1/')"
echo "📦 Version $MARKETING_VERSION (build $BUILD_NUMBER)"
mkdir -p "$BUILD_DIR"
ARCHIVE="$BUILD_DIR/Workouts.xcarchive"
EXPORT="$BUILD_DIR/Workouts-export"
rm -rf "$ARCHIVE" "$EXPORT"
echo "🛠 Archiving (iOS app + embedded watch app) ..."
xcodebuild archive \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$ARCHIVE" \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
"${AUTH[@]}"
echo "🚀 Exporting + uploading to App Store Connect ..."
xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
-exportPath "$EXPORT" \
-exportOptionsPlist "$SCRIPT_DIR/ExportOptions-iOS.plist" \
"${AUTH[@]}"
echo ""
echo "✅ Uploaded build $BUILD_NUMBER. Appears in App Store Connect > TestFlight after processing (~515 min)."
-35
View File
@@ -1,35 +0,0 @@
#!/bin/sh
## IMPORTANT ##
# Add the following files to Input Files configuraiton of the build phase
# $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)
# $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist
git=$(sh /etc/profile; which git)
number_of_commits=$("$git" rev-list HEAD --count)
git_release_version=$("$git" describe --tags --always --abbrev=0)
target_plist="$TARGET_BUILD_DIR/$INFOPLIST_PATH"
dsym_plist="$DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME/Contents/Info.plist"
git_commit=`"$git" rev-parse --short HEAD`
bundle_version=`/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$target_plist"`
build_date=`date +%F`
build="v$bundle_version-$git_commit b$number_of_commits $build_date"
#echo "version=$bundle_version-$git_commit build $number_of_commits"
"$git" tag "$bundle_version"
for plist in "$target_plist" "$dsym_plist"; do
if [ -f "$plist" ]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $number_of_commits" "$plist"
# /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${version}" "$plist"
# /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${git_release_version#*v}" "$plist"
# Add build date for AppInfoKit
/usr/libexec/PlistBuddy -c "Set :BuildDate $build_date" "$plist" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Add :BuildDate string $build_date" "$plist"
fi
done
+49
View File
@@ -0,0 +1,49 @@
import Foundation
/// Wire format for the iPhoneWatch bridge. The phone is the only device that
/// touches iCloud Drive; the watch round-trips domain documents through the phone.
/// Payloads carry the shared `*Document` types (JSON-encoded `Data` blobs, which
/// WatchConnectivity allows) keyed by stable ULIDs no name/date reconciliation.
enum WCPayload {
static let typeKey = "type"
static let splitsKey = "splits"
static let workoutsKey = "workouts"
static let workoutKey = "workout"
static let workoutUpdateType = "workoutUpdate" // watch phone (one workout)
static let requestSyncType = "requestSync" // watch phone (please push state)
// MARK: - Phone Watch (application context: latest-state-wins)
static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument]) -> [String: Any] {
var dict: [String: Any] = [:]
if let s = try? DocumentCoder.encoder.encode(splits) { dict[splitsKey] = s }
if let w = try? DocumentCoder.encoder.encode(workouts) { dict[workoutsKey] = w }
return dict
}
static func decodeSplits(_ dict: [String: Any]) -> [SplitDocument] {
guard let data = dict[splitsKey] as? Data else { return [] }
return (try? DocumentCoder.decoder.decode([SplitDocument].self, from: data)) ?? []
}
static func decodeWorkouts(_ dict: [String: Any]) -> [WorkoutDocument] {
guard let data = dict[workoutsKey] as? Data else { return [] }
return (try? DocumentCoder.decoder.decode([WorkoutDocument].self, from: data)) ?? []
}
// MARK: - Watch Phone (a single updated workout)
static func encodeWorkoutUpdate(_ workout: WorkoutDocument) -> [String: Any] {
var dict: [String: Any] = [typeKey: workoutUpdateType]
if let w = try? DocumentCoder.encoder.encode(workout) { dict[workoutKey] = w }
return dict
}
static func decodeWorkoutUpdate(_ dict: [String: Any]) -> WorkoutDocument? {
guard let data = dict[workoutKey] as? Data else { return nil }
return try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data)
}
static func requestSyncMessage() -> [String: Any] { [typeKey: requestSyncType] }
}
+144
View File
@@ -0,0 +1,144 @@
import Foundation
/// On-disk JSON shape for each aggregate. Independent from the SwiftData cache
/// entities so the wire format can evolve without dragging the cache schema.
///
/// One document = one aggregate root:
/// `SplitDocument` embeds its `[ExerciseDocument]` `Splits/<ULID>.json`
/// `WorkoutDocument` embeds its `[WorkoutLogDocument]` `Workouts/YYYY/MM/<ULID>.json`
///
/// `schemaVersion` lets us migrate old files on read without forcing a rewrite
/// at sync time, and forward-gates files written by a newer app version.
/// These documents are also the wire format for the iPhoneWatch bridge.
// MARK: - Split
struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
var schemaVersion: Int
var id: String // ULID
var name: String
var color: String
var systemImage: String
var order: Int
var createdAt: Date
var updatedAt: Date
var exercises: [ExerciseDocument]
static let currentSchema = 1
var relativePath: String { "Splits/\(id).json" }
}
struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable {
var id: String // ULID
var name: String
var order: Int
var sets: Int
var reps: Int
var weight: Int
var loadType: Int
var durationSeconds: Int // total seconds (0 when not a timed exercise)
var weightLastUpdated: Date?
var weightReminderWeeks: Int
}
// MARK: - Workout
struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
var schemaVersion: Int
var id: String // ULID (chronological)
var splitID: String?
var splitName: String?
var start: Date
var end: Date?
var status: String // WorkoutStatus raw value
var createdAt: Date
var updatedAt: Date
var logs: [WorkoutLogDocument]
static let currentSchema = 1
var relativePath: String { Self.relativePath(id: id, start: start) }
static func relativePath(id: String, start: Date) -> String {
let cal = Calendar(identifier: .gregorian)
let comps = cal.dateComponents([.year, .month], from: start)
let year = comps.year ?? 1970
let month = comps.month ?? 1
return String(format: "Workouts/%04d/%02d/%@.json", year, month, id)
}
}
struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
var id: String // ULID
var exerciseName: String
var order: Int
var sets: Int
var reps: Int
var weight: Int
var loadType: Int
var durationSeconds: Int // total seconds (0 when not a timed exercise)
var currentStateIndex: Int
var completed: Bool
var status: String // WorkoutStatus raw value
var notes: String?
var date: Date
}
// MARK: - Forward-compatibility gate
/// A file whose `schemaVersion` exceeds the reader's `currentSchema` was written
/// by a newer app version; it must be quarantined, not partially decoded (Codable
/// silently drops unknown keys) and later rewritten which would downgrade it
/// and lose the newer fields.
protocol VersionedDocument {
var schemaVersion: Int { get }
static var currentSchema: Int { get }
}
extension SplitDocument: VersionedDocument {}
extension WorkoutDocument: VersionedDocument {}
extension VersionedDocument {
/// True if this build can safely read (and rewrite) the document.
var isReadable: Bool { schemaVersion <= Self.currentSchema }
}
// MARK: - Soft-delete tombstone
/// Lives at `Stubs/<id>.json` and tells every device "this aggregate has been
/// deleted" important when a remote device missed the `.removed` event for the
/// live file because it was offline. After `gracePeriod` any device can prune it.
struct Tombstone: Codable, Sendable, Equatable {
enum Kind: String, Codable, Sendable {
case split
case workout
}
var id: String // the aggregate's ULID
var kind: Kind
var deletedAt: Date
var relativePath: String { "Stubs/\(id).json" }
static let gracePeriod: TimeInterval = 30 * 24 * 60 * 60
}
// MARK: - JSON coding
/// Shared encoder/decoder. ISO-8601 dates and sorted keys for human-readable,
/// diff-friendly files when the container is browsed via the Files app.
enum DocumentCoder {
static let encoder: JSONEncoder = {
let e = JSONEncoder()
e.outputFormatting = [.prettyPrinted, .sortedKeys]
e.dateEncodingStrategy = .iso8601
return e
}()
static let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
}
+205
View File
@@ -0,0 +1,205 @@
import Foundation
import SwiftData
// SwiftData cache entities. These are a rebuildable read-through cache of the
// iCloud Drive JSON documents never the source of truth. App code reads them
// (via @Query); all writes go through `SyncEngine` to the document files, and the
// metadata observer mirrors the files back into these entities (see Mappers).
//
// `id` is the aggregate/child ULID (stable across cache rebuilds, unlike the
// SwiftData PersistentIdentifier). Computed helpers preserve the API the views
// used against the old Core Data classes.
// MARK: - Split
@Model
final class Split {
@Attribute(.unique) var id: String = ULID.make()
var name: String = ""
var color: String = "indigo"
var systemImage: String = "dumbbell.fill"
var order: Int = 0
var createdAt: Date = Date()
var updatedAt: Date = Date()
var jsonRelativePath: String = ""
@Relationship(deleteRule: .cascade, inverse: \Exercise.split)
var exercises: [Exercise] = []
init(id: String, name: String, color: String, systemImage: String, order: Int,
createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
self.id = id
self.name = name
self.color = color
self.systemImage = systemImage
self.order = order
self.createdAt = createdAt
self.updatedAt = updatedAt
self.jsonRelativePath = jsonRelativePath
}
static let unnamed = "Unnamed Split"
var exercisesArray: [Exercise] { exercises.sorted { $0.order < $1.order } }
}
// MARK: - Exercise
@Model
final class Exercise {
@Attribute(.unique) var id: String = ULID.make()
var name: String = ""
var order: Int = 0
var sets: Int = 0
var reps: Int = 0
var weight: Int = 0
var loadType: Int = LoadType.weight.rawValue
var durationTotalSeconds: Int = 0
var weightLastUpdated: Date?
var weightReminderTimeIntervalWeeks: Int = 2
var split: Split?
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Int,
loadType: Int, durationTotalSeconds: Int, weightLastUpdated: Date?,
weightReminderTimeIntervalWeeks: Int) {
self.id = id
self.name = name
self.order = order
self.sets = sets
self.reps = reps
self.weight = weight
self.loadType = loadType
self.durationTotalSeconds = durationTotalSeconds
self.weightLastUpdated = weightLastUpdated
self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: loadType) ?? .weight }
set { loadType = newValue.rawValue }
}
/// Minutes component of the total duration (for min:sec pickers).
var durationMinutes: Int {
get { durationTotalSeconds / 60 }
set { durationTotalSeconds = newValue * 60 + durationSeconds }
}
/// Seconds component (059) of the total duration.
var durationSeconds: Int {
get { durationTotalSeconds % 60 }
set { durationTotalSeconds = durationMinutes * 60 + newValue }
}
}
// MARK: - Workout
@Model
final class Workout {
@Attribute(.unique) var id: String = ULID.make()
var splitID: String?
var splitName: String?
var start: Date = Date()
var end: Date?
var statusRaw: String = WorkoutStatus.notStarted.rawValue
var createdAt: Date = Date()
var updatedAt: Date = Date()
var jsonRelativePath: String = ""
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
var logs: [WorkoutLog] = []
init(id: String, splitID: String?, splitName: String?, start: Date, end: Date?,
statusRaw: String, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
self.id = id
self.splitID = splitID
self.splitName = splitName
self.start = start
self.end = end
self.statusRaw = statusRaw
self.createdAt = createdAt
self.updatedAt = updatedAt
self.jsonRelativePath = jsonRelativePath
}
var status: WorkoutStatus {
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
set { statusRaw = newValue.rawValue }
}
var statusName: String { status.displayName }
var logsArray: [WorkoutLog] { logs.sorted { $0.order < $1.order } }
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()
}
}
}
// MARK: - WorkoutLog
@Model
final class WorkoutLog {
@Attribute(.unique) var id: String = ULID.make()
var exerciseName: String = ""
var order: Int = 0
var sets: Int = 0
var reps: Int = 0
var weight: Int = 0
var loadType: Int = LoadType.weight.rawValue
var durationTotalSeconds: Int = 0
var currentStateIndex: Int = 0
var completed: Bool = false
var statusRaw: String = WorkoutStatus.notStarted.rawValue
var notes: String?
var date: Date = Date()
var workout: Workout?
init(id: String, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int,
loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int, completed: Bool,
statusRaw: String, notes: String?, date: Date) {
self.id = id
self.exerciseName = exerciseName
self.order = order
self.sets = sets
self.reps = reps
self.weight = weight
self.loadType = loadType
self.durationTotalSeconds = durationTotalSeconds
self.currentStateIndex = currentStateIndex
self.completed = completed
self.statusRaw = statusRaw
self.notes = notes
self.date = date
}
var status: WorkoutStatus {
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
set { statusRaw = newValue.rawValue }
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: loadType) ?? .weight }
set { loadType = newValue.rawValue }
}
var durationMinutes: Int {
get { durationTotalSeconds / 60 }
set { durationTotalSeconds = newValue * 60 + durationSeconds }
}
var durationSeconds: Int {
get { durationTotalSeconds % 60 }
set { durationTotalSeconds = durationMinutes * 60 + newValue }
}
}
+36
View File
@@ -0,0 +1,36 @@
import Foundation
/// Completion state of a workout or an individual exercise log. Persisted as its
/// raw string in both the JSON documents and the SwiftData cache.
enum WorkoutStatus: String, CaseIterable, Codable, Sendable {
case notStarted
case inProgress
case completed
case skipped
var displayName: String {
switch self {
case .notStarted: "Not Started"
case .inProgress: "In Progress"
case .completed: "Completed"
case .skipped: "Skipped"
}
}
var name: String { displayName }
}
/// How an exercise's effort is measured. Persisted as its raw `Int` value.
enum LoadType: Int, CaseIterable, Codable, Sendable {
case none = 0
case weight = 1
case duration = 2
var name: String {
switch self {
case .none: "None"
case .weight: "Weight"
case .duration: "Duration"
}
}
}
+183
View File
@@ -0,0 +1,183 @@
import Foundation
import SwiftData
// Stateless translation between the on-disk `*Document` wire format and the
// SwiftData cache entities. The only code that knows both shapes.
//
// `init(from: entity)` cache document. Used by the write path (build a
// document to save) and the iPhoneWatch bridge.
// `CacheMapper.upsert*` document cache. Used ONLY by the metadata
// observer (the sole cache mutator) and cache rebuilds. Embedded children
// are reconciled by id (update / insert / delete) so frequent in-workout
// edits don't churn unrelated rows.
// MARK: - Cache Document
extension ExerciseDocument {
init(from e: Exercise) {
self.init(id: e.id, name: e.name, order: e.order, sets: e.sets, reps: e.reps,
weight: e.weight, loadType: e.loadType, durationSeconds: e.durationTotalSeconds,
weightLastUpdated: e.weightLastUpdated,
weightReminderWeeks: e.weightReminderTimeIntervalWeeks)
}
}
extension SplitDocument {
init(from split: Split) {
self.init(schemaVersion: Self.currentSchema, id: split.id, name: split.name,
color: split.color, systemImage: split.systemImage, order: split.order,
createdAt: split.createdAt, updatedAt: split.updatedAt,
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)))
}
}
extension WorkoutLogDocument {
init(from log: WorkoutLog) {
self.init(id: log.id, exerciseName: log.exerciseName, order: log.order, sets: log.sets,
reps: log.reps, weight: log.weight, loadType: log.loadType,
durationSeconds: log.durationTotalSeconds, currentStateIndex: log.currentStateIndex,
completed: log.completed, status: log.statusRaw, notes: log.notes, date: log.date)
}
}
extension WorkoutDocument {
init(from workout: Workout) {
self.init(schemaVersion: Self.currentSchema, id: workout.id, splitID: workout.splitID,
splitName: workout.splitName, start: workout.start, end: workout.end,
status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)))
}
}
// MARK: - Document Cache (upsert)
enum CacheMapper {
static func fetchSplit(id: String, in context: ModelContext) -> Split? {
var d = FetchDescriptor<Split>(predicate: #Predicate { $0.id == id })
d.fetchLimit = 1
return try? context.fetch(d).first
}
static func fetchWorkout(id: String, in context: ModelContext) -> Workout? {
var d = FetchDescriptor<Workout>(predicate: #Predicate { $0.id == id })
d.fetchLimit = 1
return try? context.fetch(d).first
}
// MARK: Split
static func upsertSplit(_ doc: SplitDocument, relativePath: String, into context: ModelContext) {
let split: Split
if let existing = fetchSplit(id: doc.id, in: context) {
split = existing
} else {
split = Split(id: doc.id, name: doc.name, color: doc.color, systemImage: doc.systemImage,
order: doc.order, createdAt: doc.createdAt, updatedAt: doc.updatedAt,
jsonRelativePath: relativePath)
context.insert(split)
}
split.name = doc.name
split.color = doc.color
split.systemImage = doc.systemImage
split.order = doc.order
split.createdAt = doc.createdAt
split.updatedAt = doc.updatedAt
split.jsonRelativePath = relativePath
let existing = Dictionary(split.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
var keep = Set<String>()
for ed in doc.exercises {
keep.insert(ed.id)
if let e = existing[ed.id] {
apply(ed, to: e)
} else {
let e = makeExercise(ed)
e.split = split
context.insert(e)
}
}
for e in Array(split.exercises) where !keep.contains(e.id) {
context.delete(e)
}
}
private static func makeExercise(_ d: ExerciseDocument) -> Exercise {
Exercise(id: d.id, name: d.name, order: d.order, sets: d.sets, reps: d.reps, weight: d.weight,
loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
weightLastUpdated: d.weightLastUpdated,
weightReminderTimeIntervalWeeks: d.weightReminderWeeks)
}
private static func apply(_ d: ExerciseDocument, to e: Exercise) {
e.name = d.name
e.order = d.order
e.sets = d.sets
e.reps = d.reps
e.weight = d.weight
e.loadType = d.loadType
e.durationTotalSeconds = d.durationSeconds
e.weightLastUpdated = d.weightLastUpdated
e.weightReminderTimeIntervalWeeks = d.weightReminderWeeks
}
// MARK: Workout
static func upsertWorkout(_ doc: WorkoutDocument, relativePath: String, into context: ModelContext) {
let workout: Workout
if let existing = fetchWorkout(id: doc.id, in: context) {
workout = existing
} else {
workout = Workout(id: doc.id, splitID: doc.splitID, splitName: doc.splitName,
start: doc.start, end: doc.end, statusRaw: doc.status,
createdAt: doc.createdAt, updatedAt: doc.updatedAt,
jsonRelativePath: relativePath)
context.insert(workout)
}
workout.splitID = doc.splitID
workout.splitName = doc.splitName
workout.start = doc.start
workout.end = doc.end
workout.statusRaw = doc.status
workout.createdAt = doc.createdAt
workout.updatedAt = doc.updatedAt
workout.jsonRelativePath = relativePath
let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
var keep = Set<String>()
for ld in doc.logs {
keep.insert(ld.id)
if let l = existing[ld.id] {
apply(ld, to: l)
} else {
let l = makeLog(ld)
l.workout = workout
context.insert(l)
}
}
for l in Array(workout.logs) where !keep.contains(l.id) {
context.delete(l)
}
}
private static func makeLog(_ d: WorkoutLogDocument) -> WorkoutLog {
WorkoutLog(id: d.id, exerciseName: d.exerciseName, order: d.order, sets: d.sets, reps: d.reps,
weight: d.weight, loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
currentStateIndex: d.currentStateIndex, completed: d.completed, statusRaw: d.status,
notes: d.notes, date: d.date)
}
private static func apply(_ d: WorkoutLogDocument, to l: WorkoutLog) {
l.exerciseName = d.exerciseName
l.order = d.order
l.sets = d.sets
l.reps = d.reps
l.weight = d.weight
l.loadType = d.loadType
l.durationTotalSeconds = d.durationSeconds
l.currentStateIndex = d.currentStateIndex
l.completed = d.completed
l.statusRaw = d.status
l.notes = d.notes
l.date = d.date
}
}
+68
View File
@@ -0,0 +1,68 @@
import Foundation
/// Minimal ULID generator.
///
/// 26-character Crockford base32 string: 10 chars of millisecond timestamp +
/// 16 chars of randomness. Lexicographic order matches chronological order,
/// so workout documents sort newest-last by id and file listings stay ordered.
///
/// Spec: https://github.com/ulid/spec
enum ULID {
/// Crockford base32 alphabet no I, L, O, U to avoid visual confusion.
private static let alphabet: [Character] = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
/// Mints a fresh ULID for the current instant.
static func make() -> String {
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
var randomness = [UInt8](repeating: 0, count: 10)
_ = randomness.withUnsafeMutableBytes { buffer in
SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!)
}
return encode(timestamp: timestamp, randomness: randomness)
}
/// True if `s` is a 26-char ULID using the Crockford alphabet.
static func isValid(_ s: String) -> Bool {
guard s.count == 26 else { return false }
let allowed = Set(alphabet)
return s.allSatisfy { allowed.contains($0) }
}
/// Decodes the timestamp portion of a ULID, if valid.
static func timestamp(of ulid: String) -> Date? {
guard ulid.count == 26 else { return nil }
let prefix = ulid.prefix(10)
var value: UInt64 = 0
let lookup = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($1, UInt64($0)) })
for char in prefix {
guard let digit = lookup[char] else { return nil }
value = (value << 5) | digit
}
return Date(timeIntervalSince1970: Double(value) / 1000)
}
// MARK: - Internal
private static func encode(timestamp: UInt64, randomness: [UInt8]) -> String {
precondition(randomness.count == 10, "ULID randomness must be 10 bytes (80 bits)")
// 48-bit timestamp 10 base32 chars (50 bits, top 2 unused).
var output = [Character](repeating: "0", count: 26)
var t = timestamp & ((1 << 48) - 1)
for i in stride(from: 9, through: 0, by: -1) {
output[i] = alphabet[Int(t & 0x1F)]
t >>= 5
}
// 80-bit randomness 16 base32 chars.
var bigEnd = randomness
for i in stride(from: 25, through: 10, by: -1) {
var carry: UInt16 = 0
for j in 0..<bigEnd.count {
let combined = UInt16(bigEnd[j]) | (carry << 8)
bigEnd[j] = UInt8(combined >> 5)
carry = combined & 0x1F
}
output[i] = alphabet[Int(UInt8(carry))]
}
return String(output)
}
}
@@ -0,0 +1,83 @@
import Foundation
import SwiftData
/// Builds the SwiftData cache container. The iCloud Drive JSON documents are the
/// source of truth, so this store is a rebuildable cache wiping it is always
/// safe; `SyncEngine` repopulates it from the container on next launch.
enum WorkoutsModelContainer {
/// Bump whenever the cache schema changes wipes and rebuilds from files.
static let currentSchemaVersion = 1
private static let schemaVersionKey = "Workouts.persistenceSchemaVersion"
private static let identityTokenKey = "Workouts.iCloudIdentityToken"
private static var storeURL: URL {
URL.applicationSupportDirectory.appending(path: "Workouts.store")
}
static func make() -> ModelContainer {
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
ensureStoreDirectoryExists()
wipeIfNeeded()
wipeIfAccountChanged()
do {
// `.none` is load-bearing: the default `.automatic` would silently
// enable CloudKit mirroring on top of our iCloud Drive file sync.
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
let container = try ModelContainer(for: schema, configurations: [config])
UserDefaults.standard.set(currentSchemaVersion, forKey: schemaVersionKey)
return container
} catch {
print("Workouts: ModelContainer creation failed at \(storeURL.path): \(error). Falling back to in-memory.")
}
do {
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
return try ModelContainer(for: schema, configurations: [config])
} catch {
fatalError("Workouts: could not create even an in-memory ModelContainer: \(error)")
}
}
/// Records the current iCloud identity token as the cache's owner. Call after
/// rebuilding the cache for a newly-signed-in account.
static func persistCurrentIdentityToken() {
UserDefaults.standard.set(currentIdentityTokenData(), forKey: identityTokenKey)
}
// MARK: - Wipe helpers
private static func ensureStoreDirectoryExists() {
let parent = storeURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
}
private static func wipeIfNeeded() {
let stored = UserDefaults.standard.integer(forKey: schemaVersionKey)
guard stored < currentSchemaVersion else { return }
wipeStore()
}
/// Wipes the cache when the signed-in iCloud account differs from the one the
/// cache was built for. A nil token is "not ready yet", not "no account" so
/// only compare once a token resolves; mid-session changes are handled live.
private static func wipeIfAccountChanged() {
guard let current = currentIdentityTokenData() else { return }
let stored = UserDefaults.standard.data(forKey: identityTokenKey)
guard current != stored else { return }
wipeStore()
UserDefaults.standard.set(current, forKey: identityTokenKey)
}
private static func currentIdentityTokenData() -> Data? {
guard let token = FileManager.default.ubiquityIdentityToken else { return nil }
return try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false)
}
private static func wipeStore() {
let base = storeURL
for url in [base, URL(fileURLWithPath: base.path + "-wal"), URL(fileURLWithPath: base.path + "-shm")] {
try? FileManager.default.removeItem(at: url)
}
}
}
@@ -1,4 +1,7 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
extension Color {
static func color(from name: String) -> Color {
@@ -19,13 +22,20 @@ extension Color {
}
}
/// Returns a darker shade by reducing HSB brightness (not opacity).
func darker(by percentage: CGFloat = 0.2) -> Color {
return self.opacity(1.0 - percentage)
#if canImport(UIKit)
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
guard UIColor(self).getHue(&h, saturation: &s, brightness: &b, alpha: &a) else { return self }
return Color(hue: Double(h), saturation: Double(s),
brightness: Double(max(0, b * (1 - percentage))), opacity: Double(a))
#else
return self
#endif
}
}
// Available colors for splits
// Canonical palettes for splits (single source of truth).
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"]
+40
View File
@@ -0,0 +1,40 @@
import Foundation
extension Date {
// Cached formatters DateFormatter init is expensive and these are called
// in list rows on every render pass.
private static let shortDateTime: DateFormatter = {
let f = DateFormatter(); f.dateStyle = .short; f.timeStyle = .short; return f
}()
private static let timeOnly: DateFormatter = {
let f = DateFormatter(); f.dateStyle = .none; f.timeStyle = .short; return f
}()
private static let mediumDate: DateFormatter = {
let f = DateFormatter(); f.dateStyle = .medium; f.timeStyle = .none; return f
}()
private static let monthAbbrev: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMM"; return f
}()
private static let weekdayAbbrev: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE"; return f
}()
func formattedDate() -> String { Self.shortDateTime.string(from: self) }
func formattedTime() -> String { Self.timeOnly.string(from: self) }
func formatDate() -> String { Self.mediumDate.string(from: self) }
func isSameDay(as other: Date) -> Bool {
Calendar.current.isDate(self, inSameDayAs: other)
}
var abbreviatedMonth: String { Self.monthAbbrev.string(from: self) }
var abbreviatedWeekday: String { Self.weekdayAbbrev.string(from: self) }
var dayOfMonth: Int { Calendar.current.component(.day, from: self) }
func humanTimeInterval(to other: Date) -> String {
let interval = other.timeIntervalSince(self)
let hours = Int(interval) / 3600
let minutes = (Int(interval) % 3600) / 60
return hours > 0 ? "\(hours)h \(minutes)m" : "\(minutes)m"
}
}
@@ -0,0 +1,98 @@
import Foundation
import Observation
import SwiftData
import WatchConnectivity
/// Watch side of the iPhoneWatch 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
}
}
}
}
+3 -14
View File
@@ -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)
}
-77
View File
@@ -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"
}
}
}
-66
View File
@@ -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
}
}
-83
View File
@@ -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,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.
+104 -73
View File
@@ -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)
}
+12 -17
View File
@@ -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)
}
+23
View File
@@ -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>
+8 -16
View File
@@ -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() }
}
}
}
-569
View File
@@ -1,569 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
A473BF022E4CE278003EAD6F /* Workouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A473BF012E4CE278003EAD6F /* Workouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
A47512C12F1DACCF001A9C6F /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A47512C02F1DACCF001A9C6F /* Yams */; };
A47513342F1DADBE001A9C6F /* IndieAbout in Frameworks */ = {isa = PBXBuildFile; productRef = A47513332F1DADBE001A9C6F /* IndieAbout */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
A473BF032E4CE278003EAD6F /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A473BEE92E4CE276003EAD6F /* Project object */;
proxyType = 1;
remoteGlobalIDString = A473BF002E4CE278003EAD6F;
remoteInfo = "Workouts Watch App";
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
A473BF142E4CE279003EAD6F /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
A473BF022E4CE278003EAD6F /* Workouts Watch App.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
A473BEF12E4CE276003EAD6F /* Workouts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Workouts.app; sourceTree = BUILT_PRODUCTS_DIR; };
A473BF012E4CE278003EAD6F /* Workouts Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Workouts Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
A473BEF32E4CE276003EAD6F /* Workouts */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Workouts;
sourceTree = "<group>";
};
A473BF052E4CE278003EAD6F /* Workouts Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "Workouts Watch App";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
A473BEEE2E4CE276003EAD6F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A47513342F1DADBE001A9C6F /* IndieAbout in Frameworks */,
A47512C12F1DACCF001A9C6F /* Yams in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
A473BEFE2E4CE278003EAD6F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
A473BEE82E4CE276003EAD6F = {
isa = PBXGroup;
children = (
A473BEF32E4CE276003EAD6F /* Workouts */,
A473BF052E4CE278003EAD6F /* Workouts Watch App */,
A473BEF22E4CE276003EAD6F /* Products */,
);
sourceTree = "<group>";
};
A473BEF22E4CE276003EAD6F /* Products */ = {
isa = PBXGroup;
children = (
A473BEF12E4CE276003EAD6F /* Workouts.app */,
A473BF012E4CE278003EAD6F /* Workouts Watch App.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
A473BEF02E4CE276003EAD6F /* Workouts */ = {
isa = PBXNativeTarget;
buildConfigurationList = A473BF152E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts" */;
buildPhases = (
A47512C22F1DB000001A9C6F /* Update Build Number */,
A473BEED2E4CE276003EAD6F /* Sources */,
A473BEEE2E4CE276003EAD6F /* Frameworks */,
A473BEEF2E4CE276003EAD6F /* Resources */,
A473BF142E4CE279003EAD6F /* Embed Watch Content */,
);
buildRules = (
);
dependencies = (
A473BF042E4CE278003EAD6F /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
A473BEF32E4CE276003EAD6F /* Workouts */,
);
name = Workouts;
packageProductDependencies = (
A47512C02F1DACCF001A9C6F /* Yams */,
A47513332F1DADBE001A9C6F /* IndieAbout */,
);
productName = Workouts;
productReference = A473BEF12E4CE276003EAD6F /* Workouts.app */;
productType = "com.apple.product-type.application";
};
A473BF002E4CE278003EAD6F /* Workouts Watch App */ = {
isa = PBXNativeTarget;
buildConfigurationList = A473BF112E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts Watch App" */;
buildPhases = (
A473BEFD2E4CE278003EAD6F /* Sources */,
A473BEFE2E4CE278003EAD6F /* Frameworks */,
A473BEFF2E4CE278003EAD6F /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
A473BF052E4CE278003EAD6F /* Workouts Watch App */,
);
name = "Workouts Watch App";
packageProductDependencies = (
);
productName = "Workouts Watch App";
productReference = A473BF012E4CE278003EAD6F /* Workouts Watch App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
A473BEE92E4CE276003EAD6F /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 2620;
TargetAttributes = {
A473BEF02E4CE276003EAD6F = {
CreatedOnToolsVersion = 16.2;
};
A473BF002E4CE278003EAD6F = {
CreatedOnToolsVersion = 16.2;
};
};
};
buildConfigurationList = A473BEEC2E4CE276003EAD6F /* Build configuration list for PBXProject "Workouts" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = A473BEE82E4CE276003EAD6F;
minimizedProjectReferenceProxies = 1;
packageReferences = (
A47512BF2F1DACCF001A9C6F /* XCRemoteSwiftPackageReference "Yams" */,
A47513322F1DADBE001A9C6F /* XCRemoteSwiftPackageReference "indie-about" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = A473BEF22E4CE276003EAD6F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
A473BEF02E4CE276003EAD6F /* Workouts */,
A473BF002E4CE278003EAD6F /* Workouts Watch App */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A473BEEF2E4CE276003EAD6F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A473BEFF2E4CE278003EAD6F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
A47512C22F1DB000001A9C6F /* Update Build Number */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Update Build Number";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PROJECT_DIR}/Scripts/update_build_number.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A473BEED2E4CE276003EAD6F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A473BEFD2E4CE278003EAD6F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
A473BF042E4CE278003EAD6F /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A473BF002E4CE278003EAD6F /* Workouts Watch App */;
targetProxy = A473BF032E4CE278003EAD6F /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
A473BF0F2E4CE279003EAD6F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = C32Z8JNLG6;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
A473BF102E4CE279003EAD6F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = C32Z8JNLG6;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
A473BF122E4CE279003EAD6F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
A473BF132E4CE279003EAD6F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
A473BF162E4CE279003EAD6F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Workouts/Workouts.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Workouts/Preview Content\"";
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A473BF172E4CE279003EAD6F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Workouts/Workouts.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Workouts/Preview Content\"";
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
A473BEEC2E4CE276003EAD6F /* Build configuration list for PBXProject "Workouts" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A473BF0F2E4CE279003EAD6F /* Debug */,
A473BF102E4CE279003EAD6F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A473BF112E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts Watch App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A473BF122E4CE279003EAD6F /* Debug */,
A473BF132E4CE279003EAD6F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A473BF152E4CE279003EAD6F /* Build configuration list for PBXNativeTarget "Workouts" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A473BF162E4CE279003EAD6F /* Debug */,
A473BF172E4CE279003EAD6F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
A47512BF2F1DACCF001A9C6F /* XCRemoteSwiftPackageReference "Yams" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/jpsim/Yams";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 6.2.0;
};
};
A47513322F1DADBE001A9C6F /* XCRemoteSwiftPackageReference "indie-about" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://git.rzen.dev/rzen/indie-about";
requirement = {
branch = main;
kind = branch;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
A47512C02F1DACCF001A9C6F /* Yams */ = {
isa = XCSwiftPackageProductDependency;
package = A47512BF2F1DACCF001A9C6F /* XCRemoteSwiftPackageReference "Yams" */;
productName = Yams;
};
A47513332F1DADBE001A9C6F /* IndieAbout */ = {
isa = XCSwiftPackageProductDependency;
package = A47513322F1DADBE001A9C6F /* XCRemoteSwiftPackageReference "indie-about" */;
productName = IndieAbout;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = A473BEE92E4CE276003EAD6F /* Project object */;
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -1,24 +0,0 @@
{
"originHash" : "9d613082dd8a405ef810326218a4d81fdfd9ecb33be867afbc9700e52ec96e4b",
"pins" : [
{
"identity" : "indie-about",
"kind" : "remoteSourceControl",
"location" : "https://git.rzen.dev/rzen/indie-about",
"state" : {
"branch" : "main",
"revision" : "ed73ffcc5488b37ec0838ecaaa27ce050807093f"
}
},
{
"identity" : "yams",
"kind" : "remoteSourceControl",
"location" : "https://github.com/jpsim/Yams",
"state" : {
"revision" : "51b5127c7fb6ffac106ad6d199aaa33c5024895f",
"version" : "6.2.0"
}
}
],
"version" : 3
}
@@ -1,19 +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>SchemeUserState</key>
<dict>
<key>Workouts Watch App.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>Workouts.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
</dict>
</plist>
+36
View File
@@ -0,0 +1,36 @@
import Foundation
import Observation
import SwiftData
/// Composition root for the iOS app. Owns the SwiftData cache container and the
/// iCloud sync engine, and drives the one-shot launch sequence. Injected into the
/// view tree via `.environment(...)`.
@Observable
@MainActor
final class AppServices {
let container: ModelContainer
let syncEngine: SyncEngine
let watchBridge: PhoneConnectivityBridge
private var bootstrapTask: Task<Void, Never>?
init() {
let container = WorkoutsModelContainer.make()
self.container = container
self.syncEngine = SyncEngine(container: container)
self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine)
}
/// Launch step: resolve iCloud and reconcile the cache. Idempotent repeated
/// callers await the same one-shot task.
func bootstrap() async {
if let bootstrapTask { await bootstrapTask.value; return }
let task = Task { @MainActor [weak self] in
guard let self else { return }
await self.syncEngine.connect()
self.watchBridge.activate()
}
bootstrapTask = task
await task.value
}
}
@@ -0,0 +1,92 @@
import Foundation
import SwiftData
import WatchConnectivity
/// Phone side of the iPhoneWatch bridge. The phone owns iCloud Drive; the watch
/// is a thin remote that round-trips through it:
/// Phone Watch: pushes all splits + recent workouts as the latest
/// application context whenever the cache changes (local or remote).
/// Watch Phone: receives an updated `WorkoutDocument` and applies it via the
/// SyncEngine write path (file observer cache push back).
@MainActor
final class PhoneConnectivityBridge: NSObject {
private let container: ModelContainer
private let syncEngine: SyncEngine
private var session: WCSession?
private var context: ModelContext { container.mainContext }
init(container: ModelContainer, syncEngine: SyncEngine) {
self.container = container
self.syncEngine = syncEngine
super.init()
}
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
session.activate()
self.session = session
// Push fresh state to the watch whenever the cache changes.
syncEngine.onCacheChanged = { [weak self] in self?.pushAll() }
}
/// Sends the current splits + most-recent workouts to the watch.
func pushAll() {
guard let session, session.activationState == .activated, session.isPaired,
session.isWatchAppInstalled else { return }
let splits = (try? context.fetch(FetchDescriptor<Split>(sortBy: [SortDescriptor(\.order)]))) ?? []
var wDesc = FetchDescriptor<Workout>(sortBy: [SortDescriptor(\.start, order: .reverse)])
wDesc.fetchLimit = 25
let workouts = (try? context.fetch(wDesc)) ?? []
let payload = WCPayload.encodeState(
splits: splits.map(SplitDocument.init(from:)),
workouts: workouts.map(WorkoutDocument.init(from:))
)
try? session.updateApplicationContext(payload)
}
/// Parse the (non-Sendable) WC dictionary in the nonisolated delegate context,
/// then hop to the MainActor with only Sendable values.
nonisolated private func route(_ dict: [String: Any]) {
switch dict[WCPayload.typeKey] as? String {
case WCPayload.requestSyncType:
Task { @MainActor in self.pushAll() }
case WCPayload.workoutUpdateType:
if let doc = WCPayload.decodeWorkoutUpdate(dict) {
Task { @MainActor in await self.syncEngine.ingestFromWatch(doc) }
}
default:
break
}
}
}
// MARK: - WCSessionDelegate
extension PhoneConnectivityBridge: WCSessionDelegate {
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
Task { @MainActor in self.pushAll() }
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
session.activate() // reactivate for a switched watch
}
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
if session.isReachable { Task { @MainActor in self.pushAll() } }
}
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
route(message)
}
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
route(userInfo)
}
}
@@ -1,345 +0,0 @@
//
// 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
}
}
}
+3 -14
View File
@@ -1,25 +1,14 @@
//
// ContentView.swift
// Workouts
// ContentView.swift
// Workouts
//
// 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)
}
-77
View File
@@ -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"
}
}
}
-66
View File
@@ -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
}
}
-83
View File
@@ -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
}
}
-79
View File
@@ -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
}
}
-23
View File
@@ -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
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)")
}
}
}
}
+55
View File
@@ -0,0 +1,55 @@
<?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>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.dev.rzen.indie.Workouts</key>
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUbiquitousContainerName</key>
<string>Workouts</string>
<key>NSUbiquitousContainerSupportedFolderLevels</key>
<string>Any</string>
</dict>
</dict>
</dict>
</plist>
@@ -2,21 +2,17 @@
<!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>
<string>iCloud.dev.rzen.indie.Workouts</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
<string>CloudDocuments</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>com.apple.security.application-groups</key>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>group.dev.rzen.indie.Workouts</string>
<string>iCloud.dev.rzen.indie.Workouts</string>
</array>
</dict>
</plist>
+59
View File
@@ -0,0 +1,59 @@
import Foundation
import SwiftData
/// Builds starter Splits from the bundled YAML exercise catalog, grouped by the
/// catalog's `split` label (Upper Body / Core / Lower Body). Written on demand
/// (never auto-seeded) through the SyncEngine an empty cache at launch can't be
/// told apart from an iCloud library that simply hasn't downloaded yet.
enum SplitSeeder {
/// Visual theme per starter split group, in display order.
private static let groups: [(name: String, color: String, icon: String)] = [
("Upper Body", "blue", "figure.strengthtraining.traditional"),
("Core", "orange", "figure.core.training"),
("Lower Body", "green", "figure.run"),
]
/// Default split source catalog (most universally applicable).
private static let sourceFile = "bodyweight-starter.exercises.yaml"
/// Builds the default split documents (fresh ULIDs each call).
static func defaultSplitDocuments() -> [SplitDocument] {
let lists = ExerciseListLoader.loadExerciseLists()
guard let catalog = lists[sourceFile] else { return [] }
var docs: [SplitDocument] = []
for (order, group) in groups.enumerated() {
let items = catalog.exercises.filter { $0.split == group.name }
guard !items.isEmpty else { continue }
let exercises = items.enumerated().map { idx, item in
ExerciseDocument(
id: ULID.make(), name: item.name, order: idx,
sets: 3, reps: 10, weight: 0, loadType: LoadType.weight.rawValue,
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
)
}
docs.append(SplitDocument(
schemaVersion: SplitDocument.currentSchema, id: ULID.make(),
name: group.name, color: group.color, systemImage: group.icon, order: order,
createdAt: Date(), updatedAt: Date(), exercises: exercises
))
}
return docs
}
/// Writes any starter splits whose name doesn't already exist, appended after
/// existing splits. Idempotent against double-taps / partial prior seeds.
@MainActor
static func seedDefaults(into context: ModelContext, using sync: SyncEngine) async {
let existing = (try? context.fetch(FetchDescriptor<Split>())) ?? []
let existingNames = Set(existing.map(\.name))
let base = existing.count
let fresh = defaultSplitDocuments().filter { !existingNames.contains($0.name) }
for (offset, var doc) in fresh.enumerated() {
doc.order = base + offset
await sync.save(split: doc)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import Foundation
/// All iCloud Drive file I/O, isolated to an actor so blocking `NSFileCoordinator`
/// calls stay off the main thread. Paths are relative to the container's
/// `Documents/` directory (e.g. `Splits/<ULID>.json`, `Stubs/<ULID>.json`).
actor ICloudFileManager {
let documentsURL: URL
init(containerURL: URL) {
self.documentsURL = containerURL.appendingPathComponent("Documents", isDirectory: true)
}
/// Create the directory skeleton. Actor-isolated (NOT in init) so the
/// potentially-blocking file touch runs on the actor's executor, never main.
func prepareDirectories() {
for sub in ["Splits", "Workouts", "Stubs"] {
let url = documentsURL.appendingPathComponent(sub, isDirectory: true)
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
}
}
// MARK: - Coordinated primitives
func write(_ data: Data, to relativePath: String) throws {
let fileURL = documentsURL.appendingPathComponent(relativePath)
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
var coordError: NSError?
var writeError: Error?
NSFileCoordinator().coordinate(writingItemAt: fileURL, options: .forReplacing, error: &coordError) { url in
do { try data.write(to: url, options: .atomic) }
catch { writeError = error }
}
if let error = coordError ?? writeError { throw error }
}
func read(relativePath: String) throws -> Data {
let fileURL = documentsURL.appendingPathComponent(relativePath)
var coordError: NSError?
var result: Result<Data, Error>?
NSFileCoordinator().coordinate(readingItemAt: fileURL, options: [], error: &coordError) { url in
do { result = .success(try Data(contentsOf: url)) }
catch { result = .failure(error) }
}
if let coordError { throw coordError }
switch result {
case .success(let data): return data
case .failure(let error): throw error
case .none: throw CocoaError(.fileReadUnknown)
}
}
func remove(relativePath: String) throws {
let fileURL = documentsURL.appendingPathComponent(relativePath)
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
var coordError: NSError?
var deleteError: Error?
NSFileCoordinator().coordinate(writingItemAt: fileURL, options: .forDeleting, error: &coordError) { url in
do { try FileManager.default.removeItem(at: url) }
catch { deleteError = error }
}
if let error = coordError ?? deleteError { throw error }
}
func fileExists(_ relativePath: String) -> Bool {
FileManager.default.fileExists(atPath: documentsURL.appendingPathComponent(relativePath).path)
}
// MARK: - Soft delete
/// Writes a tombstone stub then removes the live file. Other devices learn of
/// the delete via the stub even if they were offline for the file removal.
func writeTombstoneAndRemove(_ tombstone: Tombstone, livePath: String) throws {
let data = try DocumentCoder.encoder.encode(tombstone)
try write(data, to: tombstone.relativePath)
try remove(relativePath: livePath)
}
// MARK: - Enumeration
/// Relative paths of all live data files (`Splits/`, `Workouts/`), excluding `Stubs/`.
func listDataFiles() -> [String] {
listJSON().filter { !$0.hasPrefix("Stubs/") }
}
/// All tombstones currently on disk.
func listTombstones() -> [Tombstone] {
listJSON().filter { $0.hasPrefix("Stubs/") }.compactMap { path in
guard let data = try? read(relativePath: path) else { return nil }
return try? DocumentCoder.decoder.decode(Tombstone.self, from: data)
}
}
private func listJSON() -> [String] {
let base = documentsURL.path + "/"
guard let enumerator = FileManager.default.enumerator(
at: documentsURL,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else { return [] }
var paths: [String] = []
for case let url as URL in enumerator where url.pathExtension == "json" {
let full = url.path
if full.hasPrefix(base) { paths.append(String(full.dropFirst(base.count))) }
}
return paths
}
// MARK: - Eviction
/// Triggers a download for an evicted file and polls until it materializes.
func ensureDownloaded(relativePath: String) {
let fileURL = documentsURL.appendingPathComponent(relativePath)
guard let values = try? fileURL.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey]),
let status = values.ubiquitousItemDownloadingStatus, status != .current else { return }
try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
}
}
+111
View File
@@ -0,0 +1,111 @@
import Foundation
/// Wraps a single `NSMetadataQuery` over the container's `Documents/` scope and
/// emits add/modify/remove events (paths relative to `Documents/`) via an
/// `AsyncStream`. `@MainActor` because `NSMetadataQuery` posts on, and must be
/// driven from, the main thread.
@MainActor
final class ICloudFileMonitor {
enum FileChangeEvent: Sendable {
case added(relativePath: String)
case modified(relativePath: String)
case removed(relativePath: String)
}
private let documentsURL: URL
private var query: NSMetadataQuery?
private var knownFiles: Set<String> = []
private var continuation: AsyncStream<FileChangeEvent>.Continuation?
init(documentsURL: URL) {
self.documentsURL = documentsURL
}
func events() -> AsyncStream<FileChangeEvent> {
AsyncStream { continuation in
self.continuation = continuation
continuation.onTermination = { @Sendable _ in
Task { @MainActor in self.stop() }
}
}
}
func start() {
let query = NSMetadataQuery()
query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
query.predicate = NSPredicate(format: "%K LIKE '*.json'", NSMetadataItemFSNameKey)
self.query = query
NotificationCenter.default.addObserver(
self, selector: #selector(queryDidFinishGathering(_:)),
name: .NSMetadataQueryDidFinishGathering, object: query)
NotificationCenter.default.addObserver(
self, selector: #selector(queryDidUpdate(_:)),
name: .NSMetadataQueryDidUpdate, object: query)
query.start()
}
func stop() {
query?.stop()
if let query { NotificationCenter.default.removeObserver(self, name: nil, object: query) }
query = nil
}
// MARK: - Notifications
@objc private func queryDidFinishGathering(_ notification: Notification) {
query?.disableUpdates()
defer { query?.enableUpdates() }
// Seed the baseline only; do NOT emit (else every existing file would fire
// `.added` on each launch). The engine's connect-time `reconcile()` does the
// initial import; this query then reports only live deltas after the baseline.
knownFiles = Set(currentRelativePaths())
}
@objc private func queryDidUpdate(_ notification: Notification) {
query?.disableUpdates()
defer { query?.enableUpdates() }
let currentFiles = Set(currentRelativePaths())
for file in currentFiles.subtracting(knownFiles) {
continuation?.yield(.added(relativePath: file))
}
for file in knownFiles.subtracting(currentFiles) {
continuation?.yield(.removed(relativePath: file))
}
if let updated = notification.userInfo?[NSMetadataQueryUpdateChangedItemsKey] as? [NSMetadataItem] {
for item in updated {
if let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL,
let path = relativePath(from: url), knownFiles.contains(path) {
continuation?.yield(.modified(relativePath: path))
}
}
}
knownFiles = currentFiles
}
// MARK: - Helpers
private func currentRelativePaths() -> [String] {
guard let query else { return [] }
var paths: [String] = []
for i in 0..<query.resultCount {
if let item = query.result(at: i) as? NSMetadataItem,
let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL,
let path = relativePath(from: url) {
paths.append(path)
}
}
return paths
}
private func relativePath(from url: URL) -> String? {
let base = documentsURL.path + "/"
let full = url.path
guard full.hasPrefix(base) else { return nil }
let relative = String(full.dropFirst(base.count))
return relative.hasSuffix(".json") ? relative : nil
}
}
+234
View File
@@ -0,0 +1,234 @@
import Foundation
import SwiftData
import Observation
enum ICloudStatus: Equatable {
case checking
case available
case unavailable
}
/// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is
/// the sole source of truth: every save/delete writes files only; the metadata
/// observer (and the connect-time reconcile) is the sole mutator of the cache.
@Observable
@MainActor
final class SyncEngine {
nonisolated static let containerIdentifier = "iCloud.dev.rzen.indie.Workouts"
private(set) var iCloudStatus: ICloudStatus = .checking
private(set) var isSyncing = false
/// Called after the cache changes (local or remote). The watch bridge uses
/// this to push fresh state to the watch.
var onCacheChanged: (() -> Void)?
private let modelContainer: ModelContainer
private var fileManager: ICloudFileManager?
private var monitor: ICloudFileMonitor?
private var monitorTask: Task<Void, Never>?
private var connectAttempt = 0
private var context: ModelContext { modelContainer.mainContext }
init(container: ModelContainer) {
self.modelContainer = container
}
// MARK: - Connection (deferred, time-boxed)
func connect() async {
guard iCloudStatus != .available else { return }
connectAttempt += 1
let attempt = connectAttempt
iCloudStatus = .checking
let url = await Task.detached {
FileManager.default.url(forUbiquityContainerIdentifier: Self.containerIdentifier)
}.value
guard let containerURL = url else {
if attempt == connectAttempt { iCloudStatus = .unavailable }
return
}
let fm = ICloudFileManager(containerURL: containerURL)
let timeout = Task { [weak self] in
try? await Task.sleep(for: .seconds(20))
guard let self, !Task.isCancelled else { return }
if self.iCloudStatus == .checking, attempt == self.connectAttempt {
self.iCloudStatus = .unavailable
}
}
await fm.prepareDirectories()
timeout.cancel()
guard attempt == connectAttempt else { return }
self.fileManager = fm
iCloudStatus = .available
WorkoutsModelContainer.persistCurrentIdentityToken()
await reconcile()
startMonitoring(documentsURL: fm.documentsURL)
cleanupOldStubs()
}
// MARK: - Monitoring
private func startMonitoring(documentsURL: URL) {
monitorTask?.cancel()
let monitor = ICloudFileMonitor(documentsURL: documentsURL)
self.monitor = monitor
monitor.start()
monitorTask = Task { [weak self] in
for await event in monitor.events() {
await self?.handle(event)
}
}
}
private func handle(_ event: ICloudFileMonitor.FileChangeEvent) async {
switch event {
case .added(let path), .modified(let path):
if path.hasPrefix("Stubs/") {
deleteCachedEntity(id: idFromStubPath(path))
} else {
await importFile(relativePath: path)
}
case .removed(let path):
if !path.hasPrefix("Stubs/") {
deleteCachedEntity(jsonRelativePath: path)
}
}
try? context.save()
onCacheChanged?()
}
/// Apply a workout received from the watch through the normal write path
/// (file observer cache), keeping iCloud Drive the single source of truth.
func ingestFromWatch(_ doc: WorkoutDocument) async {
await save(workout: doc)
}
// MARK: - Public CRUD (write path: files only)
func save(split doc: SplitDocument) async {
guard let fm = fileManager else { return }
do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) }
catch { print("[Sync] write failed for \(doc.relativePath): \(error)") }
// Cache updates reactively via the monitor.
}
func save(workout doc: WorkoutDocument) async {
guard let fm = fileManager else { return }
do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) }
catch { print("[Sync] write failed for \(doc.relativePath): \(error)") }
}
func delete(split: Split) async {
await softDelete(id: split.id, kind: .split, livePath: split.jsonRelativePath)
}
func delete(workout: Workout) async {
await softDelete(id: workout.id, kind: .workout, livePath: workout.jsonRelativePath)
}
private func softDelete(id: String, kind: Tombstone.Kind, livePath: String) async {
guard let fm = fileManager else { return }
let tombstone = Tombstone(id: id, kind: kind, deletedAt: Date())
do {
try await fm.writeTombstoneAndRemove(tombstone, livePath: livePath)
} catch {
print("[Sync] delete failed for \(id): \(error)")
}
}
// MARK: - Import / reconcile
private func importFile(relativePath: String) async {
guard let fm = fileManager else { return }
guard let data = try? await fm.read(relativePath: relativePath) else { return }
if relativePath.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
if await fm.fileExists("Stubs/\(doc.id).json") { try? await fm.remove(relativePath: relativePath); return }
CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
} else if relativePath.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
if await fm.fileExists("Stubs/\(doc.id).json") { try? await fm.remove(relativePath: relativePath); return }
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
}
}
/// Full sync against the current file set imports new/changed files and
/// prunes entities whose file is gone or tombstoned. Runs on connect so
/// changes accumulated while the app was closed are picked up.
private func reconcile() async {
guard let fm = fileManager else { return }
isSyncing = true
defer { isSyncing = false }
let tombstoned = Set(await fm.listTombstones().map(\.id))
let dataFiles = await fm.listDataFiles()
var liveSplitIDs = Set<String>()
var liveWorkoutIDs = Set<String>()
for path in dataFiles {
guard let data = try? await fm.read(relativePath: path) else { continue }
if path.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); continue }
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
liveSplitIDs.insert(doc.id)
} else if path.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); continue }
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
liveWorkoutIDs.insert(doc.id)
}
}
// Prune cache entities no longer backed by a live file.
if let splits = try? context.fetch(FetchDescriptor<Split>()) {
for s in splits where !liveSplitIDs.contains(s.id) { context.delete(s) }
}
if let workouts = try? context.fetch(FetchDescriptor<Workout>()) {
for w in workouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) }
}
try? context.save()
onCacheChanged?()
}
// MARK: - Cache deletes
private func deleteCachedEntity(id: String) {
if let s = CacheMapper.fetchSplit(id: id, in: context) { context.delete(s) }
if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) }
}
private func deleteCachedEntity(jsonRelativePath path: String) {
if let splits = try? context.fetch(FetchDescriptor<Split>(predicate: #Predicate { $0.jsonRelativePath == path })) {
splits.forEach(context.delete)
}
if let workouts = try? context.fetch(FetchDescriptor<Workout>(predicate: #Predicate { $0.jsonRelativePath == path })) {
workouts.forEach(context.delete)
}
}
private func idFromStubPath(_ path: String) -> String {
(path as NSString).lastPathComponent.replacingOccurrences(of: ".json", with: "")
}
// MARK: - Maintenance
private func cleanupOldStubs() {
guard let fm = fileManager else { return }
Task.detached(priority: .utility) {
let cutoff = Date().addingTimeInterval(-Tombstone.gracePeriod)
for tombstone in await fm.listTombstones() where tombstone.deletedAt < cutoff {
try? await fm.remove(relativePath: tombstone.relativePath)
}
}
}
}
-31
View File
@@ -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"]
-56
View File
@@ -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"
}
}
}
@@ -8,32 +8,55 @@
//
import SwiftUI
import CoreData
import SwiftData
struct ExerciseAddEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@State private var showingExercisePicker = false
@ObservedObject var exercise: Exercise
// The exercise entity provides initial values (read-only).
let exercise: Exercise
// The parent split is needed to rebuild and save the SplitDocument.
let split: Split
@State private var originalWeight: Int32? = nil
@State private var loadType: LoadType = .none
// Local editable state
@State private var exerciseName: String
@State private var originalWeight: Int
@State private var loadType: LoadType
@State private var minutes: Int
@State private var seconds: Int
@State private var weightTens: Int
@State private var weightOnes: Int
@State private var reps: Int
@State private var sets: Int
@State private var weightReminderWeeks: Int
@State private var weightLastUpdated: Date?
@State private var minutes = 0
@State private var seconds = 0
init(exercise: Exercise, split: Split) {
self.exercise = exercise
self.split = split
@State private var weight_tens = 0
@State private var weight = 0
@State private var reps: Int = 0
@State private var sets: Int = 0
let w = exercise.weight
_exerciseName = State(initialValue: exercise.name)
_originalWeight = State(initialValue: w)
_loadType = State(initialValue: exercise.loadTypeEnum)
_minutes = State(initialValue: exercise.durationMinutes)
_seconds = State(initialValue: exercise.durationSeconds)
_weightTens = State(initialValue: (w / 10) * 10)
_weightOnes = State(initialValue: w % 10)
_reps = State(initialValue: exercise.reps)
_sets = State(initialValue: exercise.sets)
_weightReminderWeeks = State(initialValue: exercise.weightReminderTimeIntervalWeeks)
_weightLastUpdated = State(initialValue: exercise.weightLastUpdated)
}
var body: some View {
NavigationStack {
Form {
Section(header: Text("Exercise")) {
if exercise.name.isEmpty {
if exerciseName.isEmpty {
Button(action: {
showingExercisePicker = true
}) {
@@ -45,7 +68,7 @@ struct ExerciseAddEditView: View {
}
}
} else {
ListItem(title: exercise.name)
ListItem(title: exerciseName)
}
}
@@ -74,7 +97,10 @@ struct ExerciseAddEditView: View {
}
}
Section(header: Text("Load Type"), footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")) {
Section(
header: Text("Load Type"),
footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")
) {
Picker("", selection: $loadType) {
ForEach(LoadType.allCases, id: \.self) { load in
Text(load.name)
@@ -87,7 +113,7 @@ struct ExerciseAddEditView: View {
if loadType == .weight {
Section(header: Text("Weight")) {
HStack {
Picker("", selection: $weight_tens) {
Picker("", selection: $weightTens) {
ForEach(0..<100) { lbs in
Text("\(lbs * 10)").tag(lbs * 10)
}
@@ -95,7 +121,7 @@ struct ExerciseAddEditView: View {
.frame(height: 100)
.pickerStyle(.wheel)
Picker("", selection: $weight) {
Picker("", selection: $weightOnes) {
ForEach(0..<10) { lbs in
Text("\(lbs)").tag(lbs)
}
@@ -130,36 +156,21 @@ struct ExerciseAddEditView: View {
Section(header: Text("Weight Increase")) {
HStack {
Text("Remind every \(exercise.weightReminderTimeIntervalWeeks) weeks")
Text("Remind every \(weightReminderWeeks) weeks")
Spacer()
Stepper("", value: Binding(
get: { Int(exercise.weightReminderTimeIntervalWeeks) },
set: { exercise.weightReminderTimeIntervalWeeks = Int32($0) }
), in: 0...366)
Stepper("", value: $weightReminderWeeks, in: 0...366)
}
if let lastUpdated = exercise.weightLastUpdated {
if let lastUpdated = weightLastUpdated {
Text("Last weight change \(Date().humanTimeInterval(to: lastUpdated)) ago")
}
}
}
.onAppear {
originalWeight = exercise.weight
weight_tens = Int(exercise.weight) / 10 * 10
weight = Int(exercise.weight) % 10
loadType = exercise.loadTypeEnum
sets = Int(exercise.sets)
reps = Int(exercise.reps)
if let duration = exercise.duration {
minutes = Int(duration.timeIntervalSince1970) / 60
seconds = Int(duration.timeIntervalSince1970) % 60
}
}
.sheet(isPresented: $showingExercisePicker) {
ExercisePickerView { exerciseNames in
exercise.name = exerciseNames.first ?? "Unnamed"
exerciseName = exerciseNames.first ?? "Unnamed"
}
}
.navigationTitle(exercise.name.isEmpty ? "New Exercise" : exercise.name)
.navigationTitle(exerciseName.isEmpty ? "New Exercise" : exerciseName)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
@@ -169,19 +180,31 @@ struct ExerciseAddEditView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
if let originalWeight = originalWeight, originalWeight != Int32(weight_tens + weight) {
exercise.weightLastUpdated = Date()
}
exercise.duration = Date(timeIntervalSince1970: Double(minutes * 60 + seconds))
exercise.weight = Int32(weight_tens + weight)
exercise.sets = Int32(sets)
exercise.reps = Int32(reps)
exercise.loadType = Int32(loadType.rawValue)
try? viewContext.save()
saveExercise()
dismiss()
}
}
}
}
}
private func saveExercise() {
let newWeight = weightTens + weightOnes
let updatedWeightDate: Date? = newWeight != originalWeight ? Date() : weightLastUpdated
let durationSecs = minutes * 60 + seconds
var doc = SplitDocument(from: split)
if let idx = doc.exercises.firstIndex(where: { $0.id == exercise.id }) {
doc.exercises[idx].name = exerciseName
doc.exercises[idx].sets = sets
doc.exercises[idx].reps = reps
doc.exercises[idx].weight = newWeight
doc.exercises[idx].loadType = loadType.rawValue
doc.exercises[idx].durationSeconds = durationSecs
doc.exercises[idx].weightLastUpdated = updatedWeightDate
doc.exercises[idx].weightReminderWeeks = weightReminderWeeks
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
}
+137 -97
View File
@@ -8,156 +8,196 @@
//
import SwiftUI
import CoreData
import SwiftData
struct ExerciseListView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@ObservedObject var split: Split
var split: Split
@State private var showingAddSheet: Bool = false
@State private var itemToEdit: Exercise? = nil
@State private var itemToDelete: Exercise? = nil
@State private var createdWorkout: Workout? = nil
/// ID of the just-created workout; drives programmatic navigation once the
/// cache observer delivers the entity a beat after the file write.
@State private var pendingWorkoutID: String? = nil
@State private var resolvedWorkout: Workout? = nil
var body: some View {
NavigationStack {
Form {
let sortedExercises = split.exercisesArray
Form {
let sortedExercises = split.exercisesArray
if !sortedExercises.isEmpty {
ForEach(sortedExercises, id: \.objectID) { item in
ListItem(
title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
)
.swipeActions {
Button {
itemToDelete = item
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
Button {
itemToEdit = item
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
if !sortedExercises.isEmpty {
ForEach(sortedExercises) { item in
ListItem(
title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
)
.swipeActions {
Button {
itemToDelete = item
} label: {
Label("Delete", systemImage: "trash")
}
}
.onMove(perform: moveExercises)
Button {
showingAddSheet = true
} label: {
ListItem(title: "Add Exercise")
}
} else {
Text("No exercises added yet.")
Button(action: { showingAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
.tint(.red)
Button {
itemToEdit = item
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
}
}
.onMove(perform: moveExercises)
Button {
showingAddSheet = true
} label: {
ListItem(title: "Add Exercise")
}
} else {
Text("No exercises added yet.")
Button(action: { showingAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
}
}
.navigationTitle("\(split.name)")
}
.navigationTitle(split.name)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Start This Split") {
startWorkout()
}
.disabled(split.exercisesArray.isEmpty)
}
}
.navigationDestination(item: $createdWorkout) { workout in
// Navigate to the workout log once the entity appears in the cache.
.navigationDestination(item: $resolvedWorkout) { workout in
WorkoutLogListView(workout: workout)
}
// Poll for the entity after we write the document.
.onChange(of: pendingWorkoutID) { _, id in
guard let id else { return }
pollForWorkout(id: id)
}
.sheet(isPresented: $showingAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in
addExercises(names: exerciseNames)
}, allowMultiSelect: true)
}
.sheet(item: $itemToEdit) { item in
ExerciseAddEditView(exercise: item)
ExerciseAddEditView(exercise: item, split: split)
}
.confirmationDialog(
"Delete Exercise?",
isPresented: .constant(itemToDelete != nil),
titleVisibility: .visible
) {
isPresented: Binding(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
),
titleVisibility: .visible,
presenting: itemToDelete
) { item in
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
viewContext.delete(item)
try? viewContext.save()
itemToDelete = nil
}
}
deleteExercise(item)
itemToDelete = nil
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
}
} message: { item in
Text("Remove \"\(item.name)\" from this split?")
}
}
// MARK: - Helpers
private func pollForWorkout(id: String) {
Task {
// Give the fileobservercache loop a moment to complete (typically < 1 s).
for _ in 0..<20 {
try? await Task.sleep(for: .milliseconds(150))
if let workout = CacheMapper.fetchWorkout(id: id, in: modelContext) {
resolvedWorkout = workout
pendingWorkoutID = nil
return
}
}
// If still not available after ~3 s, clear the pending ID silently.
pendingWorkoutID = nil
}
}
private func moveExercises(from source: IndexSet, to destination: Int) {
var exercises = split.exercisesArray
exercises.move(fromOffsets: source, toOffset: destination)
for (index, exercise) in exercises.enumerated() {
exercise.order = Int32(index)
var doc = SplitDocument(from: split)
doc.exercises = exercises.enumerated().map { i, ex in
var ed = ExerciseDocument(from: ex)
ed.order = i
return ed
}
try? viewContext.save()
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
private func startWorkout() {
let workout = Workout(context: viewContext)
workout.start = Date()
workout.end = Date()
workout.status = .notStarted
workout.split = split
for exercise in split.exercisesArray {
let workoutLog = WorkoutLog(context: viewContext)
workoutLog.exerciseName = exercise.name
workoutLog.date = Date()
workoutLog.order = exercise.order
workoutLog.sets = exercise.sets
workoutLog.reps = exercise.reps
workoutLog.weight = exercise.weight
workoutLog.status = .notStarted
workoutLog.workout = workout
let start = Date()
let logs = split.exercisesArray.enumerated().map { i, ex in
WorkoutLogDocument(
id: ULID.make(), exerciseName: ex.name, order: i,
sets: ex.sets, reps: ex.reps, weight: ex.weight,
loadType: ex.loadType, durationSeconds: ex.durationTotalSeconds,
currentStateIndex: 0, completed: false,
status: WorkoutStatus.notStarted.rawValue,
notes: nil, date: start
)
}
let doc = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchema,
id: ULID.make(),
splitID: split.id,
splitName: split.name,
start: start,
end: nil,
status: WorkoutStatus.notStarted.rawValue,
createdAt: start,
updatedAt: start,
logs: logs
)
Task {
await sync.save(workout: doc)
pendingWorkoutID = doc.id
}
try? viewContext.save()
createdWorkout = workout
}
private func addExercises(names: [String]) {
if names.count == 1 {
let exercise = Exercise(context: viewContext)
exercise.name = names.first ?? "Unnamed"
exercise.order = Int32(split.exercisesArray.count)
exercise.sets = 3
exercise.reps = 10
exercise.weight = 40
exercise.loadType = Int32(LoadType.weight.rawValue)
exercise.split = split
try? viewContext.save()
itemToEdit = exercise
} else {
let existingNames = Set(split.exercisesArray.map { $0.name })
for name in names where !existingNames.contains(name) {
let exercise = Exercise(context: viewContext)
exercise.name = name
exercise.order = Int32(split.exercisesArray.count)
exercise.sets = 3
exercise.reps = 10
exercise.weight = 40
exercise.loadType = Int32(LoadType.weight.rawValue)
exercise.split = split
var doc = SplitDocument(from: split)
let existingNames = Set(doc.exercises.map { $0.name })
let base = doc.exercises.count
let newDocs = names
.filter { !existingNames.contains($0) }
.enumerated()
.map { i, exName in
ExerciseDocument(
id: ULID.make(), name: exName, order: base + i,
sets: 3, reps: 10, weight: 40,
loadType: LoadType.weight.rawValue,
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
)
}
try? viewContext.save()
doc.exercises.append(contentsOf: newDocs)
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
private func deleteExercise(_ exercise: Exercise) {
var doc = SplitDocument(from: split)
doc.exercises.removeAll { $0.id == exercise.id }
for i in doc.exercises.indices {
doc.exercises[i].order = i
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
}
@@ -135,11 +135,7 @@ struct ExercisePickerView: View {
}
}
.onAppear {
loadExerciseLists()
exerciseLists = ExerciseListLoader.loadExerciseLists()
}
}
private func loadExerciseLists() {
exerciseLists = ExerciseListLoader.loadExerciseLists()
}
}
+33
View File
@@ -0,0 +1,33 @@
import SwiftUI
/// Gates the whole UI on iCloud availability. Files are the source of truth, so
/// there is no meaningful app without iCloud we never fall back to local-only.
struct RootGateView: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(\.scenePhase) private var scenePhase
var body: some View {
Group {
switch syncEngine.iCloudStatus {
case .checking:
ProgressView("Connecting to iCloud…")
case .available:
ContentView()
case .unavailable:
ContentUnavailableView {
Label("iCloud Required", systemImage: "icloud.slash")
} description: {
Text("Sign in to iCloud in Settings to use Workouts. Your data lives in iCloud Drive so it's safe and on all your devices.")
} actions: {
Button("Try Again") { Task { await syncEngine.connect() } }
.buttonStyle(.borderedProminent)
}
}
}
.onChange(of: scenePhase) { _, phase in
if phase == .active, syncEngine.iCloudStatus == .unavailable {
Task { await syncEngine.connect() }
}
}
}
}
+14 -21
View File
@@ -6,20 +6,14 @@
//
import SwiftUI
import CoreData
import SwiftData
import IndieAbout
struct SettingsView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Split.order, ascending: true),
NSSortDescriptor(keyPath: \Split.name, ascending: true)
],
animation: .default
)
private var splits: FetchedResults<Split>
@Query(sort: \Split.order) private var splits: [Split]
@State private var showingAddSplitSheet = false
@@ -47,7 +41,7 @@ struct SettingsView: View {
Spacer()
}
} else {
ForEach(splits, id: \.objectID) { split in
ForEach(splits) { split in
NavigationLink {
SplitDetailView(split: split)
} label: {
@@ -73,12 +67,16 @@ struct SettingsView: View {
Text("Add Split")
}
}
}
// MARK: - Account Section
Section(header: Text("Account")) {
Text("Settings coming soon")
.foregroundColor(.secondary)
Button {
Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) }
} label: {
HStack {
Image(systemName: "wand.and.sparkles")
.foregroundColor(.accentColor)
Text("Add Starter Splits")
}
}
}
// MARK: - About Section
@@ -99,8 +97,3 @@ struct SettingsView: View {
}
}
}
#Preview {
SettingsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}
+2 -22
View File
@@ -6,25 +6,5 @@
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// Protocol for items that can be ordered in a sequence
protocol OrderableItem {
/// Updates the order of the item to the specified index
func updateOrder(to index: Int)
}
/// Extension to make Split conform to OrderableItem
extension Split: OrderableItem {
func updateOrder(to index: Int) {
self.order = Int32(index)
}
}
/// Extension to make Exercise conform to OrderableItem
extension Exercise: OrderableItem {
func updateOrder(to index: Int) {
self.order = Int32(index)
}
}
// No longer used reordering is now a SyncEngine document write (onMove doc save).
// File kept to avoid Xcode project reference errors.
+2 -89
View File
@@ -6,92 +6,5 @@
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import UniformTypeIdentifiers
public struct SortableForEach<Data, Content>: View where Data: Hashable, Content: View {
@Binding var data: [Data]
@Binding var allowReordering: Bool
private let content: (Data, Bool) -> Content
@State private var draggedItem: Data?
@State private var hasChangedLocation: Bool = false
public init(_ data: Binding<[Data]>,
allowReordering: Binding<Bool>,
@ViewBuilder content: @escaping (Data, Bool) -> Content) {
_data = data
_allowReordering = allowReordering
self.content = content
}
public var body: some View {
ForEach(data, id: \.self) { item in
if allowReordering {
content(item, hasChangedLocation && draggedItem == item)
.onDrag {
draggedItem = item
return NSItemProvider(object: "\(item.hashValue)" as NSString)
}
.onDrop(of: [UTType.plainText], delegate: DragRelocateDelegate(
item: item,
data: $data,
draggedItem: $draggedItem,
hasChangedLocation: $hasChangedLocation))
} else {
content(item, false)
}
}
}
struct DragRelocateDelegate<ItemType>: DropDelegate where ItemType: Equatable {
let item: ItemType
@Binding var data: [ItemType]
@Binding var draggedItem: ItemType?
@Binding var hasChangedLocation: Bool
func dropEntered(info: DropInfo) {
guard item != draggedItem,
let current = draggedItem,
let from = data.firstIndex(of: current),
let to = data.firstIndex(of: item)
else {
return
}
hasChangedLocation = true
if data[to] != current {
withAnimation {
data.move(
fromOffsets: IndexSet(integer: from),
toOffset: (to > from) ? to + 1 : to
)
}
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
// Update the order property of each item to match its position in the array
updateItemOrders()
hasChangedLocation = false
draggedItem = nil
return true
}
// Helper method to update the order property of each item
private func updateItemOrders() {
for (index, item) in data.enumerated() {
if let orderableItem = item as? any OrderableItem {
orderableItem.updateOrder(to: index)
}
}
}
}
}
// No longer used list reordering is handled with SwiftUI's native onMove modifier
// backed by SyncEngine document writes. File kept to avoid Xcode project reference errors.
+27 -17
View File
@@ -8,10 +8,11 @@
//
import SwiftUI
import CoreData
import SwiftData
struct SplitAddEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
let split: Split?
@@ -23,8 +24,6 @@ struct SplitAddEditView: View {
@State private var showingIconPicker: Bool = false
@State private var showingDeleteConfirmation: Bool = false
private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
var isEditing: Bool { split != nil }
init(split: Split?, onDelete: (() -> Void)? = nil) {
@@ -117,8 +116,9 @@ struct SplitAddEditView: View {
) {
Button("Delete", role: .destructive) {
if let split = split {
viewContext.delete(split)
try? viewContext.save()
Task {
await sync.delete(split: split)
}
dismiss()
onDelete?()
}
@@ -131,18 +131,28 @@ struct SplitAddEditView: View {
private func save() {
if let split = split {
// Update existing
split.name = name
split.color = color
split.systemImage = systemImage
// Update existing split
var doc = SplitDocument(from: split)
doc.name = name
doc.color = color
doc.systemImage = systemImage
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
} else {
// Create new
let newSplit = Split(context: viewContext)
newSplit.name = name
newSplit.color = color
newSplit.systemImage = systemImage
newSplit.order = 0
// Create new split
let existing = (try? modelContext.fetch(FetchDescriptor<Split>())) ?? []
let doc = SplitDocument(
schemaVersion: SplitDocument.currentSchema,
id: ULID.make(),
name: name,
color: color,
systemImage: systemImage,
order: existing.count,
createdAt: Date(),
updatedAt: Date(),
exercises: []
)
Task { await sync.save(split: doc) }
}
try? viewContext.save()
}
}
+88 -79
View File
@@ -8,13 +8,13 @@
//
import SwiftUI
import CoreData
import SwiftData
struct SplitDetailView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@ObservedObject var split: Split
var split: Split
@State private var showingExerciseAddSheet: Bool = false
@State private var showingSplitEditSheet: Bool = false
@@ -22,54 +22,52 @@ struct SplitDetailView: View {
@State private var itemToDelete: Exercise? = nil
var body: some View {
NavigationStack {
Form {
Section(header: Text("What is a Split?")) {
Text("A \"split\" is simply how you divide (or \"split up\") your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
.font(.caption)
}
Form {
Section(header: Text("What is a Split?")) {
Text("A \"split\" is simply how you divide (or \"split up\") your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
.font(.caption)
}
Section(header: Text("Exercises")) {
let sortedExercises = split.exercisesArray
Section(header: Text("Exercises")) {
let sortedExercises = split.exercisesArray
if !sortedExercises.isEmpty {
ForEach(sortedExercises, id: \.objectID) { item in
ListItem(
title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
)
.swipeActions {
Button {
itemToDelete = item
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
Button {
itemToEdit = item
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
if !sortedExercises.isEmpty {
ForEach(sortedExercises) { item in
ListItem(
title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
)
.swipeActions {
Button {
itemToDelete = item
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
Button {
itemToEdit = item
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
}
.onMove(perform: moveExercises)
}
.onMove(perform: moveExercises)
Button {
showingExerciseAddSheet = true
} label: {
ListItem(systemName: "plus.circle", title: "Add Exercise")
}
} else {
Text("No exercises added yet.")
Button(action: { showingExerciseAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
}
Button {
showingExerciseAddSheet = true
} label: {
ListItem(systemName: "plus.circle", title: "Add Exercise")
}
} else {
Text("No exercises added yet.")
Button(action: { showingExerciseAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
}
}
}
.navigationTitle("\(split.name)")
}
.navigationTitle(split.name)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
@@ -90,62 +88,73 @@ struct SplitDetailView: View {
}
}
.sheet(item: $itemToEdit) { item in
ExerciseAddEditView(exercise: item)
ExerciseAddEditView(exercise: item, split: split)
}
.confirmationDialog(
"Delete Exercise?",
isPresented: .constant(itemToDelete != nil),
titleVisibility: .visible
) {
isPresented: Binding(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
),
titleVisibility: .visible,
presenting: itemToDelete
) { item in
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
viewContext.delete(item)
try? viewContext.save()
itemToDelete = nil
}
}
deleteExercise(item)
itemToDelete = nil
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
}
} message: { item in
Text("Remove \"\(item.name)\" from this split?")
}
}
private func moveExercises(from source: IndexSet, to destination: Int) {
var exercises = split.exercisesArray
exercises.move(fromOffsets: source, toOffset: destination)
for (index, exercise) in exercises.enumerated() {
exercise.order = Int32(index)
var doc = SplitDocument(from: split)
doc.exercises = exercises.enumerated().map { i, ex in
var ed = ExerciseDocument(from: ex)
ed.order = i
return ed
}
try? viewContext.save()
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
private func addExercises(names: [String]) {
if names.count == 1 {
let exercise = Exercise(context: viewContext)
exercise.name = names.first ?? "Unnamed"
exercise.order = Int32(split.exercisesArray.count)
exercise.sets = 3
exercise.reps = 10
exercise.weight = 40
exercise.loadType = Int32(LoadType.weight.rawValue)
exercise.split = split
try? viewContext.save()
itemToEdit = exercise
} else {
let existingNames = Set(split.exercisesArray.map { $0.name })
for name in names where !existingNames.contains(name) {
let exercise = Exercise(context: viewContext)
exercise.name = name
exercise.order = Int32(split.exercisesArray.count)
exercise.sets = 3
exercise.reps = 10
exercise.weight = 40
exercise.loadType = Int32(LoadType.weight.rawValue)
exercise.split = split
var doc = SplitDocument(from: split)
let existingNames = Set(doc.exercises.map { $0.name })
let base = doc.exercises.count
let newDocs = names
.filter { !existingNames.contains($0) }
.enumerated()
.map { i, exName in
ExerciseDocument(
id: ULID.make(), name: exName, order: base + i,
sets: 3, reps: 10, weight: 40,
loadType: LoadType.weight.rawValue,
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
)
}
try? viewContext.save()
doc.exercises.append(contentsOf: newDocs)
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
// If a single exercise was added, open the edit sheet once the cache refreshes.
// We rely on the observer to populate it no direct entity reference needed.
}
private func deleteExercise(_ exercise: Exercise) {
var doc = SplitDocument(from: split)
doc.exercises.removeAll { $0.id == exercise.id }
// Re-number orders after removal
for i in doc.exercises.indices {
doc.exercises[i].order = i
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
import SwiftUI
struct SplitItem: View {
@ObservedObject var split: Split
var split: Split
var body: some View {
VStack {
+18 -38
View File
@@ -8,64 +8,44 @@
//
import SwiftUI
import CoreData
import SwiftData
struct SplitListView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Split.order, ascending: true),
NSSortDescriptor(keyPath: \Split.name, ascending: true)
],
animation: .default
)
private var fetchedSplits: FetchedResults<Split>
@State private var splits: [Split] = []
@State private var allowSorting: Bool = true
@Query(sort: \Split.order) private var splits: [Split]
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
ForEach(splits) { split in
NavigationLink {
SplitDetailView(split: split)
} label: {
SplitItem(split: split)
.overlay(dragging ? Color.white.opacity(0.8) : Color.clear)
}
}
}
.padding()
}
.overlay {
if fetchedSplits.isEmpty {
if splits.isEmpty {
ContentUnavailableView(
"No Splits Yet",
systemImage: "dumbbell.fill",
description: Text("Create a split to organize your workout routine.")
label: {
Label("No Splits Yet", systemImage: "dumbbell.fill")
},
description: {
Text("Create a split to organize your workout routine.")
},
actions: {
Button("Add Starter Splits") {
Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) }
}
.buttonStyle(.borderedProminent)
}
)
}
}
.onAppear {
splits = Array(fetchedSplits)
}
.onChange(of: fetchedSplits.count) { _, _ in
splits = Array(fetchedSplits)
}
.onChange(of: splits) { _, _ in
saveContext()
}
}
private func saveContext() {
if viewContext.hasChanges {
do {
try viewContext.save()
} catch {
print("Error saving after reorder: \(error)")
}
}
}
}
-8
View File
@@ -8,11 +8,8 @@
//
import SwiftUI
import CoreData
struct SplitsView: View {
@Environment(\.managedObjectContext) private var viewContext
@State private var showingAddSheet: Bool = false
var body: some View {
@@ -32,8 +29,3 @@ struct SplitsView: View {
}
}
}
#Preview {
SplitsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}
+118 -62
View File
@@ -8,15 +8,20 @@
//
import SwiftUI
import CoreData
import SwiftData
import Charts
struct ExerciseView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
let workout: Workout
let logID: String
/// Working copy of the parent workout. Editing a log = editing this doc and
/// re-saving the whole aggregate. Driving the UI from local state (not the
/// cache entity) keeps rapid set taps from racing the filecache update.
@State private var doc: WorkoutDocument
@State private var progress: Int = 0
@State private var showingPlanEdit = false
@State private var showingNotesEdit = false
@@ -24,12 +29,49 @@ struct ExerciseView: View {
let notStartedColor = Color.white
let completedColor = Color.green
/// `seedDoc` lets the caller hand over an in-memory document (e.g. the parent's
/// working copy right after adding an exercise) so the screen doesn't wait on
/// the filecache round-trip to find the just-created log.
init(workout: Workout, logID: String, seedDoc: WorkoutDocument? = nil) {
self.workout = workout
self.logID = logID
_doc = State(initialValue: seedDoc ?? WorkoutDocument(from: workout))
}
/// The log being edited within the working doc.
private var log: WorkoutLogDocument? {
doc.logs.first { $0.id == logID }
}
var body: some View {
Group {
if let log {
content(for: log)
} else {
// The just-added log hasn't reached the cache yet; refresh shortly.
ProgressView()
}
}
.navigationTitle(log?.exerciseName ?? "")
.sheet(isPresented: $showingPlanEdit) {
PlanEditView(workout: workout, logID: logID)
}
.sheet(isPresented: $showingNotesEdit) {
NotesEditView(workout: workout, logID: logID)
}
.onAppear {
refreshDocIfNeeded()
progress = log?.currentStateIndex ?? 0
}
}
@ViewBuilder
private func content(for log: WorkoutLogDocument) -> 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
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: max(1, log.sets)), spacing: 4) {
ForEach(1...max(1, log.sets), id: \.self) { index in
ZStack {
let completed = index <= progress
let color = completed ? completedColor : notStartedColor
@@ -50,21 +92,17 @@ struct ExerciseView: View {
.colorInvert()
}
.onTapGesture {
let totalSets = Int(workoutLog.sets)
let totalSets = log.sets
let isLastTile = index == totalSets
let wasAlreadyAtThisProgress = progress == index
withAnimation(.easeInOut(duration: 0.2)) {
if wasAlreadyAtThisProgress {
progress = 0
} else {
progress = index
}
progress = wasAlreadyAtThisProgress ? 0 : index
}
updateLogStatus()
// If tapping the last tile to complete, go back to list
// Tapping the final tile to complete returns to the list.
if isLastTile && !wasAlreadyAtThisProgress {
dismiss()
}
@@ -75,7 +113,7 @@ struct ExerciseView: View {
// MARK: - Plan Section (Read-only with Edit button)
Section {
PlanTilesView(workoutLog: workoutLog)
PlanTilesView(log: log)
} header: {
HStack {
Text("Plan")
@@ -90,7 +128,7 @@ struct ExerciseView: View {
// MARK: - Notes Section (Read-only with Edit button)
Section {
if let notes = workoutLog.notes, !notes.isEmpty {
if let notes = log.notes, !notes.isEmpty {
Text(notes)
.foregroundColor(.primary)
} else {
@@ -112,93 +150,111 @@ struct ExerciseView: View {
// MARK: - Progress Tracking Chart
Section(header: Text("Progress Tracking")) {
WeightProgressionChartView(exerciseName: workoutLog.exerciseName)
WeightProgressionChartView(exerciseName: log.exerciseName)
}
}
.navigationTitle(workoutLog.exerciseName)
.sheet(isPresented: $showingPlanEdit) {
PlanEditView(workoutLog: workoutLog)
// Pull plan/notes edits made in the sheets back into the live doc.
.onChange(of: showingPlanEdit) { _, presenting in
if !presenting { refreshDocFromCache() }
}
.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)
}
}
.onChange(of: showingNotesEdit) { _, presenting in
if !presenting { refreshDocFromCache() }
}
}
// MARK: - Mutations
private func updateLogStatus() {
workoutLog.currentStateIndex = Int32(progress)
if progress >= Int(workoutLog.sets) {
workoutLog.status = .completed
workoutLog.completed = true
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = progress
if progress >= doc.logs[i].sets {
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
} else if progress > 0 {
workoutLog.status = .inProgress
workoutLog.completed = false
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
} else {
workoutLog.status = .notStarted
workoutLog.completed = false
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
doc.logs[i].completed = false
}
updateWorkoutStatus()
saveChanges()
recomputeWorkoutStatus()
doc.updatedAt = Date()
let snapshot = doc
Task { await sync.save(workout: snapshot) }
}
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 }
/// Recompute the workout's status/end from its logs.
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
}
}
private func saveChanges() {
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
/// If the requested log isn't in the working doc yet (just-added race), pull a
/// fresh copy from the cache entity once it catches up.
private func refreshDocIfNeeded() {
guard log == nil else { return }
refreshDocFromCache()
}
/// Re-read the workout from the cache to absorb edits made by child sheets
/// (plan/notes) without clobbering progress edits made here.
private func refreshDocFromCache() {
let fresh = WorkoutDocument(from: workout)
// Preserve the locally edited progress for the open log if the cache lags.
if let i = fresh.logs.firstIndex(where: { $0.id == logID }),
let mine = doc.logs.first(where: { $0.id == logID }),
fresh.logs[i].currentStateIndex != mine.currentStateIndex {
doc = fresh
doc.logs[i].currentStateIndex = mine.currentStateIndex
} else {
doc = fresh
}
if let current = log {
progress = current.currentStateIndex
}
}
}
// MARK: - Plan Tiles View
struct PlanTilesView: View {
@ObservedObject var workoutLog: WorkoutLog
let log: WorkoutLogDocument
var body: some View {
if workoutLog.loadTypeEnum == .duration {
if LoadType(rawValue: log.loadType) == .duration {
// Duration layout: Sets | Duration
HStack(spacing: 0) {
PlanTile(label: "Sets", value: "\(workoutLog.sets)")
PlanTile(label: "Sets", value: "\(log.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")
PlanTile(label: "Sets", value: "\(log.sets)")
PlanTile(label: "Reps", value: "\(log.reps)")
PlanTile(label: "Weight", value: "\(log.weight) lbs")
}
}
}
private var formattedDuration: String {
let mins = workoutLog.durationMinutes
let secs = workoutLog.durationSeconds
let mins = log.durationSeconds / 60
let secs = log.durationSeconds % 60
if mins > 0 && secs > 0 {
return "\(mins)m \(secs)s"
} else if mins > 0 {
+12 -7
View File
@@ -6,13 +6,14 @@
//
import SwiftUI
import CoreData
import SwiftData
struct NotesEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
let workout: Workout
let logID: String
@State private var notesText: String = ""
@@ -40,14 +41,18 @@ struct NotesEditView: View {
}
}
.onAppear {
notesText = workoutLog.notes ?? ""
notesText = WorkoutDocument(from: workout)
.logs.first(where: { $0.id == logID })?.notes ?? ""
}
}
}
private func saveChanges() {
workoutLog.notes = notesText
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
var doc = WorkoutDocument(from: workout)
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].notes = notesText
doc.updatedAt = Date()
let snapshot = doc
Task { await sync.save(workout: snapshot) }
}
}
+46 -29
View File
@@ -6,13 +6,15 @@
//
import SwiftUI
import CoreData
import SwiftData
struct PlanEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
let workout: Workout
let logID: String
@State private var sets: Int = 3
@State private var reps: Int = 12
@@ -21,11 +23,6 @@ struct PlanEditView: View {
@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 {
@@ -135,34 +132,54 @@ struct PlanEditView: View {
}
}
.onAppear {
sets = Int(workoutLog.sets)
reps = Int(workoutLog.reps)
weight = Int(workoutLog.weight)
durationMinutes = workoutLog.durationMinutes
durationSeconds = workoutLog.durationSeconds
selectedLoadType = workoutLog.loadTypeEnum
if let log = WorkoutDocument(from: workout).logs.first(where: { $0.id == logID }) {
sets = log.sets
reps = log.reps
weight = log.weight
durationMinutes = log.durationSeconds / 60
durationSeconds = log.durationSeconds % 60
selectedLoadType = LoadType(rawValue: log.loadType) ?? .weight
}
}
}
}
private func saveChanges() {
workoutLog.sets = Int32(sets)
workoutLog.reps = Int32(reps)
workoutLog.weight = Int32(weight)
workoutLog.durationMinutes = durationMinutes
workoutLog.durationSeconds = durationSeconds
workoutLog.loadTypeEnum = selectedLoadType
let totalSeconds = durationMinutes * 60 + durationSeconds
// 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
// 1) Update the log within the parent workout document.
var doc = WorkoutDocument(from: workout)
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].sets = sets
doc.logs[i].reps = reps
doc.logs[i].weight = weight
doc.logs[i].durationSeconds = totalSeconds
doc.logs[i].loadType = selectedLoadType.rawValue
doc.updatedAt = Date()
let exerciseName = doc.logs[i].exerciseName
let workoutDoc = doc
// 2) Mirror the plan onto the matching exercise in the split template.
var splitDoc: SplitDocument?
if let splitID = doc.splitID,
let split = CacheMapper.fetchSplit(id: splitID, in: modelContext) {
var sDoc = SplitDocument(from: split)
if let ei = sDoc.exercises.firstIndex(where: { $0.name == exerciseName }) {
sDoc.exercises[ei].sets = sets
sDoc.exercises[ei].reps = reps
sDoc.exercises[ei].weight = weight
sDoc.exercises[ei].durationSeconds = totalSeconds
sDoc.exercises[ei].loadType = selectedLoadType.rawValue
sDoc.updatedAt = Date()
splitDoc = sDoc
}
}
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
Task {
await sync.save(workout: workoutDoc)
if let splitDoc {
await sync.save(split: splitDoc)
}
}
}
}
@@ -9,21 +9,31 @@
import SwiftUI
import Charts
import CoreData
import SwiftData
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 = ""
/// Completed logs for this exercise, oldest first.
@Query private var logs: [WorkoutLog]
init(exerciseName: String) {
self.exerciseName = exerciseName
let name = exerciseName
_logs = Query(
filter: #Predicate<WorkoutLog> { $0.exerciseName == name && $0.completed },
sort: \WorkoutLog.date,
order: .forward
)
}
private var weightData: [WeightDataPoint] {
logs.map { WeightDataPoint(date: $0.date, weight: $0.weight) }
}
var body: some View {
VStack(alignment: .leading) {
if isLoading {
ProgressView("Loading data...")
} else if weightData.isEmpty {
if weightData.isEmpty {
Text("No weight history available yet.")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
@@ -70,51 +80,33 @@ struct WeightProgressionChartView: View {
}
}
.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()
private var motivationalMessage: String {
let data = weightData
guard data.count >= 2 else {
return "Complete more workouts to track your progress!"
}
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 firstWeight = data.first?.weight ?? 0
let currentWeight = data.last?.weight ?? 0
let weightDifference = currentWeight - firstWeight
if weightDifference > 0 {
let percentIncrease = Int((Double(weightDifference) / Double(firstWeight)) * 100)
let percentIncrease = firstWeight > 0
? Int((Double(weightDifference) / Double(firstWeight)) * 100)
: 0
if percentIncrease >= 20 {
motivationalMessage = "Amazing progress! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
return "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)%)!"
return "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!"
return "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!"
return "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!"
return "Your current weight is lower than when you started. Adjust your training as needed and keep pushing!"
}
}
}
@@ -8,24 +8,47 @@
//
import SwiftUI
import CoreData
import SwiftData
struct WorkoutLogListView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@ObservedObject var workout: Workout
let workout: Workout
/// Working copy of the workout aggregate. Active-session edits mutate this
/// local value (not the cache entity), which avoids losing rapid taps to the
/// fileobservercache lag, and is the single source of truth while the
/// screen is open.
@State private var doc: WorkoutDocument
@State private var showingAddSheet = false
@State private var itemToDelete: WorkoutLog? = nil
@State private var newlyAddedLog: WorkoutLog? = nil
@State private var logToDelete: WorkoutLogDocument?
@State private var addedLog: AddedLogRoute?
var sortedWorkoutLogs: [WorkoutLog] {
workout.logsArray
/// Wrapper so the programmatic push after adding an exercise uses a distinct
/// `navigationDestination(item:)` and doesn't collide with the value-based
/// row links registered for `String`.
private struct AddedLogRoute: Identifiable, Hashable { let id: String }
init(workout: Workout) {
self.workout = workout
_doc = State(initialValue: WorkoutDocument(from: workout))
}
private var sortedLogs: [WorkoutLogDocument] {
doc.logs.sorted { $0.order < $1.order }
}
/// The split this workout was started from (for adding more exercises).
private var split: Split? {
guard let splitID = doc.splitID else { return nil }
return CacheMapper.fetchSplit(id: splitID, in: modelContext)
}
var body: some View {
Group {
if sortedWorkoutLogs.isEmpty {
if sortedLogs.isEmpty {
ContentUnavailableView {
Label("No Exercises", systemImage: "figure.strengthtraining.traditional")
} description: {
@@ -40,15 +63,11 @@ struct WorkoutLogListView: View {
}
} else {
Form {
Section(header: Text("\(workout.label)")) {
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
let workoutLogStatus = log.status.checkboxStatus
NavigationLink {
ExerciseView(workoutLog: log)
} label: {
Section(header: Text(label)) {
ForEach(sortedLogs) { log in
NavigationLink(value: log.id) {
CheckboxListItem(
status: workoutLogStatus,
status: workoutStatus(log).checkboxStatus,
title: log.exerciseName,
subtitle: subtitleForLog(log)
) {
@@ -65,7 +84,7 @@ struct WorkoutLogListView: View {
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button {
itemToDelete = log
logToDelete = log
} label: {
Label("Delete", systemImage: "trash")
}
@@ -77,130 +96,173 @@ struct WorkoutLogListView: View {
}
}
}
.navigationDestination(item: $newlyAddedLog) { log in
ExerciseView(workoutLog: log)
.navigationDestination(for: String.self) { logID in
ExerciseView(workout: workout, logID: logID)
}
.navigationDestination(item: $addedLog) { route in
// Seed with our working doc so the brand-new log is available before
// the cache catches up.
ExerciseView(workout: workout, logID: route.id, seedDoc: doc)
}
.navigationTitle(doc.splitName ?? Split.unnamed)
// Absorb edits made in pushed children (ExerciseView/Plan/Notes) once the
// cache reflects them, so the list shows live status on return.
.onChange(of: workout.updatedAt) { _, _ in
doc = WorkoutDocument(from: workout)
}
.navigationTitle("\(workout.split?.name ?? Split.unnamed)")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingAddSheet.toggle() }) {
Button {
showingAddSheet.toggle()
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddSheet) {
SplitExercisePickerSheet(
split: workout.split,
existingExerciseNames: Set(sortedWorkoutLogs.map { $0.exerciseName })
split: split,
existingExerciseNames: Set(sortedLogs.map { $0.exerciseName })
) { exercise in
addExerciseFromSplit(exercise)
}
}
.confirmationDialog(
"Delete Exercise?",
isPresented: Binding<Bool>(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
isPresented: Binding(
get: { logToDelete != nil },
set: { if !$0 { logToDelete = nil } }
),
titleVisibility: .visible
) {
titleVisibility: .visible,
presenting: logToDelete
) { log in
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
viewContext.delete(item)
updateWorkoutStatus()
try? viewContext.save()
itemToDelete = nil
}
}
deleteLog(log)
logToDelete = nil
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
logToDelete = nil
}
}
}
private func cycleStatus(for log: WorkoutLog) {
switch log.status {
case .notStarted:
log.status = .inProgress
case .inProgress:
log.status = .completed
case .completed:
log.status = .notStarted
case .skipped:
log.status = .notStarted
}
updateWorkoutStatus()
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
}
// MARK: - Derived
private func completeLog(_ log: WorkoutLog) {
log.status = .completed
updateWorkoutStatus()
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
}
private func updateWorkoutStatus() {
let logs = sortedWorkoutLogs
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
private var label: String {
if doc.status == WorkoutStatus.completed.rawValue, let end = doc.end {
if doc.start.isSameDay(as: end) {
return "\(doc.start.formattedDate())\(end.formattedTime())"
} else {
return "\(doc.start.formattedDate())\(end.formattedDate())"
}
} else {
workout.status = .notStarted
return doc.start.formattedDate()
}
}
private func workoutStatus(_ log: WorkoutLogDocument) -> WorkoutStatus {
WorkoutStatus(rawValue: log.status) ?? .notStarted
}
// MARK: - Mutations (drive the local doc, persist via SyncEngine)
private func cycleStatus(for log: WorkoutLogDocument) {
guard let i = doc.logs.firstIndex(where: { $0.id == log.id }) else { return }
let next: WorkoutStatus
switch workoutStatus(log) {
case .notStarted: next = .inProgress
case .inProgress: next = .completed
case .completed: next = .notStarted
case .skipped: next = .notStarted
}
doc.logs[i].status = next.rawValue
doc.logs[i].completed = (next == .completed)
save()
}
private func completeLog(_ log: WorkoutLogDocument) {
guard let i = doc.logs.firstIndex(where: { $0.id == log.id }) else { return }
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
save()
}
private func deleteLog(_ log: WorkoutLogDocument) {
withAnimation {
doc.logs.removeAll { $0.id == log.id }
save()
}
}
private func moveLog(from source: IndexSet, to destination: Int) {
var logs = sortedWorkoutLogs
var logs = sortedLogs
logs.move(fromOffsets: source, toOffset: destination)
for (index, log) in logs.enumerated() {
log.order = Int32(index)
if let i = doc.logs.firstIndex(where: { $0.id == log.id }) {
doc.logs[i].order = index
}
}
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
save()
}
private func addExerciseFromSplit(_ exercise: Exercise) {
let now = Date()
// Update workout start time if this is the first exercise
if sortedWorkoutLogs.isEmpty {
workout.start = now
// Reuse the workout's start time only when it's the very first exercise.
if doc.logs.isEmpty {
doc.start = now
}
workout.end = nil
doc.end = nil
let log = WorkoutLog(context: viewContext)
log.exerciseName = exercise.name
log.date = now
log.order = Int32(sortedWorkoutLogs.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
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: now
)
doc.logs.append(newLog)
save()
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
// Navigate to the new exercise view
newlyAddedLog = log
// Push the new exercise straight away.
addedLog = AddedLogRoute(id: newLog.id)
}
private func subtitleForLog(_ log: WorkoutLog) -> String {
if log.loadTypeEnum == .duration {
let mins = log.durationMinutes
let secs = log.durationSeconds
/// Recompute the workout's status/end from its logs, then persist.
private func save() {
let statuses = doc.logs.map { workoutStatus($0) }
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
let anyInProgress = statuses.contains { $0 == .inProgress }
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
if allCompleted {
doc.status = WorkoutStatus.completed.rawValue
doc.end = Date()
} else if anyInProgress || !allNotStarted {
doc.status = WorkoutStatus.inProgress.rawValue
doc.end = nil
} else {
doc.status = WorkoutStatus.notStarted.rawValue
doc.end = nil
}
doc.updatedAt = Date()
let snapshot = doc
Task { await sync.save(workout: snapshot) }
}
private func subtitleForLog(_ log: WorkoutLogDocument) -> String {
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 {
@@ -224,7 +286,7 @@ struct SplitExercisePickerSheet: View {
let onExerciseSelected: (Exercise) -> Void
private var availableExercises: [Exercise] {
guard let split = split else { return [] }
guard let split else { return [] }
return split.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
}
@@ -233,7 +295,7 @@ struct SplitExercisePickerSheet: View {
Group {
if !availableExercises.isEmpty {
List {
ForEach(availableExercises, id: \.objectID) { exercise in
ForEach(availableExercises) { exercise in
Button {
onExerciseSelected(exercise)
dismiss()
@@ -8,29 +8,29 @@
//
import SwiftUI
import CoreData
import SwiftData
struct WorkoutLogsView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@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]
@State private var showingSplitPicker = false
@State private var showingSettings = false
@State private var itemToDelete: Workout? = nil
@State private var itemToDelete: Workout?
// WorkoutLogsView is the app's root screen, so it owns its NavigationStack.
var body: some View {
NavigationStack {
List {
ForEach(workouts, id: \.objectID) { workout in
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
ForEach(workouts) { workout in
NavigationLink {
WorkoutLogListView(workout: workout)
} label: {
CalendarListItem(
date: workout.start,
title: workout.split?.name ?? Split.unnamed,
title: workout.splitName ?? Split.unnamed,
subtitle: getSubtitle(for: workout),
subtitle2: workout.statusName
)
@@ -77,21 +77,16 @@ struct WorkoutLogsView: View {
}
.confirmationDialog(
"Delete Workout?",
isPresented: Binding<Bool>(
isPresented: Binding(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
),
titleVisibility: .visible
) {
titleVisibility: .visible,
presenting: itemToDelete
) { workout in
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
viewContext.delete(item)
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
itemToDelete = nil
}
}
Task { await sync.delete(workout: workout) }
itemToDelete = nil
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
@@ -112,22 +107,16 @@ struct WorkoutLogsView: View {
// MARK: - Split Picker Sheet
struct SplitPickerSheet: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Split.order, ascending: true),
NSSortDescriptor(keyPath: \Split.name, ascending: true)
],
animation: .default
)
private var splits: FetchedResults<Split>
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
private var splits: [Split]
var body: some View {
NavigationStack {
List {
ForEach(splits, id: \.objectID) { split in
ForEach(splits) { split in
Button {
startWorkout(with: split)
} label: {
@@ -155,35 +144,40 @@ struct SplitPickerSheet: View {
}
private func startWorkout(with split: Split) {
let workout = Workout(context: viewContext)
workout.start = Date()
workout.status = .notStarted
workout.split = split
for exercise in split.exercisesArray {
let workoutLog = WorkoutLog(context: viewContext)
workoutLog.exerciseName = exercise.name
workoutLog.date = Date()
workoutLog.order = exercise.order
workoutLog.sets = exercise.sets
workoutLog.reps = exercise.reps
workoutLog.weight = exercise.weight
workoutLog.loadType = exercise.loadType
workoutLog.duration = exercise.duration
workoutLog.status = .notStarted
workoutLog.workout = workout
let start = Date()
let logs = split.exercisesArray.enumerated().map { index, exercise in
WorkoutLogDocument(
id: ULID.make(),
exerciseName: exercise.name,
order: index,
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: start
)
}
try? viewContext.save()
// Sync to Watch
WatchConnectivityManager.shared.syncAllData()
// A freshly started workout has no `end` only completion stamps it.
let doc = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchema,
id: ULID.make(),
splitID: split.id,
splitName: split.name,
start: start,
end: nil,
status: WorkoutStatus.notStarted.rawValue,
createdAt: start,
updatedAt: start,
logs: logs
)
Task { await sync.save(workout: doc) }
dismiss()
}
}
#Preview {
WorkoutLogsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}
@@ -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>
+10 -17
View File
@@ -1,31 +1,24 @@
//
// WorkoutsApp.swift
// Workouts
// WorkoutsApp.swift
// Workouts
//
// 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 WorkoutsApp: App {
let persistenceController = PersistenceController.shared
let connectivityManager = WatchConnectivityManager.shared
init() {
// Set up Watch connectivity with Core Data context
connectivityManager.setViewContext(persistenceController.viewContext)
}
@State private var services = AppServices()
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, persistenceController.viewContext)
.environmentObject(connectivityManager)
RootGateView()
.environment(services)
.environment(services.syncEngine)
.modelContainer(services.container)
.task { await services.bootstrap() }
}
}
}
+82
View File
@@ -0,0 +1,82 @@
name: Workouts
options:
bundleIdPrefix: dev.rzen.indie
deploymentTarget:
iOS: "26.0"
watchOS: "26.0"
xcodeVersion: "26.0"
defaultConfig: Debug
settings:
base:
SWIFT_VERSION: "6.0"
DEVELOPMENT_TEAM: ${APPLE_TEAM_ID}
MARKETING_VERSION: "2.0"
CURRENT_PROJECT_VERSION: "1"
ENABLE_USER_SCRIPT_SANDBOXING: "NO"
packages:
IndieAbout:
url: https://git.rzen.dev/rzen/indie-about.git
from: "0.1.0"
Yams:
url: https://github.com/jpsim/Yams
from: "6.0.0"
targets:
# ---- iOS app (owns iCloud Drive sync; embeds the watch app) ----------------
Workouts:
type: application
platform: iOS
sources:
- path: Shared
- path: Workouts
excludes:
- "Resources/Info-*.plist"
- "Resources/*.entitlements"
- path: CHANGELOG.md
buildPhase: resources
type: file
- path: README.md
buildPhase: resources
type: file
- path: LICENSE.md
buildPhase: resources
type: file
dependencies:
- package: IndieAbout
- package: Yams
- target: Workouts Watch App
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts
INFOPLIST_FILE: Workouts/Resources/Info-iOS.plist
CODE_SIGN_ENTITLEMENTS: Workouts/Resources/Workouts-iOS.entitlements
GENERATE_INFOPLIST_FILE: false
SWIFT_STRICT_CONCURRENCY: complete
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
TARGETED_DEVICE_FAMILY: "1,2"
DEVELOPMENT_ASSET_PATHS: "\"Workouts/Preview Content\""
# ---- watchOS app (no iCloud; syncs through the phone via WatchConnectivity) -
Workouts Watch App:
type: application
platform: watchOS
sources:
- path: Shared
- path: Workouts Watch App
excludes:
- "Resources/Info-*.plist"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.watchkitapp
INFOPLIST_FILE: "Workouts Watch App/Resources/Info-watchOS.plist"
GENERATE_INFOPLIST_FILE: false
SWIFT_STRICT_CONCURRENCY: complete
WATCHOS_DEPLOYMENT_TARGET: "26.0"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
TARGETED_DEVICE_FAMILY: "4"
DEVELOPMENT_ASSET_PATHS: "\"Workouts Watch App/Preview Content\""