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:
@@ -13,7 +13,10 @@
|
|||||||
"Bash(log show:*)",
|
"Bash(log show:*)",
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(git push:*)"
|
"Bash(git push:*)",
|
||||||
|
"Bash(git rev-list:*)",
|
||||||
|
"Bash(git describe:*)",
|
||||||
|
"Bash(git rev-parse:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# License
|
||||||
|
|
||||||
|
Copyright 2026 Rouslan Zenetl. All rights reserved.
|
||||||
@@ -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
@@ -1,187 +1,72 @@
|
|||||||
# Workouts App Requirements
|
# Workouts App Requirements
|
||||||
|
|
||||||
## Overview
|
## 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
|
## Platform Requirements
|
||||||
- iOS app (iPhone)
|
- iOS app (iPhone) — iOS 26+
|
||||||
- watchOS app (Apple Watch companion)
|
- watchOS app (Apple Watch companion) — watchOS 26+
|
||||||
- CloudKit sync between devices
|
- Swift 6, strict concurrency; SwiftUI throughout
|
||||||
- SwiftData for persistence with CloudKit automatic sync
|
- 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
|
## Data Model
|
||||||
- `name: String` - Name of the workout split
|
- **Split**: `id` (ULID), `name`, `color`, `systemImage`, `order`, timestamps;
|
||||||
- `color: String` - Color theme for the split
|
embeds Exercises.
|
||||||
- `systemImage: String` - SF Symbol icon
|
- **Exercise**: `id`, `name`, `order`, `sets`, `reps`, `weight`, `loadType`
|
||||||
- `order: Int` - Display order
|
(none/weight/duration), `durationTotalSeconds`, `weightLastUpdated`,
|
||||||
- Relationship: One-to-many with Exercise
|
`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
|
Identity is a ULID string (stable across cache rebuilds). Duration is stored as
|
||||||
- `name: String` - Exercise name
|
integer seconds.
|
||||||
- `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
|
|
||||||
|
|
||||||
### Workout
|
## Apple Watch
|
||||||
- `start: Date` - Workout start time
|
The phone is the only device that touches iCloud Drive. The watch keeps its own
|
||||||
- `end: Date?` - Workout end time (optional)
|
local SwiftData cache fed only by WatchConnectivity:
|
||||||
- `status: WorkoutStatus` - Enum (notStarted, inProgress, completed, skipped)
|
- Phone → Watch: pushes all splits + recent workouts (application context) on
|
||||||
- Relationship: Many-to-one with Split (optional)
|
every cache change.
|
||||||
- Relationship: One-to-many with WorkoutLog
|
- 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
|
## Seed Data
|
||||||
- `exerciseName: String` - Name of the exercise
|
Starter splits (Upper Body / Core / Lower Body) are generated on demand from the
|
||||||
- `date: Date` - When performed
|
bundled YAML exercise catalogs (`Workouts/Resources/*.exercises.yaml`) — never
|
||||||
- `order: Int` - Order in workout
|
auto-seeded. The catalogs also back the in-workout exercise picker.
|
||||||
- `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
|
|
||||||
|
|
||||||
## iOS App Features
|
## Features
|
||||||
|
- Create/edit/delete/reorder workout splits with custom colors and SF Symbol icons.
|
||||||
### Main Navigation (TabView)
|
- Add exercises to splits (from the bundled catalog or manually); set default
|
||||||
1. **Workouts Tab**
|
sets/reps/weight or a duration.
|
||||||
- List all workouts sorted by start date (newest first)
|
- Start a workout from a split; track per-exercise set/rest/done progress; mark
|
||||||
- Create new workout from split
|
complete/skipped; edit the plan during a session (mirrored back to the split).
|
||||||
- Edit/delete existing workouts
|
- Weight-progression charts per exercise across past sessions.
|
||||||
- Navigate to workout logs
|
- Apple Watch: run workouts from the wrist; changes sync back to the phone.
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- **Yams**: YAML parsing for exercise definitions
|
- **Yams** — YAML parsing for the exercise catalogs.
|
||||||
- **SwiftUIReorderableForEach**: Drag-to-reorder lists
|
- **IndieAbout** — About section in Settings (bundles LICENSE.md / CHANGELOG.md).
|
||||||
|
|
||||||
## Entitlements Required
|
## Release
|
||||||
- CloudKit
|
- TestFlight / App Store via `Scripts/release.sh` (xcodebuild archive +
|
||||||
- Push Notifications (aps-environment)
|
App Store Connect API upload; credentials in `.env.release`). The iOS archive
|
||||||
- App Sandbox
|
embeds the watch app.
|
||||||
- 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
|
|
||||||
|
|||||||
@@ -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>
|
||||||
Executable
+389
@@ -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")
|
||||||
Executable
+112
@@ -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) }
|
||||||
Executable
+280
@@ -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)
|
||||||
|
}
|
||||||
Executable
+59
@@ -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()
|
||||||
Executable
+79
@@ -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 (~5–15 min)."
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Wire format for the iPhone↔Watch 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] }
|
||||||
|
}
|
||||||
@@ -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 iPhone↔Watch 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
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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 (0–59) 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 iPhone↔Watch 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-3
@@ -1,4 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
static func color(from name: String) -> 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 {
|
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"]
|
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"]
|
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"]
|
||||||
@@ -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 iPhone↔Watch bridge. The watch never touches iCloud — it
|
||||||
|
/// keeps a local SwiftData cache fed only by application-context pushes from the
|
||||||
|
/// phone, updates it optimistically on local edits, and forwards changed workouts
|
||||||
|
/// to the phone (which is the sole writer of iCloud Drive).
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class WatchConnectivityBridge: NSObject {
|
||||||
|
private let container: ModelContainer
|
||||||
|
private var session: WCSession?
|
||||||
|
|
||||||
|
/// Last time state was received from the phone (for a sync indicator).
|
||||||
|
private(set) var lastSyncDate: Date?
|
||||||
|
|
||||||
|
private var context: ModelContext { container.mainContext }
|
||||||
|
|
||||||
|
init(container: ModelContainer) {
|
||||||
|
self.container = container
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
guard WCSession.isSupported() else { return }
|
||||||
|
let session = WCSession.default
|
||||||
|
session.delegate = self
|
||||||
|
session.activate()
|
||||||
|
self.session = session
|
||||||
|
// Apply whatever the phone last pushed, then ask for a fresh push.
|
||||||
|
applyState(WCPayload.decodeSplits(session.receivedApplicationContext),
|
||||||
|
workouts: WCPayload.decodeWorkouts(session.receivedApplicationContext))
|
||||||
|
requestSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestSync() {
|
||||||
|
guard let session, session.activationState == .activated, session.isReachable else { return }
|
||||||
|
session.sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optimistically applies a workout edit to the local cache and forwards it to
|
||||||
|
/// the phone for durable persistence in iCloud Drive.
|
||||||
|
func update(workout doc: WorkoutDocument) {
|
||||||
|
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
|
||||||
|
try? context.save()
|
||||||
|
sendToPhone(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internal
|
||||||
|
|
||||||
|
private func sendToPhone(_ doc: WorkoutDocument) {
|
||||||
|
guard let session, session.activationState == .activated else { return }
|
||||||
|
let payload = WCPayload.encodeWorkoutUpdate(doc)
|
||||||
|
if session.isReachable {
|
||||||
|
session.sendMessage(payload, replyHandler: nil, errorHandler: { _ in
|
||||||
|
session.transferUserInfo(payload) // fall back to guaranteed delivery
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
session.transferUserInfo(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) {
|
||||||
|
guard !splits.isEmpty || !workouts.isEmpty else { return }
|
||||||
|
var liveSplitIDs = Set<String>()
|
||||||
|
for s in splits {
|
||||||
|
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
|
||||||
|
liveSplitIDs.insert(s.id)
|
||||||
|
}
|
||||||
|
for w in workouts {
|
||||||
|
CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
|
||||||
|
}
|
||||||
|
// Splits are sent in full → prune any the phone no longer has. Workouts are
|
||||||
|
// sent as a recent window, so they're upserted but never pruned (avoids a
|
||||||
|
// race deleting a workout just created on the watch).
|
||||||
|
if let allSplits = try? context.fetch(FetchDescriptor<Split>()) {
|
||||||
|
for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) }
|
||||||
|
}
|
||||||
|
try? context.save()
|
||||||
|
lastSyncDate = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WCSessionDelegate
|
||||||
|
|
||||||
|
extension WatchConnectivityBridge: WCSessionDelegate {
|
||||||
|
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||||
|
Task { @MainActor in self.requestSync() }
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||||
|
let splits = WCPayload.decodeSplits(applicationContext)
|
||||||
|
let workouts = WCPayload.decodeWorkouts(applicationContext)
|
||||||
|
Task { @MainActor in self.applyState(splits, workouts: workouts) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
//
|
|
||||||
// WatchConnectivityManager.swift
|
|
||||||
// Workouts Watch App
|
|
||||||
//
|
|
||||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import WatchConnectivity
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
class WatchConnectivityManager: NSObject, ObservableObject {
|
|
||||||
static let shared = WatchConnectivityManager()
|
|
||||||
|
|
||||||
private var session: WCSession?
|
|
||||||
private var viewContext: NSManagedObjectContext?
|
|
||||||
|
|
||||||
@Published var lastSyncDate: Date?
|
|
||||||
|
|
||||||
override init() {
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
if WCSession.isSupported() {
|
|
||||||
session = WCSession.default
|
|
||||||
session?.delegate = self
|
|
||||||
session?.activate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setViewContext(_ context: NSManagedObjectContext) {
|
|
||||||
self.viewContext = context
|
|
||||||
|
|
||||||
// Process any pending application context
|
|
||||||
if let session = session, !session.receivedApplicationContext.isEmpty {
|
|
||||||
processApplicationContext(session.receivedApplicationContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Send Data to iOS
|
|
||||||
|
|
||||||
func syncToiOS() {
|
|
||||||
guard let session = session else {
|
|
||||||
print("[WC-Watch] No WCSession")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
print("[WC-Watch] Syncing to iOS - state: \(session.activationState.rawValue), reachable: \(session.isReachable)")
|
|
||||||
|
|
||||||
guard session.activationState == .activated else {
|
|
||||||
print("[WC-Watch] Session not activated")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let context = viewContext else {
|
|
||||||
print("[WC-Watch] No view context")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
context.perform {
|
|
||||||
do {
|
|
||||||
let workoutsData = try self.encodeAllWorkouts(context: context)
|
|
||||||
|
|
||||||
let payload: [String: Any] = [
|
|
||||||
"type": "syncFromWatch",
|
|
||||||
"workouts": workoutsData,
|
|
||||||
"timestamp": Date().timeIntervalSince1970
|
|
||||||
]
|
|
||||||
|
|
||||||
if session.isReachable {
|
|
||||||
session.sendMessage(payload, replyHandler: nil) { error in
|
|
||||||
print("[WC-Watch] Failed to send sync: \(error)")
|
|
||||||
}
|
|
||||||
print("[WC-Watch] Sent \(workoutsData.count) workouts to iOS via message")
|
|
||||||
} else {
|
|
||||||
// Use transferUserInfo for background delivery
|
|
||||||
session.transferUserInfo(payload)
|
|
||||||
print("[WC-Watch] Queued \(workoutsData.count) workouts to iOS via transferUserInfo")
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
print("[WC-Watch] Failed to encode data: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func encodeAllWorkouts(context: NSManagedObjectContext) throws -> [[String: Any]] {
|
|
||||||
let request = Workout.fetchRequest()
|
|
||||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
|
|
||||||
let workouts = try context.fetch(request)
|
|
||||||
return workouts.map { encodeWorkout($0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func encodeWorkout(_ workout: Workout) -> [String: Any] {
|
|
||||||
var data: [String: Any] = [
|
|
||||||
"start": workout.start.timeIntervalSince1970,
|
|
||||||
"status": workout.status.rawValue
|
|
||||||
]
|
|
||||||
|
|
||||||
if let end = workout.end {
|
|
||||||
data["end"] = end.timeIntervalSince1970
|
|
||||||
}
|
|
||||||
|
|
||||||
if let split = workout.split {
|
|
||||||
data["splitName"] = split.name
|
|
||||||
}
|
|
||||||
|
|
||||||
data["logs"] = workout.logsArray.map { encodeWorkoutLog($0) }
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
private func encodeWorkoutLog(_ log: WorkoutLog) -> [String: Any] {
|
|
||||||
var data: [String: Any] = [
|
|
||||||
"exerciseName": log.exerciseName,
|
|
||||||
"order": log.order,
|
|
||||||
"sets": log.sets,
|
|
||||||
"reps": log.reps,
|
|
||||||
"weight": log.weight,
|
|
||||||
"status": log.status.rawValue,
|
|
||||||
"currentStateIndex": log.currentStateIndex,
|
|
||||||
"completed": log.completed,
|
|
||||||
"loadType": log.loadType
|
|
||||||
]
|
|
||||||
|
|
||||||
if let duration = log.duration {
|
|
||||||
data["duration"] = duration.timeIntervalSince1970
|
|
||||||
}
|
|
||||||
|
|
||||||
if let notes = log.notes {
|
|
||||||
data["notes"] = notes
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Request Sync from iOS
|
|
||||||
|
|
||||||
func requestSync() {
|
|
||||||
guard let session = session else {
|
|
||||||
print("[WC-Watch] No WCSession")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
print("[WC-Watch] Session state: \(session.activationState.rawValue), reachable: \(session.isReachable)")
|
|
||||||
|
|
||||||
guard session.isReachable else {
|
|
||||||
print("[WC-Watch] iPhone not reachable, checking pending context...")
|
|
||||||
// Try to process any pending application context
|
|
||||||
if !session.receivedApplicationContext.isEmpty {
|
|
||||||
print("[WC-Watch] Found pending context, processing...")
|
|
||||||
processApplicationContext(session.receivedApplicationContext)
|
|
||||||
} else {
|
|
||||||
print("[WC-Watch] No pending context")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session.sendMessage(["type": "requestSync"], replyHandler: nil) { error in
|
|
||||||
print("[WC-Watch] Failed to request sync: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Process Incoming Data
|
|
||||||
|
|
||||||
private func processApplicationContext(_ context: [String: Any]) {
|
|
||||||
guard let viewContext = viewContext else {
|
|
||||||
print("View context not set")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
viewContext.perform {
|
|
||||||
do {
|
|
||||||
// Process splits first (workouts reference them)
|
|
||||||
if let splitsData = context["splits"] as? [[String: Any]] {
|
|
||||||
// Get all split names from iOS
|
|
||||||
let iosSplitNames = Set(splitsData.compactMap { $0["name"] as? String })
|
|
||||||
|
|
||||||
// Delete splits not on iOS
|
|
||||||
let existingSplits = (try? viewContext.fetch(Split.fetchRequest())) ?? []
|
|
||||||
for split in existingSplits {
|
|
||||||
if !iosSplitNames.contains(split.name) {
|
|
||||||
viewContext.delete(split)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for splitData in splitsData {
|
|
||||||
self.importSplit(splitData, context: viewContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process workouts
|
|
||||||
if let workoutsData = context["workouts"] as? [[String: Any]] {
|
|
||||||
// Get all workout start dates from iOS
|
|
||||||
let iosStartDates = Set(workoutsData.compactMap { $0["start"] as? TimeInterval })
|
|
||||||
|
|
||||||
// Delete workouts not on iOS
|
|
||||||
let existingWorkouts = (try? viewContext.fetch(Workout.fetchRequest())) ?? []
|
|
||||||
for workout in existingWorkouts {
|
|
||||||
let startInterval = workout.start.timeIntervalSince1970
|
|
||||||
// Check if this workout exists on iOS (within 1 second tolerance)
|
|
||||||
let existsOnIOS = iosStartDates.contains { abs($0 - startInterval) < 1 }
|
|
||||||
if !existsOnIOS {
|
|
||||||
viewContext.delete(workout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for workoutData in workoutsData {
|
|
||||||
self.importWorkout(workoutData, context: viewContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try viewContext.save()
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.lastSyncDate = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Successfully imported data from iPhone")
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
print("Failed to import data: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Import Methods
|
|
||||||
|
|
||||||
private func importSplit(_ data: [String: Any], context: NSManagedObjectContext) {
|
|
||||||
guard let idString = data["id"] as? String,
|
|
||||||
let name = data["name"] as? String else { return }
|
|
||||||
|
|
||||||
// Find existing or create new
|
|
||||||
let split = findOrCreateSplit(idString: idString, name: name, context: context)
|
|
||||||
|
|
||||||
split.name = name
|
|
||||||
split.color = data["color"] as? String ?? "blue"
|
|
||||||
split.systemImage = data["systemImage"] as? String ?? "dumbbell.fill"
|
|
||||||
split.order = Int32(data["order"] as? Int ?? 0)
|
|
||||||
|
|
||||||
// Import exercises
|
|
||||||
if let exercisesData = data["exercises"] as? [[String: Any]] {
|
|
||||||
// Get all exercise names from iOS
|
|
||||||
let iosExerciseNames = Set(exercisesData.compactMap { $0["name"] as? String })
|
|
||||||
|
|
||||||
// Delete exercises not on iOS
|
|
||||||
for exercise in split.exercisesArray {
|
|
||||||
if !iosExerciseNames.contains(exercise.name) {
|
|
||||||
context.delete(exercise)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import/update exercises from iOS
|
|
||||||
for exerciseData in exercisesData {
|
|
||||||
importExercise(exerciseData, split: split, context: context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func importExercise(_ data: [String: Any], split: Split, context: NSManagedObjectContext) {
|
|
||||||
guard let idString = data["id"] as? String,
|
|
||||||
let name = data["name"] as? String else { return }
|
|
||||||
|
|
||||||
let exercise = findOrCreateExercise(idString: idString, name: name, split: split, context: context)
|
|
||||||
|
|
||||||
exercise.name = name
|
|
||||||
exercise.order = Int32(data["order"] as? Int ?? 0)
|
|
||||||
exercise.sets = Int32(data["sets"] as? Int ?? 3)
|
|
||||||
exercise.reps = Int32(data["reps"] as? Int ?? 10)
|
|
||||||
exercise.weight = Int32(data["weight"] as? Int ?? 0)
|
|
||||||
exercise.loadType = Int32(data["loadType"] as? Int ?? 1)
|
|
||||||
|
|
||||||
if let durationInterval = data["duration"] as? TimeInterval {
|
|
||||||
exercise.duration = Date(timeIntervalSince1970: durationInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
exercise.split = split
|
|
||||||
}
|
|
||||||
|
|
||||||
private func importWorkout(_ data: [String: Any], context: NSManagedObjectContext) {
|
|
||||||
guard let idString = data["id"] as? String,
|
|
||||||
let startInterval = data["start"] as? TimeInterval else { return }
|
|
||||||
|
|
||||||
let startDate = Date(timeIntervalSince1970: startInterval)
|
|
||||||
let workout = findOrCreateWorkout(idString: idString, startDate: startDate, context: context)
|
|
||||||
|
|
||||||
workout.start = startDate
|
|
||||||
|
|
||||||
if let endInterval = data["end"] as? TimeInterval {
|
|
||||||
workout.end = Date(timeIntervalSince1970: endInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let statusRaw = data["status"] as? String,
|
|
||||||
let status = WorkoutStatus(rawValue: statusRaw) {
|
|
||||||
workout.status = status
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link to split
|
|
||||||
if let splitName = data["splitName"] as? String {
|
|
||||||
workout.split = findSplitByName(splitName, context: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import logs
|
|
||||||
if let logsData = data["logs"] as? [[String: Any]] {
|
|
||||||
// Get all exercise names from iOS
|
|
||||||
let iosExerciseNames = Set(logsData.compactMap { $0["exerciseName"] as? String })
|
|
||||||
|
|
||||||
// Delete logs not on iOS
|
|
||||||
for log in workout.logsArray {
|
|
||||||
if !iosExerciseNames.contains(log.exerciseName) {
|
|
||||||
context.delete(log)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import/update logs from iOS
|
|
||||||
for logData in logsData {
|
|
||||||
importWorkoutLog(logData, workout: workout, context: context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func importWorkoutLog(_ data: [String: Any], workout: Workout, context: NSManagedObjectContext) {
|
|
||||||
guard let idString = data["id"] as? String,
|
|
||||||
let exerciseName = data["exerciseName"] as? String else { return }
|
|
||||||
|
|
||||||
let log = findOrCreateWorkoutLog(idString: idString, exerciseName: exerciseName, workout: workout, context: context)
|
|
||||||
|
|
||||||
log.exerciseName = exerciseName
|
|
||||||
log.date = Date(timeIntervalSince1970: data["date"] as? TimeInterval ?? Date().timeIntervalSince1970)
|
|
||||||
log.order = Int32(data["order"] as? Int ?? 0)
|
|
||||||
log.sets = Int32(data["sets"] as? Int ?? 3)
|
|
||||||
log.reps = Int32(data["reps"] as? Int ?? 10)
|
|
||||||
log.weight = Int32(data["weight"] as? Int ?? 0)
|
|
||||||
log.currentStateIndex = Int32(data["currentStateIndex"] as? Int ?? 0)
|
|
||||||
log.completed = data["completed"] as? Bool ?? false
|
|
||||||
log.loadType = Int32(data["loadType"] as? Int ?? 1)
|
|
||||||
|
|
||||||
if let statusRaw = data["status"] as? String,
|
|
||||||
let status = WorkoutStatus(rawValue: statusRaw) {
|
|
||||||
log.status = status
|
|
||||||
}
|
|
||||||
|
|
||||||
if let durationInterval = data["duration"] as? TimeInterval {
|
|
||||||
log.duration = Date(timeIntervalSince1970: durationInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.notes = data["notes"] as? String
|
|
||||||
log.workout = workout
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Find or Create Helpers
|
|
||||||
|
|
||||||
private func findOrCreateSplit(idString: String, name: String, context: NSManagedObjectContext) -> Split {
|
|
||||||
// Try to find by name first (more reliable than object ID across devices)
|
|
||||||
let request = Split.fetchRequest()
|
|
||||||
request.predicate = NSPredicate(format: "name == %@", name)
|
|
||||||
request.fetchLimit = 1
|
|
||||||
|
|
||||||
if let existing = try? context.fetch(request).first {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
return Split(context: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findOrCreateExercise(idString: String, name: String, split: Split, context: NSManagedObjectContext) -> Exercise {
|
|
||||||
// Find by name within split
|
|
||||||
if let existing = split.exercisesArray.first(where: { $0.name == name }) {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
return Exercise(context: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findOrCreateWorkout(idString: String, startDate: Date, context: NSManagedObjectContext) -> Workout {
|
|
||||||
// Find by start date (should be unique per workout)
|
|
||||||
let request = Workout.fetchRequest()
|
|
||||||
// Match within 1 second to account for any floating point differences
|
|
||||||
let startInterval = startDate.timeIntervalSince1970
|
|
||||||
request.predicate = NSPredicate(
|
|
||||||
format: "start >= %@ AND start <= %@",
|
|
||||||
Date(timeIntervalSince1970: startInterval - 1) as NSDate,
|
|
||||||
Date(timeIntervalSince1970: startInterval + 1) as NSDate
|
|
||||||
)
|
|
||||||
request.fetchLimit = 1
|
|
||||||
|
|
||||||
if let existing = try? context.fetch(request).first {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
return Workout(context: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findOrCreateWorkoutLog(idString: String, exerciseName: String, workout: Workout, context: NSManagedObjectContext) -> WorkoutLog {
|
|
||||||
// Find existing log in this workout with same exercise name
|
|
||||||
if let existing = workout.logsArray.first(where: { $0.exerciseName == exerciseName }) {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
return WorkoutLog(context: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findSplitByName(_ name: String, context: NSManagedObjectContext) -> Split? {
|
|
||||||
let request = Split.fetchRequest()
|
|
||||||
request.predicate = NSPredicate(format: "name == %@", name)
|
|
||||||
request.fetchLimit = 1
|
|
||||||
return try? context.fetch(request).first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - WCSessionDelegate
|
|
||||||
|
|
||||||
extension WatchConnectivityManager: WCSessionDelegate {
|
|
||||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
|
||||||
if let error = error {
|
|
||||||
print("[WC-Watch] Activation failed: \(error)")
|
|
||||||
} else {
|
|
||||||
print("[WC-Watch] Activated with state: \(activationState.rawValue)")
|
|
||||||
|
|
||||||
// Check for any pending context
|
|
||||||
let context = session.receivedApplicationContext
|
|
||||||
print("[WC-Watch] Pending context keys: \(context.keys)")
|
|
||||||
if !context.isEmpty {
|
|
||||||
print("[WC-Watch] Processing pending context...")
|
|
||||||
processApplicationContext(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Receive application context updates
|
|
||||||
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
|
||||||
print("[WC-Watch] Received application context with keys: \(applicationContext.keys)")
|
|
||||||
if let workouts = applicationContext["workouts"] as? [[String: Any]] {
|
|
||||||
print("[WC-Watch] Contains \(workouts.count) workouts")
|
|
||||||
}
|
|
||||||
processApplicationContext(applicationContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Receive immediate messages
|
|
||||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
|
||||||
if let type = message["type"] as? String {
|
|
||||||
switch type {
|
|
||||||
case "workoutUpdate":
|
|
||||||
if let workoutData = message["workout"] as? [String: Any],
|
|
||||||
let context = viewContext {
|
|
||||||
context.perform {
|
|
||||||
self.importWorkout(workoutData, context: context)
|
|
||||||
try? context.save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,14 @@
|
|||||||
//
|
//
|
||||||
// ContentView.swift
|
// ContentView.swift
|
||||||
// Workouts Watch App
|
// 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 SwiftUI
|
||||||
import CoreData
|
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WorkoutLogsView()
|
WorkoutLogsView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ContentView()
|
|
||||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(Exercise)
|
|
||||||
public class Exercise: NSManagedObject, Identifiable {
|
|
||||||
@NSManaged public var name: String
|
|
||||||
@NSManaged public var loadType: Int32
|
|
||||||
@NSManaged public var order: Int32
|
|
||||||
@NSManaged public var sets: Int32
|
|
||||||
@NSManaged public var reps: Int32
|
|
||||||
@NSManaged public var weight: Int32
|
|
||||||
@NSManaged public var duration: Date?
|
|
||||||
@NSManaged public var weightLastUpdated: Date?
|
|
||||||
@NSManaged public var weightReminderTimeIntervalWeeks: Int32
|
|
||||||
|
|
||||||
@NSManaged public var split: Split?
|
|
||||||
|
|
||||||
public var id: NSManagedObjectID { objectID }
|
|
||||||
|
|
||||||
var loadTypeEnum: LoadType {
|
|
||||||
get { LoadType(rawValue: Int(loadType)) ?? .weight }
|
|
||||||
set { loadType = Int32(newValue.rawValue) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duration helpers for minutes/seconds conversion
|
|
||||||
var durationMinutes: Int {
|
|
||||||
get {
|
|
||||||
guard let duration = duration else { return 0 }
|
|
||||||
return Int(duration.timeIntervalSince1970) / 60
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
let seconds = durationSeconds
|
|
||||||
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var durationSeconds: Int {
|
|
||||||
get {
|
|
||||||
guard let duration = duration else { return 0 }
|
|
||||||
return Int(duration.timeIntervalSince1970) % 60
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
let minutes = durationMinutes
|
|
||||||
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fetch Request
|
|
||||||
|
|
||||||
extension Exercise {
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Exercise> {
|
|
||||||
return NSFetchRequest<Exercise>(entityName: "Exercise")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func orderedFetchRequest(for split: Split) -> NSFetchRequest<Exercise> {
|
|
||||||
let request = fetchRequest()
|
|
||||||
request.predicate = NSPredicate(format: "split == %@", split)
|
|
||||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Exercise.order, ascending: true)]
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
enum LoadType: Int, CaseIterable {
|
|
||||||
case none = 0
|
|
||||||
case weight = 1
|
|
||||||
case duration = 2
|
|
||||||
|
|
||||||
var name: String {
|
|
||||||
switch self {
|
|
||||||
case .none: "None"
|
|
||||||
case .weight: "Weight"
|
|
||||||
case .duration: "Duration"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@objc(Split)
|
|
||||||
public class Split: NSManagedObject, Identifiable {
|
|
||||||
@NSManaged public var name: String
|
|
||||||
@NSManaged public var color: String
|
|
||||||
@NSManaged public var systemImage: String
|
|
||||||
@NSManaged public var order: Int32
|
|
||||||
|
|
||||||
@NSManaged public var exercises: NSSet?
|
|
||||||
@NSManaged public var workouts: NSSet?
|
|
||||||
|
|
||||||
public var id: NSManagedObjectID { objectID }
|
|
||||||
|
|
||||||
static let unnamed = "Unnamed Split"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience Accessors
|
|
||||||
|
|
||||||
extension Split {
|
|
||||||
var exercisesArray: [Exercise] {
|
|
||||||
let set = exercises as? Set<Exercise> ?? []
|
|
||||||
return set.sorted { $0.order < $1.order }
|
|
||||||
}
|
|
||||||
|
|
||||||
var workoutsArray: [Workout] {
|
|
||||||
let set = workouts as? Set<Workout> ?? []
|
|
||||||
return set.sorted { $0.start > $1.start }
|
|
||||||
}
|
|
||||||
|
|
||||||
func addToExercises(_ exercise: Exercise) {
|
|
||||||
let items = mutableSetValue(forKey: "exercises")
|
|
||||||
items.add(exercise)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeFromExercises(_ exercise: Exercise) {
|
|
||||||
let items = mutableSetValue(forKey: "exercises")
|
|
||||||
items.remove(exercise)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addToWorkouts(_ workout: Workout) {
|
|
||||||
let items = mutableSetValue(forKey: "workouts")
|
|
||||||
items.add(workout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeFromWorkouts(_ workout: Workout) {
|
|
||||||
let items = mutableSetValue(forKey: "workouts")
|
|
||||||
items.remove(workout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fetch Request
|
|
||||||
|
|
||||||
extension Split {
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Split> {
|
|
||||||
return NSFetchRequest<Split>(entityName: "Split")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func orderedFetchRequest() -> NSFetchRequest<Split> {
|
|
||||||
let request = fetchRequest()
|
|
||||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Split.order, ascending: true)]
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(Workout)
|
|
||||||
public class Workout: NSManagedObject, Identifiable {
|
|
||||||
@NSManaged public var start: Date
|
|
||||||
@NSManaged public var end: Date?
|
|
||||||
|
|
||||||
@NSManaged public var split: Split?
|
|
||||||
@NSManaged public var logs: NSSet?
|
|
||||||
|
|
||||||
public var id: NSManagedObjectID { objectID }
|
|
||||||
|
|
||||||
var status: WorkoutStatus {
|
|
||||||
get {
|
|
||||||
willAccessValue(forKey: "status")
|
|
||||||
let raw = primitiveValue(forKey: "status") as? String ?? "notStarted"
|
|
||||||
didAccessValue(forKey: "status")
|
|
||||||
return WorkoutStatus(rawValue: raw) ?? .notStarted
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
willChangeValue(forKey: "status")
|
|
||||||
setPrimitiveValue(newValue.rawValue, forKey: "status")
|
|
||||||
didChangeValue(forKey: "status")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var label: String {
|
|
||||||
if status == .completed, let endDate = end {
|
|
||||||
if start.isSameDay(as: endDate) {
|
|
||||||
return "\(start.formattedDate())—\(endDate.formattedTime())"
|
|
||||||
} else {
|
|
||||||
return "\(start.formattedDate())—\(endDate.formattedDate())"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return start.formattedDate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var statusName: String {
|
|
||||||
return status.displayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience Accessors
|
|
||||||
|
|
||||||
extension Workout {
|
|
||||||
var logsArray: [WorkoutLog] {
|
|
||||||
let set = logs as? Set<WorkoutLog> ?? []
|
|
||||||
return set.sorted { $0.order < $1.order }
|
|
||||||
}
|
|
||||||
|
|
||||||
func addToLogs(_ log: WorkoutLog) {
|
|
||||||
let items = mutableSetValue(forKey: "logs")
|
|
||||||
items.add(log)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeFromLogs(_ log: WorkoutLog) {
|
|
||||||
let items = mutableSetValue(forKey: "logs")
|
|
||||||
items.remove(log)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fetch Request
|
|
||||||
|
|
||||||
extension Workout {
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Workout> {
|
|
||||||
return NSFetchRequest<Workout>(entityName: "Workout")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func recentFetchRequest() -> NSFetchRequest<Workout> {
|
|
||||||
let request = fetchRequest()
|
|
||||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
static func fetchRequest(for split: Split) -> NSFetchRequest<Workout> {
|
|
||||||
let request = fetchRequest()
|
|
||||||
request.predicate = NSPredicate(format: "split == %@", split)
|
|
||||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(WorkoutLog)
|
|
||||||
public class WorkoutLog: NSManagedObject, Identifiable {
|
|
||||||
@NSManaged public var date: Date
|
|
||||||
@NSManaged public var sets: Int32
|
|
||||||
@NSManaged public var reps: Int32
|
|
||||||
@NSManaged public var weight: Int32
|
|
||||||
@NSManaged public var order: Int32
|
|
||||||
@NSManaged public var exerciseName: String
|
|
||||||
@NSManaged public var currentStateIndex: Int32
|
|
||||||
@NSManaged public var elapsedSeconds: Int32
|
|
||||||
@NSManaged public var completed: Bool
|
|
||||||
@NSManaged public var loadType: Int32
|
|
||||||
@NSManaged public var duration: Date?
|
|
||||||
@NSManaged public var notes: String?
|
|
||||||
|
|
||||||
@NSManaged public var workout: Workout?
|
|
||||||
|
|
||||||
public var id: NSManagedObjectID { objectID }
|
|
||||||
|
|
||||||
var status: WorkoutStatus {
|
|
||||||
get {
|
|
||||||
willAccessValue(forKey: "status")
|
|
||||||
let raw = primitiveValue(forKey: "status") as? String ?? "notStarted"
|
|
||||||
didAccessValue(forKey: "status")
|
|
||||||
return WorkoutStatus(rawValue: raw) ?? .notStarted
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
willChangeValue(forKey: "status")
|
|
||||||
setPrimitiveValue(newValue.rawValue, forKey: "status")
|
|
||||||
didChangeValue(forKey: "status")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var loadTypeEnum: LoadType {
|
|
||||||
get { LoadType(rawValue: Int(loadType)) ?? .weight }
|
|
||||||
set { loadType = Int32(newValue.rawValue) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duration helpers for minutes/seconds conversion
|
|
||||||
var durationMinutes: Int {
|
|
||||||
get {
|
|
||||||
guard let duration = duration else { return 0 }
|
|
||||||
return Int(duration.timeIntervalSince1970) / 60
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
let seconds = durationSeconds
|
|
||||||
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var durationSeconds: Int {
|
|
||||||
get {
|
|
||||||
guard let duration = duration else { return 0 }
|
|
||||||
return Int(duration.timeIntervalSince1970) % 60
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
let minutes = durationMinutes
|
|
||||||
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fetch Request
|
|
||||||
|
|
||||||
extension WorkoutLog {
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<WorkoutLog> {
|
|
||||||
return NSFetchRequest<WorkoutLog>(entityName: "WorkoutLog")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func orderedFetchRequest(for workout: Workout) -> NSFetchRequest<WorkoutLog> {
|
|
||||||
let request = fetchRequest()
|
|
||||||
request.predicate = NSPredicate(format: "workout == %@", workout)
|
|
||||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \WorkoutLog.order, ascending: true)]
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum WorkoutStatus: String, CaseIterable, Codable {
|
|
||||||
case notStarted = "notStarted"
|
|
||||||
case inProgress = "inProgress"
|
|
||||||
case completed = "completed"
|
|
||||||
case skipped = "skipped"
|
|
||||||
|
|
||||||
var displayName: String {
|
|
||||||
switch self {
|
|
||||||
case .notStarted:
|
|
||||||
return "Not Started"
|
|
||||||
case .inProgress:
|
|
||||||
return "In Progress"
|
|
||||||
case .completed:
|
|
||||||
return "Completed"
|
|
||||||
case .skipped:
|
|
||||||
return "Skipped"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var name: String { displayName }
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import CoreData
|
|
||||||
import CloudKit
|
|
||||||
|
|
||||||
struct PersistenceController {
|
|
||||||
static let shared = PersistenceController()
|
|
||||||
|
|
||||||
let container: NSPersistentCloudKitContainer
|
|
||||||
|
|
||||||
// CloudKit container identifier - same as iOS app for sync
|
|
||||||
static let cloudKitContainerIdentifier = "iCloud.dev.rzen.indie.Workouts"
|
|
||||||
|
|
||||||
// App Group identifier for shared storage between iOS and Watch
|
|
||||||
static let appGroupIdentifier = "group.dev.rzen.indie.Workouts"
|
|
||||||
|
|
||||||
var viewContext: NSManagedObjectContext {
|
|
||||||
container.viewContext
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview Support
|
|
||||||
|
|
||||||
static var preview: PersistenceController = {
|
|
||||||
let controller = PersistenceController(inMemory: true)
|
|
||||||
let viewContext = controller.container.viewContext
|
|
||||||
|
|
||||||
// Create sample data for previews
|
|
||||||
let split = Split(context: viewContext)
|
|
||||||
split.name = "Upper Body"
|
|
||||||
split.color = "blue"
|
|
||||||
split.systemImage = "dumbbell.fill"
|
|
||||||
split.order = 0
|
|
||||||
|
|
||||||
let exercise = Exercise(context: viewContext)
|
|
||||||
exercise.name = "Bench Press"
|
|
||||||
exercise.sets = 3
|
|
||||||
exercise.reps = 10
|
|
||||||
exercise.weight = 135
|
|
||||||
exercise.order = 0
|
|
||||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
|
||||||
exercise.split = split
|
|
||||||
|
|
||||||
do {
|
|
||||||
try viewContext.save()
|
|
||||||
} catch {
|
|
||||||
let nsError = error as NSError
|
|
||||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return controller
|
|
||||||
}()
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
|
|
||||||
init(inMemory: Bool = false, cloudKitEnabled: Bool = true) {
|
|
||||||
container = NSPersistentCloudKitContainer(name: "Workouts")
|
|
||||||
|
|
||||||
guard let description = container.persistentStoreDescriptions.first else {
|
|
||||||
fatalError("Failed to retrieve a persistent store description.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if inMemory {
|
|
||||||
description.url = URL(fileURLWithPath: "/dev/null")
|
|
||||||
description.cloudKitContainerOptions = nil
|
|
||||||
} else {
|
|
||||||
// Use App Group container for shared storage between iOS and Watch
|
|
||||||
if let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier) {
|
|
||||||
let storeURL = appGroupURL.appendingPathComponent("Workouts.sqlite")
|
|
||||||
description.url = storeURL
|
|
||||||
print("Using shared App Group store at: \(storeURL)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if cloudKitEnabled {
|
|
||||||
// Check if CloudKit is available before enabling
|
|
||||||
let cloudKitAvailable = FileManager.default.ubiquityIdentityToken != nil
|
|
||||||
|
|
||||||
if cloudKitAvailable {
|
|
||||||
// Set CloudKit container options
|
|
||||||
let cloudKitOptions = NSPersistentCloudKitContainerOptions(
|
|
||||||
containerIdentifier: Self.cloudKitContainerIdentifier
|
|
||||||
)
|
|
||||||
description.cloudKitContainerOptions = cloudKitOptions
|
|
||||||
} else {
|
|
||||||
// CloudKit not available (not signed in, etc.)
|
|
||||||
description.cloudKitContainerOptions = nil
|
|
||||||
print("CloudKit not available - using local storage only")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable persistent history tracking (useful even without CloudKit)
|
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
|
||||||
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
|
||||||
} else {
|
|
||||||
// CloudKit explicitly disabled
|
|
||||||
description.cloudKitContainerOptions = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
container.loadPersistentStores { storeDescription, error in
|
|
||||||
if let error = error as NSError? {
|
|
||||||
// In production, handle this more gracefully
|
|
||||||
print("CoreData error: \(error), \(error.userInfo)")
|
|
||||||
#if DEBUG
|
|
||||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure view context
|
|
||||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
|
||||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
|
||||||
|
|
||||||
// Pin the viewContext to the current generation token
|
|
||||||
do {
|
|
||||||
try container.viewContext.setQueryGenerationFrom(.current)
|
|
||||||
} catch {
|
|
||||||
print("Failed to pin viewContext to the current generation: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Save Context
|
|
||||||
|
|
||||||
func save() {
|
|
||||||
let context = container.viewContext
|
|
||||||
if context.hasChanges {
|
|
||||||
do {
|
|
||||||
try context.save()
|
|
||||||
} catch {
|
|
||||||
let nsError = error as NSError
|
|
||||||
print("Save error: \(nsError), \(nsError.userInfo)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Workouts</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Workouts</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
|
<key>WKApplication</key>
|
||||||
|
<true/>
|
||||||
|
<key>WKCompanionAppBundleIdentifier</key>
|
||||||
|
<string>dev.rzen.indie.Workouts</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,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
|
import WatchKit
|
||||||
|
|
||||||
struct ExerciseProgressView: View {
|
struct ExerciseProgressView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@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 currentPage: Int = 0
|
||||||
@State private var showingCancelConfirm = false
|
@State private var showingCancelConfirm = false
|
||||||
|
|
||||||
|
private var log: WorkoutLogDocument? {
|
||||||
|
doc.logs.first(where: { $0.id == logID })
|
||||||
|
}
|
||||||
|
|
||||||
private var totalSets: Int {
|
private var totalSets: Int {
|
||||||
max(1, Int(workoutLog.sets))
|
max(1, log?.sets ?? 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var totalPages: Int {
|
private var totalPages: Int {
|
||||||
@@ -29,7 +38,7 @@ struct ExerciseProgressView: View {
|
|||||||
|
|
||||||
private var firstUnfinishedSetPage: Int {
|
private var firstUnfinishedSetPage: Int {
|
||||||
// currentStateIndex is the number of completed sets
|
// currentStateIndex is the number of completed sets
|
||||||
let completedSets = Int(workoutLog.currentStateIndex)
|
let completedSets = log?.currentStateIndex ?? 0
|
||||||
if completedSets >= totalSets {
|
if completedSets >= totalSets {
|
||||||
// All done, go to done page
|
// All done, go to done page
|
||||||
return totalPages - 1
|
return totalPages - 1
|
||||||
@@ -86,10 +95,10 @@ struct ExerciseProgressView: View {
|
|||||||
SetPageView(
|
SetPageView(
|
||||||
setNumber: setNumber,
|
setNumber: setNumber,
|
||||||
totalSets: totalSets,
|
totalSets: totalSets,
|
||||||
reps: Int(workoutLog.reps),
|
reps: log?.reps ?? 0,
|
||||||
isTimeBased: workoutLog.loadTypeEnum == .duration,
|
isTimeBased: LoadType(rawValue: log?.loadType ?? LoadType.weight.rawValue) == .duration,
|
||||||
durationMinutes: workoutLog.durationMinutes,
|
durationMinutes: (log?.durationSeconds ?? 0) / 60,
|
||||||
durationSeconds: workoutLog.durationSeconds
|
durationSeconds: (log?.durationSeconds ?? 0) % 60
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Rest page (1, 3, 5, ...)
|
// Rest page (1, 3, 5, ...)
|
||||||
@@ -105,50 +114,50 @@ struct ExerciseProgressView: View {
|
|||||||
let setIndex = (pageIndex + 1) / 2
|
let setIndex = (pageIndex + 1) / 2
|
||||||
let clampedProgress = min(setIndex, totalSets)
|
let clampedProgress = min(setIndex, totalSets)
|
||||||
|
|
||||||
if clampedProgress != Int(workoutLog.currentStateIndex) {
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
workoutLog.currentStateIndex = Int32(clampedProgress)
|
guard clampedProgress != doc.logs[i].currentStateIndex else { return }
|
||||||
|
|
||||||
if clampedProgress >= totalSets {
|
doc.logs[i].currentStateIndex = clampedProgress
|
||||||
workoutLog.status = .completed
|
|
||||||
workoutLog.completed = true
|
|
||||||
} else if clampedProgress > 0 {
|
|
||||||
workoutLog.status = .inProgress
|
|
||||||
workoutLog.completed = false
|
|
||||||
}
|
|
||||||
|
|
||||||
updateWorkoutStatus()
|
if clampedProgress >= totalSets {
|
||||||
try? viewContext.save()
|
doc.logs[i].status = WorkoutStatus.completed.rawValue
|
||||||
|
doc.logs[i].completed = true
|
||||||
// Sync to iOS
|
} else if clampedProgress > 0 {
|
||||||
WatchConnectivityManager.shared.syncToiOS()
|
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
||||||
|
doc.logs[i].completed = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recomputeWorkoutStatus()
|
||||||
|
doc.updatedAt = Date()
|
||||||
|
onChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func completeExercise() {
|
private func completeExercise() {
|
||||||
workoutLog.currentStateIndex = Int32(totalSets)
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
workoutLog.status = .completed
|
doc.logs[i].currentStateIndex = totalSets
|
||||||
workoutLog.completed = true
|
doc.logs[i].status = WorkoutStatus.completed.rawValue
|
||||||
updateWorkoutStatus()
|
doc.logs[i].completed = true
|
||||||
try? viewContext.save()
|
|
||||||
|
|
||||||
// Sync to iOS
|
recomputeWorkoutStatus()
|
||||||
WatchConnectivityManager.shared.syncToiOS()
|
doc.updatedAt = Date()
|
||||||
|
onChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateWorkoutStatus() {
|
private func recomputeWorkoutStatus() {
|
||||||
guard let workout = workoutLog.workout else { return }
|
let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
|
||||||
let logs = workout.logsArray
|
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
|
||||||
let allCompleted = logs.allSatisfy { $0.status == .completed }
|
let anyInProgress = statuses.contains { $0 == .inProgress }
|
||||||
let anyInProgress = logs.contains { $0.status == .inProgress }
|
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
|
||||||
let allNotStarted = logs.allSatisfy { $0.status == .notStarted }
|
|
||||||
|
|
||||||
if allCompleted {
|
if allCompleted {
|
||||||
workout.status = .completed
|
doc.status = WorkoutStatus.completed.rawValue
|
||||||
workout.end = Date()
|
doc.end = Date()
|
||||||
} else if anyInProgress || !allNotStarted {
|
} else if anyInProgress || !allNotStarted {
|
||||||
workout.status = .inProgress
|
doc.status = WorkoutStatus.inProgress.rawValue
|
||||||
|
doc.end = nil
|
||||||
} else {
|
} else {
|
||||||
workout.status = .notStarted
|
doc.status = WorkoutStatus.notStarted.rawValue
|
||||||
|
doc.end = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +215,8 @@ struct RestPageView: View {
|
|||||||
let restNumber: Int
|
let restNumber: Int
|
||||||
|
|
||||||
@State private var elapsedSeconds: Int = 0
|
@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 {
|
var body: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
@@ -224,11 +234,12 @@ struct RestPageView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
startTimer()
|
elapsedSeconds = 0
|
||||||
WKInterfaceDevice.current().play(.start)
|
WKInterfaceDevice.current().play(.start)
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onReceive(ticker) { _ in
|
||||||
stopTimer()
|
elapsedSeconds += 1
|
||||||
|
checkHapticPing()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,19 +249,6 @@ struct RestPageView: View {
|
|||||||
return String(format: "%d:%02d", minutes, seconds)
|
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() {
|
private func checkHapticPing() {
|
||||||
// Haptic ping every 10 seconds with pattern:
|
// Haptic ping every 10 seconds with pattern:
|
||||||
// 10s: single, 20s: double, 30s: triple, 40s: single, 50s: double, etc.
|
// 10s: single, 20s: double, 30s: triple, 40s: single, 50s: double, etc.
|
||||||
|
|||||||
@@ -6,26 +6,51 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
struct WorkoutLogListView: View {
|
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 showingExercisePicker = false
|
||||||
@State private var selectedLog: WorkoutLog?
|
@State private var selectedLogID: String?
|
||||||
|
|
||||||
var sortedWorkoutLogs: [WorkoutLog] {
|
init(workout: Workout) {
|
||||||
workout.logsArray
|
_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 {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
Section(header: Text(workout.label)) {
|
Section(header: Text(label)) {
|
||||||
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
|
ForEach(sortedLogs) { log in
|
||||||
Button {
|
Button {
|
||||||
selectedLog = log
|
selectedLogID = log.id
|
||||||
} label: {
|
} label: {
|
||||||
WorkoutLogRowLabel(log: log)
|
WorkoutLogRowLabel(log: log)
|
||||||
}
|
}
|
||||||
@@ -33,42 +58,81 @@ struct WorkoutLogListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
if !availableExercises.isEmpty {
|
||||||
Button {
|
Section {
|
||||||
showingExercisePicker = true
|
Button {
|
||||||
} label: {
|
showingExercisePicker = true
|
||||||
HStack {
|
} label: {
|
||||||
Image(systemName: "plus.circle.fill")
|
HStack {
|
||||||
.foregroundColor(.green)
|
Image(systemName: "plus.circle.fill")
|
||||||
Text("Add Exercise")
|
.foregroundColor(.green)
|
||||||
|
Text("Add Exercise")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
if sortedWorkoutLogs.isEmpty {
|
if sortedLogs.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No Exercises",
|
"No Exercises",
|
||||||
systemImage: "figure.strengthtraining.traditional",
|
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)
|
.navigationTitle(doc.splitName ?? Split.unnamed)
|
||||||
.navigationDestination(item: $selectedLog) { log in
|
.navigationDestination(item: $selectedLogID) { logID in
|
||||||
ExerciseProgressView(workoutLog: log)
|
ExerciseProgressView(doc: $doc, logID: logID, onChange: { bridge.update(workout: doc) })
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingExercisePicker) {
|
.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
|
// MARK: - Workout Log Row Label
|
||||||
|
|
||||||
struct WorkoutLogRowLabel: View {
|
struct WorkoutLogRowLabel: View {
|
||||||
@ObservedObject var log: WorkoutLog
|
let log: WorkoutLogDocument
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -89,8 +153,12 @@ struct WorkoutLogRowLabel: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var status: WorkoutStatus {
|
||||||
|
WorkoutStatus(rawValue: log.status) ?? .notStarted
|
||||||
|
}
|
||||||
|
|
||||||
private var statusIcon: Image {
|
private var statusIcon: Image {
|
||||||
switch log.status {
|
switch status {
|
||||||
case .completed:
|
case .completed:
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
case .inProgress:
|
case .inProgress:
|
||||||
@@ -103,7 +171,7 @@ struct WorkoutLogRowLabel: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var statusColor: Color {
|
private var statusColor: Color {
|
||||||
switch log.status {
|
switch status {
|
||||||
case .completed:
|
case .completed:
|
||||||
.green
|
.green
|
||||||
case .inProgress:
|
case .inProgress:
|
||||||
@@ -116,9 +184,9 @@ struct WorkoutLogRowLabel: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var subtitle: String {
|
private var subtitle: String {
|
||||||
if log.loadTypeEnum == .duration {
|
if LoadType(rawValue: log.loadType) == .duration {
|
||||||
let mins = log.durationMinutes
|
let mins = log.durationSeconds / 60
|
||||||
let secs = log.durationSeconds
|
let secs = log.durationSeconds % 60
|
||||||
if mins > 0 && secs > 0 {
|
if mins > 0 && secs > 0 {
|
||||||
return "\(log.sets) × \(mins)m \(secs)s"
|
return "\(log.sets) × \(mins)m \(secs)s"
|
||||||
} else if mins > 0 {
|
} else if mins > 0 {
|
||||||
@@ -135,27 +203,22 @@ struct WorkoutLogRowLabel: View {
|
|||||||
// MARK: - Exercise Picker View
|
// MARK: - Exercise Picker View
|
||||||
|
|
||||||
struct ExercisePickerView: View {
|
struct ExercisePickerView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@ObservedObject var workout: Workout
|
let exercises: [Exercise]
|
||||||
|
let onSelect: (Exercise) -> Void
|
||||||
private var availableExercises: [Exercise] {
|
|
||||||
guard let split = workout.split else { return [] }
|
|
||||||
let existingNames = Set(workout.logsArray.map { $0.exerciseName })
|
|
||||||
return split.exercisesArray.filter { !existingNames.contains($0.name) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
if availableExercises.isEmpty {
|
if exercises.isEmpty {
|
||||||
Text("All exercises added")
|
Text("All exercises added")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(availableExercises, id: \.objectID) { exercise in
|
ForEach(exercises) { exercise in
|
||||||
Button {
|
Button {
|
||||||
addExercise(exercise)
|
onSelect(exercise)
|
||||||
|
dismiss()
|
||||||
} label: {
|
} label: {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(exercise.name)
|
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 {
|
private func exerciseSubtitle(_ exercise: Exercise) -> String {
|
||||||
let loadType = LoadType(rawValue: Int(exercise.loadType)) ?? .weight
|
if exercise.loadTypeEnum == .duration {
|
||||||
if loadType == .duration {
|
|
||||||
let mins = exercise.durationMinutes
|
let mins = exercise.durationMinutes
|
||||||
let secs = exercise.durationSeconds
|
let secs = exercise.durationSeconds
|
||||||
if mins > 0 && secs > 0 {
|
if mins > 0 && secs > 0 {
|
||||||
@@ -222,8 +258,3 @@ struct ExercisePickerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
|
||||||
WorkoutLogListView(workout: Workout())
|
|
||||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,22 +6,17 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
struct WorkoutLogsView: View {
|
struct WorkoutLogsView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(WatchConnectivityBridge.self) private var bridge
|
||||||
@EnvironmentObject var connectivityManager: WatchConnectivityManager
|
|
||||||
|
|
||||||
@FetchRequest(
|
@Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout]
|
||||||
sortDescriptors: [NSSortDescriptor(keyPath: \Workout.start, ascending: false)],
|
|
||||||
animation: .default
|
|
||||||
)
|
|
||||||
private var workouts: FetchedResults<Workout>
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
ForEach(workouts, id: \.objectID) { workout in
|
ForEach(workouts) { workout in
|
||||||
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
|
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
|
||||||
WorkoutRow(workout: workout)
|
WorkoutRow(workout: workout)
|
||||||
}
|
}
|
||||||
@@ -40,12 +35,17 @@ struct WorkoutLogsView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
connectivityManager.requestSync()
|
bridge.requestSync()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
if workouts.isEmpty {
|
||||||
|
bridge.requestSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,11 +53,11 @@ struct WorkoutLogsView: View {
|
|||||||
// MARK: - Workout Row
|
// MARK: - Workout Row
|
||||||
|
|
||||||
struct WorkoutRow: View {
|
struct WorkoutRow: View {
|
||||||
@ObservedObject var workout: Workout
|
let workout: Workout
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(workout.split?.name ?? Split.unnamed)
|
Text(workout.splitName ?? Split.unnamed)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
@@ -92,8 +92,3 @@ struct WorkoutRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
|
||||||
WorkoutLogsView()
|
|
||||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Composition root for the watch app. Owns the local SwiftData cache and the
|
||||||
|
/// WatchConnectivity bridge. The watch has no iCloud access; all data arrives from
|
||||||
|
/// the phone via the bridge.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class WatchAppServices {
|
||||||
|
let container: ModelContainer
|
||||||
|
let bridge: WatchConnectivityBridge
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let container = WorkoutsModelContainer.make()
|
||||||
|
self.container = container
|
||||||
|
self.bridge = WatchConnectivityBridge(container: container)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
bridge.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>aps-environment</key>
|
|
||||||
<string>development</string>
|
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
|
||||||
<array/>
|
|
||||||
<key>com.apple.developer.icloud-services</key>
|
|
||||||
<array>
|
|
||||||
<string>CloudKit</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
|
||||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>group.dev.rzen.indie.Workouts</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>_XCCurrentVersionName</key>
|
|
||||||
<string>Workouts.xcdatamodel</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23231" systemVersion="24D5034f" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
|
|
||||||
<entity name="Split" representedClassName="Split" syncable="YES">
|
|
||||||
<attribute name="color" attributeType="String" defaultValueString="indigo"/>
|
|
||||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
|
||||||
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="systemImage" attributeType="String" defaultValueString="dumbbell.fill"/>
|
|
||||||
<relationship name="exercises" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Exercise" inverseName="split" inverseEntity="Exercise"/>
|
|
||||||
<relationship name="workouts" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Workout" inverseName="split" inverseEntity="Workout"/>
|
|
||||||
</entity>
|
|
||||||
<entity name="Exercise" representedClassName="Exercise" syncable="YES">
|
|
||||||
<attribute name="duration" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
|
||||||
<attribute name="loadType" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
|
||||||
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="weight" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="weightLastUpdated" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
|
||||||
<attribute name="weightReminderTimeIntervalWeeks" attributeType="Integer 32" defaultValueString="2" usesScalarValueType="YES"/>
|
|
||||||
<relationship name="split" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Split" inverseName="exercises" inverseEntity="Split"/>
|
|
||||||
</entity>
|
|
||||||
<entity name="Workout" representedClassName="Workout" syncable="YES">
|
|
||||||
<attribute name="end" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
|
||||||
<attribute name="start" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
|
||||||
<attribute name="status" attributeType="String" defaultValueString="notStarted"/>
|
|
||||||
<relationship name="logs" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="WorkoutLog" inverseName="workout" inverseEntity="WorkoutLog"/>
|
|
||||||
<relationship name="split" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Split" inverseName="workouts" inverseEntity="Split"/>
|
|
||||||
</entity>
|
|
||||||
<entity name="WorkoutLog" representedClassName="WorkoutLog" syncable="YES">
|
|
||||||
<attribute name="completed" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="currentStateIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="date" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
|
||||||
<attribute name="duration" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
|
|
||||||
<attribute name="elapsedSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="exerciseName" attributeType="String" defaultValueString=""/>
|
|
||||||
<attribute name="loadType" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="notes" optional="YES" attributeType="String" defaultValueString=""/>
|
|
||||||
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
|
||||||
<attribute name="status" optional="YES" attributeType="String" defaultValueString="notStarted"/>
|
|
||||||
<attribute name="weight" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
|
||||||
<relationship name="workout" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Workout" inverseName="logs" inverseEntity="Workout"/>
|
|
||||||
</entity>
|
|
||||||
</model>
|
|
||||||
@@ -1,31 +1,23 @@
|
|||||||
//
|
//
|
||||||
// WorkoutsApp.swift
|
// WorkoutsApp.swift
|
||||||
// Workouts Watch App
|
// 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 SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct WorkoutsWatchApp: App {
|
struct WorkoutsWatchApp: App {
|
||||||
let persistenceController = PersistenceController.shared
|
@State private var services = WatchAppServices()
|
||||||
let connectivityManager = WatchConnectivityManager.shared
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Set up iPhone connectivity with Core Data context
|
|
||||||
connectivityManager.setViewContext(persistenceController.viewContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
.environment(services.bridge)
|
||||||
.environmentObject(connectivityManager)
|
.modelContainer(services.container)
|
||||||
|
.task { services.activate() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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 iPhone↔Watch 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,14 @@
|
|||||||
//
|
//
|
||||||
// ContentView.swift
|
// ContentView.swift
|
||||||
// Workouts
|
// 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 SwiftUI
|
||||||
import CoreData
|
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WorkoutLogsView()
|
WorkoutLogsView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ContentView()
|
|
||||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(Exercise)
|
|
||||||
public class Exercise: NSManagedObject, Identifiable {
|
|
||||||
@NSManaged public var name: String
|
|
||||||
@NSManaged public var loadType: Int32
|
|
||||||
@NSManaged public var order: Int32
|
|
||||||
@NSManaged public var sets: Int32
|
|
||||||
@NSManaged public var reps: Int32
|
|
||||||
@NSManaged public var weight: Int32
|
|
||||||
@NSManaged public var duration: Date?
|
|
||||||
@NSManaged public var weightLastUpdated: Date?
|
|
||||||
@NSManaged public var weightReminderTimeIntervalWeeks: Int32
|
|
||||||
|
|
||||||
@NSManaged public var split: Split?
|
|
||||||
|
|
||||||
public var id: NSManagedObjectID { objectID }
|
|
||||||
|
|
||||||
var loadTypeEnum: LoadType {
|
|
||||||
get { LoadType(rawValue: Int(loadType)) ?? .weight }
|
|
||||||
set { loadType = Int32(newValue.rawValue) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duration helpers for minutes/seconds conversion
|
|
||||||
var durationMinutes: Int {
|
|
||||||
get {
|
|
||||||
guard let duration = duration else { return 0 }
|
|
||||||
return Int(duration.timeIntervalSince1970) / 60
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
let seconds = durationSeconds
|
|
||||||
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var durationSeconds: Int {
|
|
||||||
get {
|
|
||||||
guard let duration = duration else { return 0 }
|
|
||||||
return Int(duration.timeIntervalSince1970) % 60
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
let minutes = durationMinutes
|
|
||||||
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fetch Request
|
|
||||||
|
|
||||||
extension Exercise {
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Exercise> {
|
|
||||||
return NSFetchRequest<Exercise>(entityName: "Exercise")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func orderedFetchRequest(for split: Split) -> NSFetchRequest<Exercise> {
|
|
||||||
let request = fetchRequest()
|
|
||||||
request.predicate = NSPredicate(format: "split == %@", split)
|
|
||||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Exercise.order, ascending: true)]
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
enum LoadType: Int, CaseIterable {
|
|
||||||
case none = 0
|
|
||||||
case weight = 1
|
|
||||||
case duration = 2
|
|
||||||
|
|
||||||
var name: String {
|
|
||||||
switch self {
|
|
||||||
case .none: "None"
|
|
||||||
case .weight: "Weight"
|
|
||||||
case .duration: "Duration"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@objc(Split)
|
|
||||||
public class Split: NSManagedObject, Identifiable {
|
|
||||||
@NSManaged public var name: String
|
|
||||||
@NSManaged public var color: String
|
|
||||||
@NSManaged public var systemImage: String
|
|
||||||
@NSManaged public var order: Int32
|
|
||||||
|
|
||||||
@NSManaged public var exercises: NSSet?
|
|
||||||
@NSManaged public var workouts: NSSet?
|
|
||||||
|
|
||||||
public var id: NSManagedObjectID { objectID }
|
|
||||||
|
|
||||||
static let unnamed = "Unnamed Split"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience Accessors
|
|
||||||
|
|
||||||
extension Split {
|
|
||||||
var exercisesArray: [Exercise] {
|
|
||||||
let set = exercises as? Set<Exercise> ?? []
|
|
||||||
return set.sorted { $0.order < $1.order }
|
|
||||||
}
|
|
||||||
|
|
||||||
var workoutsArray: [Workout] {
|
|
||||||
let set = workouts as? Set<Workout> ?? []
|
|
||||||
return set.sorted { $0.start > $1.start }
|
|
||||||
}
|
|
||||||
|
|
||||||
func addToExercises(_ exercise: Exercise) {
|
|
||||||
let items = mutableSetValue(forKey: "exercises")
|
|
||||||
items.add(exercise)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeFromExercises(_ exercise: Exercise) {
|
|
||||||
let items = mutableSetValue(forKey: "exercises")
|
|
||||||
items.remove(exercise)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addToWorkouts(_ workout: Workout) {
|
|
||||||
let items = mutableSetValue(forKey: "workouts")
|
|
||||||
items.add(workout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeFromWorkouts(_ workout: Workout) {
|
|
||||||
let items = mutableSetValue(forKey: "workouts")
|
|
||||||
items.remove(workout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fetch Request
|
|
||||||
|
|
||||||
extension Split {
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Split> {
|
|
||||||
return NSFetchRequest<Split>(entityName: "Split")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func orderedFetchRequest() -> NSFetchRequest<Split> {
|
|
||||||
let request = fetchRequest()
|
|
||||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Split.order, ascending: true)]
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(Workout)
|
|
||||||
public class Workout: NSManagedObject, Identifiable {
|
|
||||||
@NSManaged public var start: Date
|
|
||||||
@NSManaged public var end: Date?
|
|
||||||
|
|
||||||
@NSManaged public var split: Split?
|
|
||||||
@NSManaged public var logs: NSSet?
|
|
||||||
|
|
||||||
public var id: NSManagedObjectID { objectID }
|
|
||||||
|
|
||||||
var status: WorkoutStatus {
|
|
||||||
get {
|
|
||||||
willAccessValue(forKey: "status")
|
|
||||||
let raw = primitiveValue(forKey: "status") as? String ?? "notStarted"
|
|
||||||
didAccessValue(forKey: "status")
|
|
||||||
return WorkoutStatus(rawValue: raw) ?? .notStarted
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
willChangeValue(forKey: "status")
|
|
||||||
setPrimitiveValue(newValue.rawValue, forKey: "status")
|
|
||||||
didChangeValue(forKey: "status")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var label: String {
|
|
||||||
if status == .completed, let endDate = end {
|
|
||||||
if start.isSameDay(as: endDate) {
|
|
||||||
return "\(start.formattedDate())—\(endDate.formattedTime())"
|
|
||||||
} else {
|
|
||||||
return "\(start.formattedDate())—\(endDate.formattedDate())"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return start.formattedDate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var statusName: String {
|
|
||||||
return status.displayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience Accessors
|
|
||||||
|
|
||||||
extension Workout {
|
|
||||||
var logsArray: [WorkoutLog] {
|
|
||||||
let set = logs as? Set<WorkoutLog> ?? []
|
|
||||||
return set.sorted { $0.order < $1.order }
|
|
||||||
}
|
|
||||||
|
|
||||||
func addToLogs(_ log: WorkoutLog) {
|
|
||||||
let items = mutableSetValue(forKey: "logs")
|
|
||||||
items.add(log)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeFromLogs(_ log: WorkoutLog) {
|
|
||||||
let items = mutableSetValue(forKey: "logs")
|
|
||||||
items.remove(log)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fetch Request
|
|
||||||
|
|
||||||
extension Workout {
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Workout> {
|
|
||||||
return NSFetchRequest<Workout>(entityName: "Workout")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func recentFetchRequest() -> NSFetchRequest<Workout> {
|
|
||||||
let request = fetchRequest()
|
|
||||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
static func fetchRequest(for split: Split) -> NSFetchRequest<Workout> {
|
|
||||||
let request = fetchRequest()
|
|
||||||
request.predicate = NSPredicate(format: "split == %@", split)
|
|
||||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \Workout.start, ascending: false)]
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CoreData
|
|
||||||
|
|
||||||
@objc(WorkoutLog)
|
|
||||||
public class WorkoutLog: NSManagedObject, Identifiable {
|
|
||||||
@NSManaged public var date: Date
|
|
||||||
@NSManaged public var sets: Int32
|
|
||||||
@NSManaged public var reps: Int32
|
|
||||||
@NSManaged public var weight: Int32
|
|
||||||
@NSManaged public var order: Int32
|
|
||||||
@NSManaged public var exerciseName: String
|
|
||||||
@NSManaged public var currentStateIndex: Int32
|
|
||||||
@NSManaged public var elapsedSeconds: Int32
|
|
||||||
@NSManaged public var completed: Bool
|
|
||||||
@NSManaged public var loadType: Int32
|
|
||||||
@NSManaged public var duration: Date?
|
|
||||||
@NSManaged public var notes: String?
|
|
||||||
|
|
||||||
@NSManaged public var workout: Workout?
|
|
||||||
|
|
||||||
public var id: NSManagedObjectID { objectID }
|
|
||||||
|
|
||||||
var status: WorkoutStatus {
|
|
||||||
get {
|
|
||||||
willAccessValue(forKey: "status")
|
|
||||||
let raw = primitiveValue(forKey: "status") as? String ?? "notStarted"
|
|
||||||
didAccessValue(forKey: "status")
|
|
||||||
return WorkoutStatus(rawValue: raw) ?? .notStarted
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
willChangeValue(forKey: "status")
|
|
||||||
setPrimitiveValue(newValue.rawValue, forKey: "status")
|
|
||||||
didChangeValue(forKey: "status")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var loadTypeEnum: LoadType {
|
|
||||||
get { LoadType(rawValue: Int(loadType)) ?? .weight }
|
|
||||||
set { loadType = Int32(newValue.rawValue) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duration helpers for minutes/seconds conversion
|
|
||||||
var durationMinutes: Int {
|
|
||||||
get {
|
|
||||||
guard let duration = duration else { return 0 }
|
|
||||||
return Int(duration.timeIntervalSince1970) / 60
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
let seconds = durationSeconds
|
|
||||||
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var durationSeconds: Int {
|
|
||||||
get {
|
|
||||||
guard let duration = duration else { return 0 }
|
|
||||||
return Int(duration.timeIntervalSince1970) % 60
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
let minutes = durationMinutes
|
|
||||||
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fetch Request
|
|
||||||
|
|
||||||
extension WorkoutLog {
|
|
||||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<WorkoutLog> {
|
|
||||||
return NSFetchRequest<WorkoutLog>(entityName: "WorkoutLog")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func orderedFetchRequest(for workout: Workout) -> NSFetchRequest<WorkoutLog> {
|
|
||||||
let request = fetchRequest()
|
|
||||||
request.predicate = NSPredicate(format: "workout == %@", workout)
|
|
||||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \WorkoutLog.order, ascending: true)]
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum WorkoutStatus: String, CaseIterable, Codable {
|
|
||||||
case notStarted = "notStarted"
|
|
||||||
case inProgress = "inProgress"
|
|
||||||
case completed = "completed"
|
|
||||||
case skipped = "skipped"
|
|
||||||
|
|
||||||
var displayName: String {
|
|
||||||
switch self {
|
|
||||||
case .notStarted:
|
|
||||||
return "Not Started"
|
|
||||||
case .inProgress:
|
|
||||||
return "In Progress"
|
|
||||||
case .completed:
|
|
||||||
return "Completed"
|
|
||||||
case .skipped:
|
|
||||||
return "Skipped"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var name: String { displayName }
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import CoreData
|
|
||||||
import CloudKit
|
|
||||||
|
|
||||||
struct PersistenceController {
|
|
||||||
static let shared = PersistenceController()
|
|
||||||
|
|
||||||
let container: NSPersistentCloudKitContainer
|
|
||||||
|
|
||||||
// CloudKit container identifier
|
|
||||||
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,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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
|
||||||
<string>development</string>
|
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>iCloud.dev.rzen.indie.Workouts</string>
|
<string>iCloud.dev.rzen.indie.Workouts</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.developer.icloud-services</key>
|
<key>com.apple.developer.icloud-services</key>
|
||||||
<array>
|
<array>
|
||||||
<string>CloudKit</string>
|
<string>CloudDocuments</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
<key>com.apple.developer.ubiquity-container-identifiers</key>
|
||||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
<array>
|
||||||
<string>group.dev.rzen.indie.Workouts</string>
|
<string>iCloud.dev.rzen.indie.Workouts</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension Color {
|
|
||||||
static func color(from name: String) -> Color {
|
|
||||||
switch name.lowercased() {
|
|
||||||
case "red": return .red
|
|
||||||
case "orange": return .orange
|
|
||||||
case "yellow": return .yellow
|
|
||||||
case "green": return .green
|
|
||||||
case "mint": return .mint
|
|
||||||
case "teal": return .teal
|
|
||||||
case "cyan": return .cyan
|
|
||||||
case "blue": return .blue
|
|
||||||
case "indigo": return .indigo
|
|
||||||
case "purple": return .purple
|
|
||||||
case "pink": return .pink
|
|
||||||
case "brown": return .brown
|
|
||||||
default: return .indigo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func darker(by percentage: CGFloat = 0.2) -> Color {
|
|
||||||
return self.opacity(1.0 - percentage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Available colors for splits
|
|
||||||
let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
|
|
||||||
|
|
||||||
// Available system images for splits
|
|
||||||
let availableIcons = ["dumbbell.fill", "figure.strengthtraining.traditional", "figure.run", "figure.hiking", "figure.cooldown", "figure.boxing", "figure.wrestling", "figure.gymnastics", "figure.handball", "figure.core.training", "heart.fill", "bolt.fill"]
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension Date {
|
|
||||||
func formattedDate() -> String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateStyle = .short
|
|
||||||
formatter.timeStyle = .short
|
|
||||||
return formatter.string(from: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formattedTime() -> String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateStyle = .none
|
|
||||||
formatter.timeStyle = .short
|
|
||||||
return formatter.string(from: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isSameDay(as other: Date) -> Bool {
|
|
||||||
Calendar.current.isDate(self, inSameDayAs: other)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDate() -> String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateStyle = .medium
|
|
||||||
formatter.timeStyle = .none
|
|
||||||
return formatter.string(from: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
var abbreviatedMonth: String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "MMM"
|
|
||||||
return formatter.string(from: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
var dayOfMonth: Int {
|
|
||||||
Calendar.current.component(.day, from: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
var abbreviatedWeekday: String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "EEE"
|
|
||||||
return formatter.string(from: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func humanTimeInterval(to other: Date) -> String {
|
|
||||||
let interval = other.timeIntervalSince(self)
|
|
||||||
let hours = Int(interval) / 3600
|
|
||||||
let minutes = (Int(interval) % 3600) / 60
|
|
||||||
|
|
||||||
if hours > 0 {
|
|
||||||
return "\(hours)h \(minutes)m"
|
|
||||||
} else {
|
|
||||||
return "\(minutes)m"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,32 +8,55 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
struct ExerciseAddEditView: View {
|
struct ExerciseAddEditView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(SyncEngine.self) private var sync
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var showingExercisePicker = false
|
@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
|
// Local editable state
|
||||||
@State private var loadType: LoadType = .none
|
@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
|
init(exercise: Exercise, split: Split) {
|
||||||
@State private var seconds = 0
|
self.exercise = exercise
|
||||||
|
self.split = split
|
||||||
|
|
||||||
@State private var weight_tens = 0
|
let w = exercise.weight
|
||||||
@State private var weight = 0
|
_exerciseName = State(initialValue: exercise.name)
|
||||||
|
_originalWeight = State(initialValue: w)
|
||||||
@State private var reps: Int = 0
|
_loadType = State(initialValue: exercise.loadTypeEnum)
|
||||||
@State private var sets: Int = 0
|
_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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
Section(header: Text("Exercise")) {
|
Section(header: Text("Exercise")) {
|
||||||
if exercise.name.isEmpty {
|
if exerciseName.isEmpty {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingExercisePicker = true
|
showingExercisePicker = true
|
||||||
}) {
|
}) {
|
||||||
@@ -45,7 +68,7 @@ struct ExerciseAddEditView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
Picker("", selection: $loadType) {
|
||||||
ForEach(LoadType.allCases, id: \.self) { load in
|
ForEach(LoadType.allCases, id: \.self) { load in
|
||||||
Text(load.name)
|
Text(load.name)
|
||||||
@@ -87,7 +113,7 @@ struct ExerciseAddEditView: View {
|
|||||||
if loadType == .weight {
|
if loadType == .weight {
|
||||||
Section(header: Text("Weight")) {
|
Section(header: Text("Weight")) {
|
||||||
HStack {
|
HStack {
|
||||||
Picker("", selection: $weight_tens) {
|
Picker("", selection: $weightTens) {
|
||||||
ForEach(0..<100) { lbs in
|
ForEach(0..<100) { lbs in
|
||||||
Text("\(lbs * 10)").tag(lbs * 10)
|
Text("\(lbs * 10)").tag(lbs * 10)
|
||||||
}
|
}
|
||||||
@@ -95,7 +121,7 @@ struct ExerciseAddEditView: View {
|
|||||||
.frame(height: 100)
|
.frame(height: 100)
|
||||||
.pickerStyle(.wheel)
|
.pickerStyle(.wheel)
|
||||||
|
|
||||||
Picker("", selection: $weight) {
|
Picker("", selection: $weightOnes) {
|
||||||
ForEach(0..<10) { lbs in
|
ForEach(0..<10) { lbs in
|
||||||
Text("\(lbs)").tag(lbs)
|
Text("\(lbs)").tag(lbs)
|
||||||
}
|
}
|
||||||
@@ -130,36 +156,21 @@ struct ExerciseAddEditView: View {
|
|||||||
|
|
||||||
Section(header: Text("Weight Increase")) {
|
Section(header: Text("Weight Increase")) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Remind every \(exercise.weightReminderTimeIntervalWeeks) weeks")
|
Text("Remind every \(weightReminderWeeks) weeks")
|
||||||
Spacer()
|
Spacer()
|
||||||
Stepper("", value: Binding(
|
Stepper("", value: $weightReminderWeeks, in: 0...366)
|
||||||
get: { Int(exercise.weightReminderTimeIntervalWeeks) },
|
|
||||||
set: { exercise.weightReminderTimeIntervalWeeks = Int32($0) }
|
|
||||||
), in: 0...366)
|
|
||||||
}
|
}
|
||||||
if let lastUpdated = exercise.weightLastUpdated {
|
if let lastUpdated = weightLastUpdated {
|
||||||
Text("Last weight change \(Date().humanTimeInterval(to: lastUpdated)) ago")
|
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) {
|
.sheet(isPresented: $showingExercisePicker) {
|
||||||
ExercisePickerView { exerciseNames in
|
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 {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
@@ -169,19 +180,31 @@ struct ExerciseAddEditView: View {
|
|||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
if let originalWeight = originalWeight, originalWeight != Int32(weight_tens + weight) {
|
saveExercise()
|
||||||
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()
|
|
||||||
dismiss()
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,156 +8,196 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
struct ExerciseListView: View {
|
struct ExerciseListView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(SyncEngine.self) private var sync
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
@ObservedObject var split: Split
|
var split: Split
|
||||||
|
|
||||||
@State private var showingAddSheet: Bool = false
|
@State private var showingAddSheet: Bool = false
|
||||||
@State private var itemToEdit: Exercise? = nil
|
@State private var itemToEdit: Exercise? = nil
|
||||||
@State private var itemToDelete: 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 {
|
var body: some View {
|
||||||
NavigationStack {
|
Form {
|
||||||
Form {
|
let sortedExercises = split.exercisesArray
|
||||||
let sortedExercises = split.exercisesArray
|
|
||||||
|
|
||||||
if !sortedExercises.isEmpty {
|
if !sortedExercises.isEmpty {
|
||||||
ForEach(sortedExercises, id: \.objectID) { item in
|
ForEach(sortedExercises) { item in
|
||||||
ListItem(
|
ListItem(
|
||||||
title: item.name,
|
title: item.name,
|
||||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||||||
)
|
)
|
||||||
.swipeActions {
|
.swipeActions {
|
||||||
Button {
|
Button {
|
||||||
itemToDelete = item
|
itemToDelete = item
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
|
||||||
.tint(.red)
|
|
||||||
Button {
|
|
||||||
itemToEdit = item
|
|
||||||
} label: {
|
|
||||||
Label("Edit", systemImage: "pencil")
|
|
||||||
}
|
|
||||||
.tint(.indigo)
|
|
||||||
}
|
}
|
||||||
}
|
.tint(.red)
|
||||||
.onMove(perform: moveExercises)
|
Button {
|
||||||
|
itemToEdit = item
|
||||||
Button {
|
} label: {
|
||||||
showingAddSheet = true
|
Label("Edit", systemImage: "pencil")
|
||||||
} label: {
|
}
|
||||||
ListItem(title: "Add Exercise")
|
.tint(.indigo)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text("No exercises added yet.")
|
|
||||||
Button(action: { showingAddSheet.toggle() }) {
|
|
||||||
ListItem(title: "Add Exercise")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.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 {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button("Start This Split") {
|
Button("Start This Split") {
|
||||||
startWorkout()
|
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)
|
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) {
|
.sheet(isPresented: $showingAddSheet) {
|
||||||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||||
addExercises(names: exerciseNames)
|
addExercises(names: exerciseNames)
|
||||||
}, allowMultiSelect: true)
|
}, allowMultiSelect: true)
|
||||||
}
|
}
|
||||||
.sheet(item: $itemToEdit) { item in
|
.sheet(item: $itemToEdit) { item in
|
||||||
ExerciseAddEditView(exercise: item)
|
ExerciseAddEditView(exercise: item, split: split)
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Delete Exercise?",
|
"Delete Exercise?",
|
||||||
isPresented: .constant(itemToDelete != nil),
|
isPresented: Binding(
|
||||||
titleVisibility: .visible
|
get: { itemToDelete != nil },
|
||||||
) {
|
set: { if !$0 { itemToDelete = nil } }
|
||||||
|
),
|
||||||
|
titleVisibility: .visible,
|
||||||
|
presenting: itemToDelete
|
||||||
|
) { item in
|
||||||
Button("Delete", role: .destructive) {
|
Button("Delete", role: .destructive) {
|
||||||
if let item = itemToDelete {
|
deleteExercise(item)
|
||||||
withAnimation {
|
itemToDelete = nil
|
||||||
viewContext.delete(item)
|
|
||||||
try? viewContext.save()
|
|
||||||
itemToDelete = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {
|
Button("Cancel", role: .cancel) {
|
||||||
itemToDelete = nil
|
itemToDelete = nil
|
||||||
}
|
}
|
||||||
|
} message: { item in
|
||||||
|
Text("Remove \"\(item.name)\" from this split?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func pollForWorkout(id: String) {
|
||||||
|
Task {
|
||||||
|
// Give the file→observer→cache 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) {
|
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||||||
var exercises = split.exercisesArray
|
var exercises = split.exercisesArray
|
||||||
exercises.move(fromOffsets: source, toOffset: destination)
|
exercises.move(fromOffsets: source, toOffset: destination)
|
||||||
for (index, exercise) in exercises.enumerated() {
|
var doc = SplitDocument(from: split)
|
||||||
exercise.order = Int32(index)
|
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() {
|
private func startWorkout() {
|
||||||
let workout = Workout(context: viewContext)
|
let start = Date()
|
||||||
workout.start = Date()
|
let logs = split.exercisesArray.enumerated().map { i, ex in
|
||||||
workout.end = Date()
|
WorkoutLogDocument(
|
||||||
workout.status = .notStarted
|
id: ULID.make(), exerciseName: ex.name, order: i,
|
||||||
workout.split = split
|
sets: ex.sets, reps: ex.reps, weight: ex.weight,
|
||||||
|
loadType: ex.loadType, durationSeconds: ex.durationTotalSeconds,
|
||||||
for exercise in split.exercisesArray {
|
currentStateIndex: 0, completed: false,
|
||||||
let workoutLog = WorkoutLog(context: viewContext)
|
status: WorkoutStatus.notStarted.rawValue,
|
||||||
workoutLog.exerciseName = exercise.name
|
notes: nil, date: start
|
||||||
workoutLog.date = Date()
|
)
|
||||||
workoutLog.order = exercise.order
|
}
|
||||||
workoutLog.sets = exercise.sets
|
let doc = WorkoutDocument(
|
||||||
workoutLog.reps = exercise.reps
|
schemaVersion: WorkoutDocument.currentSchema,
|
||||||
workoutLog.weight = exercise.weight
|
id: ULID.make(),
|
||||||
workoutLog.status = .notStarted
|
splitID: split.id,
|
||||||
workoutLog.workout = workout
|
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]) {
|
private func addExercises(names: [String]) {
|
||||||
if names.count == 1 {
|
var doc = SplitDocument(from: split)
|
||||||
let exercise = Exercise(context: viewContext)
|
let existingNames = Set(doc.exercises.map { $0.name })
|
||||||
exercise.name = names.first ?? "Unnamed"
|
let base = doc.exercises.count
|
||||||
exercise.order = Int32(split.exercisesArray.count)
|
let newDocs = names
|
||||||
exercise.sets = 3
|
.filter { !existingNames.contains($0) }
|
||||||
exercise.reps = 10
|
.enumerated()
|
||||||
exercise.weight = 40
|
.map { i, exName in
|
||||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
ExerciseDocument(
|
||||||
exercise.split = split
|
id: ULID.make(), name: exName, order: base + i,
|
||||||
try? viewContext.save()
|
sets: 3, reps: 10, weight: 40,
|
||||||
itemToEdit = exercise
|
loadType: LoadType.weight.rawValue,
|
||||||
} else {
|
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
|
||||||
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
|
|
||||||
}
|
}
|
||||||
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 {
|
.onAppear {
|
||||||
loadExerciseLists()
|
exerciseLists = ExerciseListLoader.loadExerciseLists()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadExerciseLists() {
|
|
||||||
exerciseLists = ExerciseListLoader.loadExerciseLists()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,20 +6,14 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
import IndieAbout
|
import IndieAbout
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(SyncEngine.self) private var sync
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
@FetchRequest(
|
@Query(sort: \Split.order) private var splits: [Split]
|
||||||
sortDescriptors: [
|
|
||||||
NSSortDescriptor(keyPath: \Split.order, ascending: true),
|
|
||||||
NSSortDescriptor(keyPath: \Split.name, ascending: true)
|
|
||||||
],
|
|
||||||
animation: .default
|
|
||||||
)
|
|
||||||
private var splits: FetchedResults<Split>
|
|
||||||
|
|
||||||
@State private var showingAddSplitSheet = false
|
@State private var showingAddSplitSheet = false
|
||||||
|
|
||||||
@@ -47,7 +41,7 @@ struct SettingsView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ForEach(splits, id: \.objectID) { split in
|
ForEach(splits) { split in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
SplitDetailView(split: split)
|
SplitDetailView(split: split)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -73,12 +67,16 @@ struct SettingsView: View {
|
|||||||
Text("Add Split")
|
Text("Add Split")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Account Section
|
Button {
|
||||||
Section(header: Text("Account")) {
|
Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) }
|
||||||
Text("Settings coming soon")
|
} label: {
|
||||||
.foregroundColor(.secondary)
|
HStack {
|
||||||
|
Image(systemName: "wand.and.sparkles")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
Text("Add Starter Splits")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - About Section
|
// MARK: - About Section
|
||||||
@@ -99,8 +97,3 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
|
||||||
SettingsView()
|
|
||||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,25 +6,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
//
|
//
|
||||||
|
// No longer used — reordering is now a SyncEngine document write (onMove → doc save).
|
||||||
import Foundation
|
// File kept to avoid Xcode project reference errors.
|
||||||
|
|
||||||
/// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,92 +6,5 @@
|
|||||||
//
|
//
|
||||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
//
|
//
|
||||||
|
// No longer used — list reordering is handled with SwiftUI's native onMove modifier
|
||||||
import SwiftUI
|
// backed by SyncEngine document writes. File kept to avoid Xcode project reference errors.
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
struct SplitAddEditView: View {
|
struct SplitAddEditView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(SyncEngine.self) private var sync
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
let split: Split?
|
let split: Split?
|
||||||
@@ -23,8 +24,6 @@ struct SplitAddEditView: View {
|
|||||||
@State private var showingIconPicker: Bool = false
|
@State private var showingIconPicker: Bool = false
|
||||||
@State private var showingDeleteConfirmation: 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 }
|
var isEditing: Bool { split != nil }
|
||||||
|
|
||||||
init(split: Split?, onDelete: (() -> Void)? = nil) {
|
init(split: Split?, onDelete: (() -> Void)? = nil) {
|
||||||
@@ -117,8 +116,9 @@ struct SplitAddEditView: View {
|
|||||||
) {
|
) {
|
||||||
Button("Delete", role: .destructive) {
|
Button("Delete", role: .destructive) {
|
||||||
if let split = split {
|
if let split = split {
|
||||||
viewContext.delete(split)
|
Task {
|
||||||
try? viewContext.save()
|
await sync.delete(split: split)
|
||||||
|
}
|
||||||
dismiss()
|
dismiss()
|
||||||
onDelete?()
|
onDelete?()
|
||||||
}
|
}
|
||||||
@@ -131,18 +131,28 @@ struct SplitAddEditView: View {
|
|||||||
|
|
||||||
private func save() {
|
private func save() {
|
||||||
if let split = split {
|
if let split = split {
|
||||||
// Update existing
|
// Update existing split
|
||||||
split.name = name
|
var doc = SplitDocument(from: split)
|
||||||
split.color = color
|
doc.name = name
|
||||||
split.systemImage = systemImage
|
doc.color = color
|
||||||
|
doc.systemImage = systemImage
|
||||||
|
doc.updatedAt = Date()
|
||||||
|
Task { await sync.save(split: doc) }
|
||||||
} else {
|
} else {
|
||||||
// Create new
|
// Create new split
|
||||||
let newSplit = Split(context: viewContext)
|
let existing = (try? modelContext.fetch(FetchDescriptor<Split>())) ?? []
|
||||||
newSplit.name = name
|
let doc = SplitDocument(
|
||||||
newSplit.color = color
|
schemaVersion: SplitDocument.currentSchema,
|
||||||
newSplit.systemImage = systemImage
|
id: ULID.make(),
|
||||||
newSplit.order = 0
|
name: name,
|
||||||
|
color: color,
|
||||||
|
systemImage: systemImage,
|
||||||
|
order: existing.count,
|
||||||
|
createdAt: Date(),
|
||||||
|
updatedAt: Date(),
|
||||||
|
exercises: []
|
||||||
|
)
|
||||||
|
Task { await sync.save(split: doc) }
|
||||||
}
|
}
|
||||||
try? viewContext.save()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
struct SplitDetailView: View {
|
struct SplitDetailView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(SyncEngine.self) private var sync
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@ObservedObject var split: Split
|
var split: Split
|
||||||
|
|
||||||
@State private var showingExerciseAddSheet: Bool = false
|
@State private var showingExerciseAddSheet: Bool = false
|
||||||
@State private var showingSplitEditSheet: Bool = false
|
@State private var showingSplitEditSheet: Bool = false
|
||||||
@@ -22,54 +22,52 @@ struct SplitDetailView: View {
|
|||||||
@State private var itemToDelete: Exercise? = nil
|
@State private var itemToDelete: Exercise? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
Form {
|
||||||
Form {
|
Section(header: Text("What is a Split?")) {
|
||||||
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.")
|
||||||
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)
|
||||||
.font(.caption)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text("Exercises")) {
|
Section(header: Text("Exercises")) {
|
||||||
let sortedExercises = split.exercisesArray
|
let sortedExercises = split.exercisesArray
|
||||||
|
|
||||||
if !sortedExercises.isEmpty {
|
if !sortedExercises.isEmpty {
|
||||||
ForEach(sortedExercises, id: \.objectID) { item in
|
ForEach(sortedExercises) { item in
|
||||||
ListItem(
|
ListItem(
|
||||||
title: item.name,
|
title: item.name,
|
||||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||||||
)
|
)
|
||||||
.swipeActions {
|
.swipeActions {
|
||||||
Button {
|
Button {
|
||||||
itemToDelete = item
|
itemToDelete = item
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
|
||||||
.tint(.red)
|
|
||||||
Button {
|
|
||||||
itemToEdit = item
|
|
||||||
} label: {
|
|
||||||
Label("Edit", systemImage: "pencil")
|
|
||||||
}
|
|
||||||
.tint(.indigo)
|
|
||||||
}
|
}
|
||||||
|
.tint(.red)
|
||||||
|
Button {
|
||||||
|
itemToEdit = item
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
.tint(.indigo)
|
||||||
}
|
}
|
||||||
.onMove(perform: moveExercises)
|
}
|
||||||
|
.onMove(perform: moveExercises)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showingExerciseAddSheet = true
|
showingExerciseAddSheet = true
|
||||||
} label: {
|
} label: {
|
||||||
ListItem(systemName: "plus.circle", title: "Add Exercise")
|
ListItem(systemName: "plus.circle", title: "Add Exercise")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("No exercises added yet.")
|
Text("No exercises added yet.")
|
||||||
Button(action: { showingExerciseAddSheet.toggle() }) {
|
Button(action: { showingExerciseAddSheet.toggle() }) {
|
||||||
ListItem(title: "Add Exercise")
|
ListItem(title: "Add Exercise")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("\(split.name)")
|
|
||||||
}
|
}
|
||||||
|
.navigationTitle(split.name)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button {
|
Button {
|
||||||
@@ -90,62 +88,73 @@ struct SplitDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $itemToEdit) { item in
|
.sheet(item: $itemToEdit) { item in
|
||||||
ExerciseAddEditView(exercise: item)
|
ExerciseAddEditView(exercise: item, split: split)
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Delete Exercise?",
|
"Delete Exercise?",
|
||||||
isPresented: .constant(itemToDelete != nil),
|
isPresented: Binding(
|
||||||
titleVisibility: .visible
|
get: { itemToDelete != nil },
|
||||||
) {
|
set: { if !$0 { itemToDelete = nil } }
|
||||||
|
),
|
||||||
|
titleVisibility: .visible,
|
||||||
|
presenting: itemToDelete
|
||||||
|
) { item in
|
||||||
Button("Delete", role: .destructive) {
|
Button("Delete", role: .destructive) {
|
||||||
if let item = itemToDelete {
|
deleteExercise(item)
|
||||||
withAnimation {
|
itemToDelete = nil
|
||||||
viewContext.delete(item)
|
|
||||||
try? viewContext.save()
|
|
||||||
itemToDelete = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {
|
Button("Cancel", role: .cancel) {
|
||||||
itemToDelete = nil
|
itemToDelete = nil
|
||||||
}
|
}
|
||||||
|
} message: { item in
|
||||||
|
Text("Remove \"\(item.name)\" from this split?")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||||||
var exercises = split.exercisesArray
|
var exercises = split.exercisesArray
|
||||||
exercises.move(fromOffsets: source, toOffset: destination)
|
exercises.move(fromOffsets: source, toOffset: destination)
|
||||||
for (index, exercise) in exercises.enumerated() {
|
var doc = SplitDocument(from: split)
|
||||||
exercise.order = Int32(index)
|
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]) {
|
private func addExercises(names: [String]) {
|
||||||
if names.count == 1 {
|
var doc = SplitDocument(from: split)
|
||||||
let exercise = Exercise(context: viewContext)
|
let existingNames = Set(doc.exercises.map { $0.name })
|
||||||
exercise.name = names.first ?? "Unnamed"
|
let base = doc.exercises.count
|
||||||
exercise.order = Int32(split.exercisesArray.count)
|
let newDocs = names
|
||||||
exercise.sets = 3
|
.filter { !existingNames.contains($0) }
|
||||||
exercise.reps = 10
|
.enumerated()
|
||||||
exercise.weight = 40
|
.map { i, exName in
|
||||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
ExerciseDocument(
|
||||||
exercise.split = split
|
id: ULID.make(), name: exName, order: base + i,
|
||||||
try? viewContext.save()
|
sets: 3, reps: 10, weight: 40,
|
||||||
itemToEdit = exercise
|
loadType: LoadType.weight.rawValue,
|
||||||
} else {
|
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
|
||||||
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
|
|
||||||
}
|
}
|
||||||
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SplitItem: View {
|
struct SplitItem: View {
|
||||||
@ObservedObject var split: Split
|
var split: Split
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
|
|||||||
@@ -8,64 +8,44 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
struct SplitListView: View {
|
struct SplitListView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(SyncEngine.self) private var sync
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
@FetchRequest(
|
@Query(sort: \Split.order) private var splits: [Split]
|
||||||
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
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
||||||
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
|
ForEach(splits) { split in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
SplitDetailView(split: split)
|
SplitDetailView(split: split)
|
||||||
} label: {
|
} label: {
|
||||||
SplitItem(split: split)
|
SplitItem(split: split)
|
||||||
.overlay(dragging ? Color.white.opacity(0.8) : Color.clear)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
if fetchedSplits.isEmpty {
|
if splits.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No Splits Yet",
|
label: {
|
||||||
systemImage: "dumbbell.fill",
|
Label("No Splits Yet", systemImage: "dumbbell.fill")
|
||||||
description: Text("Create a split to organize your workout routine.")
|
},
|
||||||
|
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,11 +8,8 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
|
||||||
|
|
||||||
struct SplitsView: View {
|
struct SplitsView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
|
||||||
|
|
||||||
@State private var showingAddSheet: Bool = false
|
@State private var showingAddSheet: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -32,8 +29,3 @@ struct SplitsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
|
||||||
SplitsView()
|
|
||||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,15 +8,20 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
import Charts
|
import Charts
|
||||||
|
|
||||||
struct ExerciseView: View {
|
struct ExerciseView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(SyncEngine.self) private var sync
|
||||||
@Environment(\.dismiss) private var dismiss
|
@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 file→cache update.
|
||||||
|
@State private var doc: WorkoutDocument
|
||||||
@State private var progress: Int = 0
|
@State private var progress: Int = 0
|
||||||
@State private var showingPlanEdit = false
|
@State private var showingPlanEdit = false
|
||||||
@State private var showingNotesEdit = false
|
@State private var showingNotesEdit = false
|
||||||
@@ -24,12 +29,49 @@ struct ExerciseView: View {
|
|||||||
let notStartedColor = Color.white
|
let notStartedColor = Color.white
|
||||||
let completedColor = Color.green
|
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 file→cache 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 {
|
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 {
|
Form {
|
||||||
// MARK: - Progress Section
|
// MARK: - Progress Section
|
||||||
Section(header: Text("Progress")) {
|
Section(header: Text("Progress")) {
|
||||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: Int(workoutLog.sets)), spacing: 4) {
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: max(1, log.sets)), spacing: 4) {
|
||||||
ForEach(1...max(1, Int(workoutLog.sets)), id: \.self) { index in
|
ForEach(1...max(1, log.sets), id: \.self) { index in
|
||||||
ZStack {
|
ZStack {
|
||||||
let completed = index <= progress
|
let completed = index <= progress
|
||||||
let color = completed ? completedColor : notStartedColor
|
let color = completed ? completedColor : notStartedColor
|
||||||
@@ -50,21 +92,17 @@ struct ExerciseView: View {
|
|||||||
.colorInvert()
|
.colorInvert()
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
let totalSets = Int(workoutLog.sets)
|
let totalSets = log.sets
|
||||||
let isLastTile = index == totalSets
|
let isLastTile = index == totalSets
|
||||||
let wasAlreadyAtThisProgress = progress == index
|
let wasAlreadyAtThisProgress = progress == index
|
||||||
|
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
if wasAlreadyAtThisProgress {
|
progress = wasAlreadyAtThisProgress ? 0 : index
|
||||||
progress = 0
|
|
||||||
} else {
|
|
||||||
progress = index
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLogStatus()
|
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 {
|
if isLastTile && !wasAlreadyAtThisProgress {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
@@ -75,7 +113,7 @@ struct ExerciseView: View {
|
|||||||
|
|
||||||
// MARK: - Plan Section (Read-only with Edit button)
|
// MARK: - Plan Section (Read-only with Edit button)
|
||||||
Section {
|
Section {
|
||||||
PlanTilesView(workoutLog: workoutLog)
|
PlanTilesView(log: log)
|
||||||
} header: {
|
} header: {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Plan")
|
Text("Plan")
|
||||||
@@ -90,7 +128,7 @@ struct ExerciseView: View {
|
|||||||
|
|
||||||
// MARK: - Notes Section (Read-only with Edit button)
|
// MARK: - Notes Section (Read-only with Edit button)
|
||||||
Section {
|
Section {
|
||||||
if let notes = workoutLog.notes, !notes.isEmpty {
|
if let notes = log.notes, !notes.isEmpty {
|
||||||
Text(notes)
|
Text(notes)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
} else {
|
} else {
|
||||||
@@ -112,93 +150,111 @@ struct ExerciseView: View {
|
|||||||
|
|
||||||
// MARK: - Progress Tracking Chart
|
// MARK: - Progress Tracking Chart
|
||||||
Section(header: Text("Progress Tracking")) {
|
Section(header: Text("Progress Tracking")) {
|
||||||
WeightProgressionChartView(exerciseName: workoutLog.exerciseName)
|
WeightProgressionChartView(exerciseName: log.exerciseName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(workoutLog.exerciseName)
|
// Pull plan/notes edits made in the sheets back into the live doc.
|
||||||
.sheet(isPresented: $showingPlanEdit) {
|
.onChange(of: showingPlanEdit) { _, presenting in
|
||||||
PlanEditView(workoutLog: workoutLog)
|
if !presenting { refreshDocFromCache() }
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingNotesEdit) {
|
.onChange(of: showingNotesEdit) { _, presenting in
|
||||||
NotesEditView(workoutLog: workoutLog)
|
if !presenting { refreshDocFromCache() }
|
||||||
}
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Mutations
|
||||||
|
|
||||||
private func updateLogStatus() {
|
private func updateLogStatus() {
|
||||||
workoutLog.currentStateIndex = Int32(progress)
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
if progress >= Int(workoutLog.sets) {
|
doc.logs[i].currentStateIndex = progress
|
||||||
workoutLog.status = .completed
|
|
||||||
workoutLog.completed = true
|
if progress >= doc.logs[i].sets {
|
||||||
|
doc.logs[i].status = WorkoutStatus.completed.rawValue
|
||||||
|
doc.logs[i].completed = true
|
||||||
} else if progress > 0 {
|
} else if progress > 0 {
|
||||||
workoutLog.status = .inProgress
|
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
||||||
workoutLog.completed = false
|
doc.logs[i].completed = false
|
||||||
} else {
|
} else {
|
||||||
workoutLog.status = .notStarted
|
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
|
||||||
workoutLog.completed = false
|
doc.logs[i].completed = false
|
||||||
}
|
}
|
||||||
updateWorkoutStatus()
|
|
||||||
saveChanges()
|
recomputeWorkoutStatus()
|
||||||
|
doc.updatedAt = Date()
|
||||||
|
let snapshot = doc
|
||||||
|
Task { await sync.save(workout: snapshot) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateWorkoutStatus() {
|
/// Recompute the workout's status/end from its logs.
|
||||||
guard let workout = workoutLog.workout else { return }
|
private func recomputeWorkoutStatus() {
|
||||||
let logs = workout.logsArray
|
let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
|
||||||
let allCompleted = logs.allSatisfy { $0.status == .completed }
|
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
|
||||||
let anyInProgress = logs.contains { $0.status == .inProgress }
|
let anyInProgress = statuses.contains { $0 == .inProgress }
|
||||||
let allNotStarted = logs.allSatisfy { $0.status == .notStarted }
|
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
|
||||||
|
|
||||||
if allCompleted {
|
if allCompleted {
|
||||||
workout.status = .completed
|
doc.status = WorkoutStatus.completed.rawValue
|
||||||
workout.end = Date()
|
doc.end = Date()
|
||||||
} else if anyInProgress || !allNotStarted {
|
} else if anyInProgress || !allNotStarted {
|
||||||
workout.status = .inProgress
|
doc.status = WorkoutStatus.inProgress.rawValue
|
||||||
|
doc.end = nil
|
||||||
} else {
|
} else {
|
||||||
workout.status = .notStarted
|
doc.status = WorkoutStatus.notStarted.rawValue
|
||||||
|
doc.end = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveChanges() {
|
/// If the requested log isn't in the working doc yet (just-added race), pull a
|
||||||
try? viewContext.save()
|
/// fresh copy from the cache entity once it catches up.
|
||||||
WatchConnectivityManager.shared.syncAllData()
|
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
|
// MARK: - Plan Tiles View
|
||||||
|
|
||||||
struct PlanTilesView: View {
|
struct PlanTilesView: View {
|
||||||
@ObservedObject var workoutLog: WorkoutLog
|
let log: WorkoutLogDocument
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if workoutLog.loadTypeEnum == .duration {
|
if LoadType(rawValue: log.loadType) == .duration {
|
||||||
// Duration layout: Sets | Duration
|
// Duration layout: Sets | Duration
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
PlanTile(label: "Sets", value: "\(workoutLog.sets)")
|
PlanTile(label: "Sets", value: "\(log.sets)")
|
||||||
PlanTile(label: "Duration", value: formattedDuration)
|
PlanTile(label: "Duration", value: formattedDuration)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Weight layout: Sets | Reps | Weight
|
// Weight layout: Sets | Reps | Weight
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
PlanTile(label: "Sets", value: "\(workoutLog.sets)")
|
PlanTile(label: "Sets", value: "\(log.sets)")
|
||||||
PlanTile(label: "Reps", value: "\(workoutLog.reps)")
|
PlanTile(label: "Reps", value: "\(log.reps)")
|
||||||
PlanTile(label: "Weight", value: "\(workoutLog.weight) lbs")
|
PlanTile(label: "Weight", value: "\(log.weight) lbs")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var formattedDuration: String {
|
private var formattedDuration: String {
|
||||||
let mins = workoutLog.durationMinutes
|
let mins = log.durationSeconds / 60
|
||||||
let secs = workoutLog.durationSeconds
|
let secs = log.durationSeconds % 60
|
||||||
if mins > 0 && secs > 0 {
|
if mins > 0 && secs > 0 {
|
||||||
return "\(mins)m \(secs)s"
|
return "\(mins)m \(secs)s"
|
||||||
} else if mins > 0 {
|
} else if mins > 0 {
|
||||||
|
|||||||
@@ -6,13 +6,14 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
struct NotesEditView: View {
|
struct NotesEditView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(SyncEngine.self) private var sync
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@ObservedObject var workoutLog: WorkoutLog
|
let workout: Workout
|
||||||
|
let logID: String
|
||||||
|
|
||||||
@State private var notesText: String = ""
|
@State private var notesText: String = ""
|
||||||
|
|
||||||
@@ -40,14 +41,18 @@ struct NotesEditView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
notesText = workoutLog.notes ?? ""
|
notesText = WorkoutDocument(from: workout)
|
||||||
|
.logs.first(where: { $0.id == logID })?.notes ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveChanges() {
|
private func saveChanges() {
|
||||||
workoutLog.notes = notesText
|
var doc = WorkoutDocument(from: workout)
|
||||||
try? viewContext.save()
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
WatchConnectivityManager.shared.syncAllData()
|
doc.logs[i].notes = notesText
|
||||||
|
doc.updatedAt = Date()
|
||||||
|
let snapshot = doc
|
||||||
|
Task { await sync.save(workout: snapshot) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,15 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
struct PlanEditView: View {
|
struct PlanEditView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(SyncEngine.self) private var sync
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@ObservedObject var workoutLog: WorkoutLog
|
let workout: Workout
|
||||||
|
let logID: String
|
||||||
|
|
||||||
@State private var sets: Int = 3
|
@State private var sets: Int = 3
|
||||||
@State private var reps: Int = 12
|
@State private var reps: Int = 12
|
||||||
@@ -21,11 +23,6 @@ struct PlanEditView: View {
|
|||||||
@State private var durationSeconds: Int = 0
|
@State private var durationSeconds: Int = 0
|
||||||
@State private var selectedLoadType: LoadType = .weight
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
@@ -135,34 +132,54 @@ struct PlanEditView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
sets = Int(workoutLog.sets)
|
if let log = WorkoutDocument(from: workout).logs.first(where: { $0.id == logID }) {
|
||||||
reps = Int(workoutLog.reps)
|
sets = log.sets
|
||||||
weight = Int(workoutLog.weight)
|
reps = log.reps
|
||||||
durationMinutes = workoutLog.durationMinutes
|
weight = log.weight
|
||||||
durationSeconds = workoutLog.durationSeconds
|
durationMinutes = log.durationSeconds / 60
|
||||||
selectedLoadType = workoutLog.loadTypeEnum
|
durationSeconds = log.durationSeconds % 60
|
||||||
|
selectedLoadType = LoadType(rawValue: log.loadType) ?? .weight
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveChanges() {
|
private func saveChanges() {
|
||||||
workoutLog.sets = Int32(sets)
|
let totalSeconds = durationMinutes * 60 + durationSeconds
|
||||||
workoutLog.reps = Int32(reps)
|
|
||||||
workoutLog.weight = Int32(weight)
|
|
||||||
workoutLog.durationMinutes = durationMinutes
|
|
||||||
workoutLog.durationSeconds = durationSeconds
|
|
||||||
workoutLog.loadTypeEnum = selectedLoadType
|
|
||||||
|
|
||||||
// Sync to corresponding exercise
|
// 1) Update the log within the parent workout document.
|
||||||
if let exercise = correspondingExercise {
|
var doc = WorkoutDocument(from: workout)
|
||||||
exercise.sets = workoutLog.sets
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
exercise.reps = workoutLog.reps
|
doc.logs[i].sets = sets
|
||||||
exercise.weight = workoutLog.weight
|
doc.logs[i].reps = reps
|
||||||
exercise.loadType = workoutLog.loadType
|
doc.logs[i].weight = weight
|
||||||
exercise.duration = workoutLog.duration
|
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()
|
Task {
|
||||||
WatchConnectivityManager.shared.syncAllData()
|
await sync.save(workout: workoutDoc)
|
||||||
|
if let splitDoc {
|
||||||
|
await sync.save(split: splitDoc)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,21 +9,31 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Charts
|
import Charts
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
struct WeightProgressionChartView: View {
|
struct WeightProgressionChartView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
|
||||||
|
|
||||||
let exerciseName: String
|
let exerciseName: String
|
||||||
@State private var weightData: [WeightDataPoint] = []
|
|
||||||
@State private var isLoading: Bool = true
|
/// Completed logs for this exercise, oldest first.
|
||||||
@State private var motivationalMessage: String = ""
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if isLoading {
|
if weightData.isEmpty {
|
||||||
ProgressView("Loading data...")
|
|
||||||
} else if weightData.isEmpty {
|
|
||||||
Text("No weight history available yet.")
|
Text("No weight history available yet.")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
@@ -70,51 +80,33 @@ struct WeightProgressionChartView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.onAppear {
|
|
||||||
loadWeightData()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadWeightData() {
|
private var motivationalMessage: String {
|
||||||
isLoading = true
|
let data = weightData
|
||||||
|
guard data.count >= 2 else {
|
||||||
let request: NSFetchRequest<WorkoutLog> = WorkoutLog.fetchRequest()
|
return "Complete more workouts to track your progress!"
|
||||||
request.predicate = NSPredicate(format: "exerciseName == %@ AND completed == YES", exerciseName)
|
|
||||||
request.sortDescriptors = [NSSortDescriptor(keyPath: \WorkoutLog.date, ascending: true)]
|
|
||||||
|
|
||||||
if let logs = try? viewContext.fetch(request) {
|
|
||||||
weightData = logs.map { log in
|
|
||||||
WeightDataPoint(date: log.date, weight: Int(log.weight))
|
|
||||||
}
|
|
||||||
generateMotivationalMessage()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
let firstWeight = data.first?.weight ?? 0
|
||||||
}
|
let currentWeight = data.last?.weight ?? 0
|
||||||
|
|
||||||
private func generateMotivationalMessage() {
|
|
||||||
guard weightData.count >= 2 else {
|
|
||||||
motivationalMessage = "Complete more workouts to track your progress!"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let firstWeight = weightData.first?.weight ?? 0
|
|
||||||
let currentWeight = weightData.last?.weight ?? 0
|
|
||||||
let weightDifference = currentWeight - firstWeight
|
let weightDifference = currentWeight - firstWeight
|
||||||
|
|
||||||
if weightDifference > 0 {
|
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 {
|
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 {
|
} 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 {
|
} 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 {
|
} 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 {
|
} 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 SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
struct WorkoutLogListView: View {
|
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
|
||||||
|
/// file→observer→cache 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 showingAddSheet = false
|
||||||
@State private var itemToDelete: WorkoutLog? = nil
|
@State private var logToDelete: WorkoutLogDocument?
|
||||||
@State private var newlyAddedLog: WorkoutLog? = nil
|
@State private var addedLog: AddedLogRoute?
|
||||||
|
|
||||||
var sortedWorkoutLogs: [WorkoutLog] {
|
/// Wrapper so the programmatic push after adding an exercise uses a distinct
|
||||||
workout.logsArray
|
/// `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 {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if sortedWorkoutLogs.isEmpty {
|
if sortedLogs.isEmpty {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("No Exercises", systemImage: "figure.strengthtraining.traditional")
|
Label("No Exercises", systemImage: "figure.strengthtraining.traditional")
|
||||||
} description: {
|
} description: {
|
||||||
@@ -40,15 +63,11 @@ struct WorkoutLogListView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Form {
|
Form {
|
||||||
Section(header: Text("\(workout.label)")) {
|
Section(header: Text(label)) {
|
||||||
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
|
ForEach(sortedLogs) { log in
|
||||||
let workoutLogStatus = log.status.checkboxStatus
|
NavigationLink(value: log.id) {
|
||||||
|
|
||||||
NavigationLink {
|
|
||||||
ExerciseView(workoutLog: log)
|
|
||||||
} label: {
|
|
||||||
CheckboxListItem(
|
CheckboxListItem(
|
||||||
status: workoutLogStatus,
|
status: workoutStatus(log).checkboxStatus,
|
||||||
title: log.exerciseName,
|
title: log.exerciseName,
|
||||||
subtitle: subtitleForLog(log)
|
subtitle: subtitleForLog(log)
|
||||||
) {
|
) {
|
||||||
@@ -65,7 +84,7 @@ struct WorkoutLogListView: View {
|
|||||||
}
|
}
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
Button {
|
Button {
|
||||||
itemToDelete = log
|
logToDelete = log
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
}
|
||||||
@@ -77,130 +96,173 @@ struct WorkoutLogListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(item: $newlyAddedLog) { log in
|
.navigationDestination(for: String.self) { logID in
|
||||||
ExerciseView(workoutLog: log)
|
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 {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: { showingAddSheet.toggle() }) {
|
Button {
|
||||||
|
showingAddSheet.toggle()
|
||||||
|
} label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAddSheet) {
|
.sheet(isPresented: $showingAddSheet) {
|
||||||
SplitExercisePickerSheet(
|
SplitExercisePickerSheet(
|
||||||
split: workout.split,
|
split: split,
|
||||||
existingExerciseNames: Set(sortedWorkoutLogs.map { $0.exerciseName })
|
existingExerciseNames: Set(sortedLogs.map { $0.exerciseName })
|
||||||
) { exercise in
|
) { exercise in
|
||||||
addExerciseFromSplit(exercise)
|
addExerciseFromSplit(exercise)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Delete Exercise?",
|
"Delete Exercise?",
|
||||||
isPresented: Binding<Bool>(
|
isPresented: Binding(
|
||||||
get: { itemToDelete != nil },
|
get: { logToDelete != nil },
|
||||||
set: { if !$0 { itemToDelete = nil } }
|
set: { if !$0 { logToDelete = nil } }
|
||||||
),
|
),
|
||||||
titleVisibility: .visible
|
titleVisibility: .visible,
|
||||||
) {
|
presenting: logToDelete
|
||||||
|
) { log in
|
||||||
Button("Delete", role: .destructive) {
|
Button("Delete", role: .destructive) {
|
||||||
if let item = itemToDelete {
|
deleteLog(log)
|
||||||
withAnimation {
|
logToDelete = nil
|
||||||
viewContext.delete(item)
|
|
||||||
updateWorkoutStatus()
|
|
||||||
try? viewContext.save()
|
|
||||||
itemToDelete = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {
|
Button("Cancel", role: .cancel) {
|
||||||
itemToDelete = nil
|
logToDelete = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cycleStatus(for log: WorkoutLog) {
|
// MARK: - Derived
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func completeLog(_ log: WorkoutLog) {
|
private var label: String {
|
||||||
log.status = .completed
|
if doc.status == WorkoutStatus.completed.rawValue, let end = doc.end {
|
||||||
updateWorkoutStatus()
|
if doc.start.isSameDay(as: end) {
|
||||||
try? viewContext.save()
|
return "\(doc.start.formattedDate())—\(end.formattedTime())"
|
||||||
WatchConnectivityManager.shared.syncAllData()
|
} else {
|
||||||
}
|
return "\(doc.start.formattedDate())—\(end.formattedDate())"
|
||||||
|
}
|
||||||
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
|
|
||||||
} else {
|
} 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) {
|
private func moveLog(from source: IndexSet, to destination: Int) {
|
||||||
var logs = sortedWorkoutLogs
|
var logs = sortedLogs
|
||||||
logs.move(fromOffsets: source, toOffset: destination)
|
logs.move(fromOffsets: source, toOffset: destination)
|
||||||
for (index, log) in logs.enumerated() {
|
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()
|
save()
|
||||||
WatchConnectivityManager.shared.syncAllData()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addExerciseFromSplit(_ exercise: Exercise) {
|
private func addExerciseFromSplit(_ exercise: Exercise) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
|
|
||||||
// Update workout start time if this is the first exercise
|
// Reuse the workout's start time only when it's the very first exercise.
|
||||||
if sortedWorkoutLogs.isEmpty {
|
if doc.logs.isEmpty {
|
||||||
workout.start = now
|
doc.start = now
|
||||||
}
|
}
|
||||||
workout.end = nil
|
doc.end = nil
|
||||||
|
|
||||||
let log = WorkoutLog(context: viewContext)
|
let newLog = WorkoutLogDocument(
|
||||||
log.exerciseName = exercise.name
|
id: ULID.make(),
|
||||||
log.date = now
|
exerciseName: exercise.name,
|
||||||
log.order = Int32(sortedWorkoutLogs.count)
|
order: doc.logs.count,
|
||||||
log.sets = exercise.sets
|
sets: exercise.sets,
|
||||||
log.reps = exercise.reps
|
reps: exercise.reps,
|
||||||
log.weight = exercise.weight
|
weight: exercise.weight,
|
||||||
log.loadType = exercise.loadType
|
loadType: exercise.loadType,
|
||||||
log.duration = exercise.duration
|
durationSeconds: exercise.durationTotalSeconds,
|
||||||
log.status = .notStarted
|
currentStateIndex: 0,
|
||||||
log.workout = workout
|
completed: false,
|
||||||
|
status: WorkoutStatus.notStarted.rawValue,
|
||||||
|
notes: nil,
|
||||||
|
date: now
|
||||||
|
)
|
||||||
|
doc.logs.append(newLog)
|
||||||
|
save()
|
||||||
|
|
||||||
try? viewContext.save()
|
// Push the new exercise straight away.
|
||||||
WatchConnectivityManager.shared.syncAllData()
|
addedLog = AddedLogRoute(id: newLog.id)
|
||||||
|
|
||||||
// Navigate to the new exercise view
|
|
||||||
newlyAddedLog = log
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func subtitleForLog(_ log: WorkoutLog) -> String {
|
/// Recompute the workout's status/end from its logs, then persist.
|
||||||
if log.loadTypeEnum == .duration {
|
private func save() {
|
||||||
let mins = log.durationMinutes
|
let statuses = doc.logs.map { workoutStatus($0) }
|
||||||
let secs = log.durationSeconds
|
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 {
|
if mins > 0 && secs > 0 {
|
||||||
return "\(log.sets) × \(mins)m \(secs)s"
|
return "\(log.sets) × \(mins)m \(secs)s"
|
||||||
} else if mins > 0 {
|
} else if mins > 0 {
|
||||||
@@ -224,7 +286,7 @@ struct SplitExercisePickerSheet: View {
|
|||||||
let onExerciseSelected: (Exercise) -> Void
|
let onExerciseSelected: (Exercise) -> Void
|
||||||
|
|
||||||
private var availableExercises: [Exercise] {
|
private var availableExercises: [Exercise] {
|
||||||
guard let split = split else { return [] }
|
guard let split else { return [] }
|
||||||
return split.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
|
return split.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +295,7 @@ struct SplitExercisePickerSheet: View {
|
|||||||
Group {
|
Group {
|
||||||
if !availableExercises.isEmpty {
|
if !availableExercises.isEmpty {
|
||||||
List {
|
List {
|
||||||
ForEach(availableExercises, id: \.objectID) { exercise in
|
ForEach(availableExercises) { exercise in
|
||||||
Button {
|
Button {
|
||||||
onExerciseSelected(exercise)
|
onExerciseSelected(exercise)
|
||||||
dismiss()
|
dismiss()
|
||||||
|
|||||||
@@ -8,29 +8,29 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
struct WorkoutLogsView: View {
|
struct WorkoutLogsView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(SyncEngine.self) private var sync
|
||||||
|
|
||||||
@FetchRequest(
|
@Query(sort: \Workout.start, order: .reverse)
|
||||||
sortDescriptors: [NSSortDescriptor(keyPath: \Workout.start, ascending: false)],
|
private var workouts: [Workout]
|
||||||
animation: .default
|
|
||||||
)
|
|
||||||
private var workouts: FetchedResults<Workout>
|
|
||||||
|
|
||||||
@State private var showingSplitPicker = false
|
@State private var showingSplitPicker = false
|
||||||
@State private var showingSettings = 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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
ForEach(workouts, id: \.objectID) { workout in
|
ForEach(workouts) { workout in
|
||||||
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
|
NavigationLink {
|
||||||
|
WorkoutLogListView(workout: workout)
|
||||||
|
} label: {
|
||||||
CalendarListItem(
|
CalendarListItem(
|
||||||
date: workout.start,
|
date: workout.start,
|
||||||
title: workout.split?.name ?? Split.unnamed,
|
title: workout.splitName ?? Split.unnamed,
|
||||||
subtitle: getSubtitle(for: workout),
|
subtitle: getSubtitle(for: workout),
|
||||||
subtitle2: workout.statusName
|
subtitle2: workout.statusName
|
||||||
)
|
)
|
||||||
@@ -77,21 +77,16 @@ struct WorkoutLogsView: View {
|
|||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Delete Workout?",
|
"Delete Workout?",
|
||||||
isPresented: Binding<Bool>(
|
isPresented: Binding(
|
||||||
get: { itemToDelete != nil },
|
get: { itemToDelete != nil },
|
||||||
set: { if !$0 { itemToDelete = nil } }
|
set: { if !$0 { itemToDelete = nil } }
|
||||||
),
|
),
|
||||||
titleVisibility: .visible
|
titleVisibility: .visible,
|
||||||
) {
|
presenting: itemToDelete
|
||||||
|
) { workout in
|
||||||
Button("Delete", role: .destructive) {
|
Button("Delete", role: .destructive) {
|
||||||
if let item = itemToDelete {
|
Task { await sync.delete(workout: workout) }
|
||||||
withAnimation {
|
itemToDelete = nil
|
||||||
viewContext.delete(item)
|
|
||||||
try? viewContext.save()
|
|
||||||
WatchConnectivityManager.shared.syncAllData()
|
|
||||||
itemToDelete = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {
|
Button("Cancel", role: .cancel) {
|
||||||
itemToDelete = nil
|
itemToDelete = nil
|
||||||
@@ -112,22 +107,16 @@ struct WorkoutLogsView: View {
|
|||||||
// MARK: - Split Picker Sheet
|
// MARK: - Split Picker Sheet
|
||||||
|
|
||||||
struct SplitPickerSheet: View {
|
struct SplitPickerSheet: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(SyncEngine.self) private var sync
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@FetchRequest(
|
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
|
||||||
sortDescriptors: [
|
private var splits: [Split]
|
||||||
NSSortDescriptor(keyPath: \Split.order, ascending: true),
|
|
||||||
NSSortDescriptor(keyPath: \Split.name, ascending: true)
|
|
||||||
],
|
|
||||||
animation: .default
|
|
||||||
)
|
|
||||||
private var splits: FetchedResults<Split>
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
ForEach(splits, id: \.objectID) { split in
|
ForEach(splits) { split in
|
||||||
Button {
|
Button {
|
||||||
startWorkout(with: split)
|
startWorkout(with: split)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -155,35 +144,40 @@ struct SplitPickerSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startWorkout(with split: Split) {
|
private func startWorkout(with split: Split) {
|
||||||
let workout = Workout(context: viewContext)
|
let start = Date()
|
||||||
workout.start = Date()
|
let logs = split.exercisesArray.enumerated().map { index, exercise in
|
||||||
workout.status = .notStarted
|
WorkoutLogDocument(
|
||||||
workout.split = split
|
id: ULID.make(),
|
||||||
|
exerciseName: exercise.name,
|
||||||
for exercise in split.exercisesArray {
|
order: index,
|
||||||
let workoutLog = WorkoutLog(context: viewContext)
|
sets: exercise.sets,
|
||||||
workoutLog.exerciseName = exercise.name
|
reps: exercise.reps,
|
||||||
workoutLog.date = Date()
|
weight: exercise.weight,
|
||||||
workoutLog.order = exercise.order
|
loadType: exercise.loadType,
|
||||||
workoutLog.sets = exercise.sets
|
durationSeconds: exercise.durationTotalSeconds,
|
||||||
workoutLog.reps = exercise.reps
|
currentStateIndex: 0,
|
||||||
workoutLog.weight = exercise.weight
|
completed: false,
|
||||||
workoutLog.loadType = exercise.loadType
|
status: WorkoutStatus.notStarted.rawValue,
|
||||||
workoutLog.duration = exercise.duration
|
notes: nil,
|
||||||
workoutLog.status = .notStarted
|
date: start
|
||||||
workoutLog.workout = workout
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
try? viewContext.save()
|
// A freshly started workout has no `end` — only completion stamps it.
|
||||||
|
let doc = WorkoutDocument(
|
||||||
// Sync to Watch
|
schemaVersion: WorkoutDocument.currentSchema,
|
||||||
WatchConnectivityManager.shared.syncAllData()
|
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()
|
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
@@ -1,31 +1,24 @@
|
|||||||
//
|
//
|
||||||
// WorkoutsApp.swift
|
// WorkoutsApp.swift
|
||||||
// Workouts
|
// 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 SwiftUI
|
||||||
import CoreData
|
import SwiftData
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct WorkoutsApp: App {
|
struct WorkoutsApp: App {
|
||||||
let persistenceController = PersistenceController.shared
|
@State private var services = AppServices()
|
||||||
let connectivityManager = WatchConnectivityManager.shared
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Set up Watch connectivity with Core Data context
|
|
||||||
connectivityManager.setViewContext(persistenceController.viewContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
RootGateView()
|
||||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
.environment(services)
|
||||||
.environmentObject(connectivityManager)
|
.environment(services.syncEngine)
|
||||||
|
.modelContainer(services.container)
|
||||||
|
.task { await services.bootstrap() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+82
@@ -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\""
|
||||||
Reference in New Issue
Block a user