diff --git a/.fleet/design-review-ios.json b/.fleet/design-review-ios.json new file mode 100644 index 0000000..4b51e13 --- /dev/null +++ b/.fleet/design-review-ios.json @@ -0,0 +1,38 @@ +{ + "$schema": "fleet.design-review.v1", + "version": 1, + "project": "Setline native iOS", + "target": "ios/", + "mode": "preserve", + "register": "product", + "context": { + "product": "PRODUCT.md", + "design": "DESIGN.md" + }, + "direction": { + "references": ["Existing Setline identity", "Apple Fitness workout legibility", "Editorial sports scoreboards"], + "probes": [], + "selected": "Scoreboard Split", + "approval": "not-required", + "before": "artifacts/design/before.png" + }, + "evidence": { + "screenshots": [ + {"width": 390, "path": "ios/artifacts/simulator/today.png"}, + {"width": 390, "path": "ios/artifacts/simulator/workout-player.png"}, + {"width": 390, "path": "ios/artifacts/simulator/rest-timer.png"}, + {"width": 390, "path": "ios/artifacts/simulator/plan.png"}, + {"width": 390, "path": "ios/artifacts/simulator/history.png"}, + {"width": 390, "path": "ios/artifacts/simulator/accessibility-xl.png"} + ], + "projectCheck": {"command": "cd ios && ./scripts/check.sh", "status": "pass"}, + "critique": {"score": 36, "maximum": 40}, + "audit": {"score": 17, "maximum": 20}, + "unresolved": {"p0": 0, "p1": 0}, + "detector": {"posture": "advisory", "findings": []} + }, + "ownerFeedback": { + "decision": "delegated", + "note": "Owner explicitly required super beautiful UI across every application and delegated the native visual direction." + } +} diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index b898c94..f5c0801 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -21,6 +21,11 @@ social features, meal/recovery tracking, sensors, Apple Health, and Apple Watch. ## Timeline +- 2026-08-11 — prepared the first native SwiftUI iPhone beta with complete + local-first workout execution, planning, history, data transfer, + accessibility, simulator coverage, App Store metadata, and a personal-team + signed archive path; native account synchronization remains tracked + separately. - 2026-07-31 — replaced static progress examples with recorded-history analytics for exercises, workouts, and represented programme weeks, including bounded trends, honest legacy/empty boundaries, and explicit measurement @@ -72,6 +77,8 @@ social features, meal/recovery tracking, sensors, Apple Health, and Apple Watch. ## Products +- `ios/` — native SwiftUI iPhone beta for local-first workout execution; + App Store Connect/TestFlight transport remains manual. - Repository root — installable mobile-first Setline web app. - [Public GitHub repository](https://github.com/Significant-Hobbies/setline) — canonical source, product planning, and issue owner. @@ -82,6 +89,10 @@ social features, meal/recovery tracking, sensors, Apple Health, and Apple Watch. ## Features (shipped) +- Native iPhone workout player with authored-order snapshots, activity-specific + recording, drop segments, skips, session-only extras, deferrals, + timestamp-derived rest, relaunch recovery, planning, history, progression, + data transfer, accessibility, simulator tests, and personal-team archiving. - Public editorial product changelog at `/changelog`. - Dated seven-day schedule for the supplied 12-week strength, cardio, and mobility programme. diff --git a/ios/NATIVE_AUDIT.md b/ios/NATIVE_AUDIT.md new file mode 100644 index 0000000..bf506ce --- /dev/null +++ b/ios/NATIVE_AUDIT.md @@ -0,0 +1,33 @@ +# Setline Native Quality Audit + +Audit target: iPhone simulator build of the native SwiftUI application. + +## Score + +| Category | Score | Notes | +| --- | ---: | --- | +| Accessibility | 3/4 | Dynamic Type layouts, semantic controls, readable contrast, and non-color state cues are present. Final VoiceOver traversal should be repeated on physical hardware. | +| Performance | 4/4 | Local-first model, small view hierarchy, timestamp-derived timers, and no polling or blocking network work. | +| Appearance | 3/4 | Deliberate high-contrast Scoreboard Split identity with coherent hierarchy and native materials. The intentional light presentation does not yet expose an alternate dark palette. | +| Platform conventions | 4/4 | SwiftUI navigation, tabs, sheets, menus, confirmations, native text styles, and system feedback conventions. | +| Adaptivity | 3/4 | Compact iPhone and accessibility text sizes are stable without horizontal clipping. Dedicated iPad composition is outside this submission scope. | + +**Total: 17/20 — Good** + +## Positive findings + +- The workout player keeps the current set, actuals, deviations, and primary action legible at a glance. +- Timers derive from wall-clock timestamps, so backgrounding and relaunching do not create drift. +- The programme grid remains readable at compact widths and the tab bar stays reachable at accessibility sizes. +- Empty, destructive, transfer, and recovery paths use explicit native presentation rather than silent state changes. + +## Residual findings + +- P0: 0 +- P1: 0 +- P2: 2 — physical-device VoiceOver pass; optional dark appearance after initial release. +- P3: 0 + +## Evidence + +Screenshots are stored in `artifacts/simulator/`. Automated coverage and the Release simulator build are run by `./scripts/check.sh`; the personal-team archive is created by `./scripts/archive.sh` without upload. diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..0da9235 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,27 @@ +# Setline for iPhone + +Native SwiftUI workout execution for iOS 17 and later. The app uses Apple frameworks only and keeps active workouts usable offline. + +## Local checks + +```bash +./scripts/check.sh +``` + +The destination can be overridden with `SETLINE_SIMULATOR_DESTINATION`. + +## Personal-team archive + +```bash +SETLINE_ARCHIVE_PATH=/private/tmp/Setline.xcarchive ./scripts/archive.sh +``` + +The archive script is locked to personal team `8F7LXHTJZR`, verifies the app signature, and intentionally contains no upload step. + +## Device-only checks before submission + +- Exercise the full workout flow one-handed on the smallest supported physical iPhone. +- Background the app for longer than one rest interval and confirm wall-clock recovery. +- Verify VoiceOver order and the largest Dynamic Type categories on hardware. +- Confirm account callback, Keychain persistence, airplane-mode recovery, and notification behavior once account sync is enabled. +- Confirm the final App Store icon, screenshots, support URL, privacy URL, age rating, and privacy answers in App Store Connect before any upload. diff --git a/ios/RELEASE_METADATA.md b/ios/RELEASE_METADATA.md new file mode 100644 index 0000000..b9b6d75 --- /dev/null +++ b/ios/RELEASE_METADATA.md @@ -0,0 +1,70 @@ +# Setline iOS release draft + +This file is preparation only. No App Store Connect record has been created. + +## Identity + +- Name: Setline +- Bundle ID: `com.significanthobbies.setline` +- Version: `1.0.0` +- Build: `1` +- SKU: `setline-ios-1` +- Primary language: English (U.S.) +- Category: Health & Fitness +- Minimum iOS: 17.0 +- Copyright: `2026 Sarthak Agrawal` +- License: Apple's standard EULA +- Content rights: all bundled copy, artwork, and sample programme content is owned or licensed for distribution + +## Store copy + +**Subtitle** +Follow every set precisely + +**Promotional text** +Bring your authored programme into the gym, record what really happened, and keep every set usable offline. + +**Description** +Setline is the execution layer for a programme you already trust. Start the planned workout, follow exercises and sets in their exact authored order, record weight, repetitions, timed work, cardio, mobility, and drop segments, and let timestamp-based rest timing survive interruptions. + +Explicit session changes stay explicit: skip a set, do it later, or add an extra without silently rewriting the next workout. History keeps planned and recorded values separate, while deterministic progression suggestions require your approval. + +Active workouts are device-first and remain usable without a connection. Export or preview-replace the complete local state whenever you choose. + +**Keywords** +workout,training,strength,gym,sets,reps,rest timer,programme,fitness log + +## URLs + +- Support: `https://setline.significanthobbies.com` +- Privacy: `https://setline.significanthobbies.com/privacy` + +## Privacy draft + +- Tracking: none +- Third-party advertising: none +- Local workout and profile data: remains on device in device-only mode +- App Store Connect answer for this build: “No, we do not collect data from this app” +- IDFA: not used + +## Age rating draft + +- Made for Kids: No +- Violence, sexual content, profanity, drugs, alcohol, gambling, horror: None +- Medical or treatment claims: None +- User-generated content, messaging, unrestricted web access: None + +Confirm the rating produced by App Store Connect's current questionnaire. + +## Review notes draft + +Setline does not generate or prescribe a training programme. The bundled sample demonstrates exact-order workout playback. All active workout actions work offline. No HealthKit or sensor data is requested. + +## Screenshots and release + +- iPhone 6.9-inch portrait: `today.png`, `plan.png`, `workout-player.png`, `rest-timer.png`, and `history.png` +- Each store image is `1206 × 2622`, an accepted 6.9-inch screenshot size +- Accessibility evidence is retained separately and is not part of the default store sequence +- iPad screenshots: not required; the target is iPhone only +- App previews: omit for version 1.0 +- Release: manual diff --git a/ios/Setline.xcodeproj/project.pbxproj b/ios/Setline.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d385e98 --- /dev/null +++ b/ios/Setline.xcodeproj/project.pbxproj @@ -0,0 +1,739 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 06B31DF75BFAA837FA9FAAA6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6BF346E7B39E1FEE8CD50C7C /* Assets.xcassets */; }; + 1FA5DF9F89B308A82207E40B /* SetlineCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 309CBBBA2928642CBD999F67 /* Progression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 594DC48A6CD7B68259549A12 /* Progression.swift */; }; + 3CE2DEDE101DA826A45AC5F8 /* Design.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2CB1821B5B5AE4C75873C6 /* Design.swift */; }; + 66E5690BB3806C5D8CF41CF8 /* SetlineUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE9CEC1CE889DCD183F4CF7 /* SetlineUITests.swift */; }; + 677B5B2EB644215DBE6CE4D0 /* SetlineCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1211B40E359F4BBD82A557A9 /* SetlineCoreTests.swift */; }; + 7DFE7762F43344AFC746CDC6 /* Domain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0859DE334CC4D97FFE0DFDA /* Domain.swift */; }; + 8AEB42E3793E01E4E40F67FF /* SetlineCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */; }; + A748F947702FBF73CBE681AD /* WorkoutPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818A8EDA05B5D0B4E9DE47B3 /* WorkoutPlayerView.swift */; }; + A949A76428AB4118FB259A70 /* SecondaryViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFAE6EB21BC26F97140F4FEE /* SecondaryViews.swift */; }; + BE5FDC83C2B64F526F64979C /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824EFFB1023C857CE4F1FE1C /* RootView.swift */; }; + C66AD84C2D8FAD24149B2445 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F52F5D64B932696BAEDEE0AC /* PrivacyInfo.xcprivacy */; }; + C7E1EDD15D038B815CB78D27 /* SetlineApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608F81A574360691A58B8581 /* SetlineApp.swift */; }; + D9647D921553DE22AB36226A /* SetlineCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + E85DFE444A157EC32C671B9B /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B839442BFCCB000A563CD704 /* AppModel.swift */; }; + EC589E605EEE16DA5E0F613E /* SetlineCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */; }; + F5D0CC71C826F541575E7D99 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8358BE39000A0F7E1BB90 /* Persistence.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 7D4397B88F1D01E27BFC22E9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7613AF2ED4B7ABAB4CD5A5B0 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B796AD95D7DE9827A13053A1; + remoteInfo = SetlineCore; + }; + C41071549BFD7A53D606E56C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7613AF2ED4B7ABAB4CD5A5B0 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B796AD95D7DE9827A13053A1; + remoteInfo = SetlineCore; + }; + CD54B67C4D6E23A943EA6C9B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7613AF2ED4B7ABAB4CD5A5B0 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D603BD59019B73191F313D99; + remoteInfo = Setline; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 53FE46E630D8EDE2E85D098B /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 1FA5DF9F89B308A82207E40B /* SetlineCore.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 7D884851B222637191A5600B /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + D9647D921553DE22AB36226A /* SetlineCore.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SetlineCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1211B40E359F4BBD82A557A9 /* SetlineCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetlineCoreTests.swift; sourceTree = ""; }; + 3C2CB1821B5B5AE4C75873C6 /* Design.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Design.swift; sourceTree = ""; }; + 594DC48A6CD7B68259549A12 /* Progression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Progression.swift; sourceTree = ""; }; + 608F81A574360691A58B8581 /* SetlineApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetlineApp.swift; sourceTree = ""; }; + 65E8358BE39000A0F7E1BB90 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; + 6BF346E7B39E1FEE8CD50C7C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 818A8EDA05B5D0B4E9DE47B3 /* WorkoutPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutPlayerView.swift; sourceTree = ""; }; + 824EFFB1023C857CE4F1FE1C /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + B0859DE334CC4D97FFE0DFDA /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = ""; }; + B1F5DA4EB37759476E57B69F /* SetlineUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = SetlineUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B7B0A3E4EF45F3C038E3C265 /* SetlineCoreTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = SetlineCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B839442BFCCB000A563CD704 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + BA87F29CCAE78D4F24E75B1F /* Setline.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Setline.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DFAE6EB21BC26F97140F4FEE /* SecondaryViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryViews.swift; sourceTree = ""; }; + F52F5D64B932696BAEDEE0AC /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + FEE9CEC1CE889DCD183F4CF7 /* SetlineUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetlineUITests.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + DF546D2AE6ADE8F24C83344E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8AEB42E3793E01E4E40F67FF /* SetlineCore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FE6FCD7F0FB0D03573FB95CA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + EC589E605EEE16DA5E0F613E /* SetlineCore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1DB949AB54ED01CA9EBB90BB /* Resources */ = { + isa = PBXGroup; + children = ( + 6BF346E7B39E1FEE8CD50C7C /* Assets.xcassets */, + F52F5D64B932696BAEDEE0AC /* PrivacyInfo.xcprivacy */, + ); + path = Resources; + sourceTree = ""; + }; + 28BB78622912024E69F708EC /* Setline */ = { + isa = PBXGroup; + children = ( + B839442BFCCB000A563CD704 /* AppModel.swift */, + 3C2CB1821B5B5AE4C75873C6 /* Design.swift */, + 824EFFB1023C857CE4F1FE1C /* RootView.swift */, + DFAE6EB21BC26F97140F4FEE /* SecondaryViews.swift */, + 608F81A574360691A58B8581 /* SetlineApp.swift */, + 818A8EDA05B5D0B4E9DE47B3 /* WorkoutPlayerView.swift */, + 1DB949AB54ED01CA9EBB90BB /* Resources */, + ); + name = Setline; + path = Sources/Setline; + sourceTree = ""; + }; + 401800249844F0B5D6E836D3 = { + isa = PBXGroup; + children = ( + 28BB78622912024E69F708EC /* Setline */, + 5745423AFF1E15935A399E22 /* SetlineCore */, + E920810526DCE73641A751A7 /* SetlineCoreTests */, + CA65850D7C9D4AC4404536A1 /* SetlineUITests */, + C93C5A86494CB1CE017F1623 /* Products */, + ); + sourceTree = ""; + }; + 5745423AFF1E15935A399E22 /* SetlineCore */ = { + isa = PBXGroup; + children = ( + B0859DE334CC4D97FFE0DFDA /* Domain.swift */, + 65E8358BE39000A0F7E1BB90 /* Persistence.swift */, + 594DC48A6CD7B68259549A12 /* Progression.swift */, + ); + name = SetlineCore; + path = Sources/SetlineCore; + sourceTree = ""; + }; + C93C5A86494CB1CE017F1623 /* Products */ = { + isa = PBXGroup; + children = ( + BA87F29CCAE78D4F24E75B1F /* Setline.app */, + 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */, + B7B0A3E4EF45F3C038E3C265 /* SetlineCoreTests.xctest */, + B1F5DA4EB37759476E57B69F /* SetlineUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + CA65850D7C9D4AC4404536A1 /* SetlineUITests */ = { + isa = PBXGroup; + children = ( + FEE9CEC1CE889DCD183F4CF7 /* SetlineUITests.swift */, + ); + name = SetlineUITests; + path = Tests/SetlineUITests; + sourceTree = ""; + }; + E920810526DCE73641A751A7 /* SetlineCoreTests */ = { + isa = PBXGroup; + children = ( + 1211B40E359F4BBD82A557A9 /* SetlineCoreTests.swift */, + ); + name = SetlineCoreTests; + path = Tests/SetlineCoreTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 74023CB72D0996B7E5057D87 /* SetlineUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 703C9D776AEE43D96D0D8D26 /* Build configuration list for PBXNativeTarget "SetlineUITests" */; + buildPhases = ( + 2515F326A4A6A88F85E1DC4D /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 9A6BC58D463F35BFC63D2FBD /* PBXTargetDependency */, + ); + name = SetlineUITests; + packageProductDependencies = ( + ); + productName = SetlineUITests; + productReference = B1F5DA4EB37759476E57B69F /* SetlineUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + B796AD95D7DE9827A13053A1 /* SetlineCore */ = { + isa = PBXNativeTarget; + buildConfigurationList = C8EAE7F1F8099550E9F81511 /* Build configuration list for PBXNativeTarget "SetlineCore" */; + buildPhases = ( + 66C58AB55779EC703D6E1C99 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SetlineCore; + packageProductDependencies = ( + ); + productName = SetlineCore; + productReference = 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */; + productType = "com.apple.product-type.framework"; + }; + D603BD59019B73191F313D99 /* Setline */ = { + isa = PBXNativeTarget; + buildConfigurationList = C11ED67EB1FBB73D642FE3CD /* Build configuration list for PBXNativeTarget "Setline" */; + buildPhases = ( + 3311BA4559E04C8EE8FE8BB8 /* Sources */, + 1B15D018AF3479506C1137DC /* Resources */, + DF546D2AE6ADE8F24C83344E /* Frameworks */, + 7D884851B222637191A5600B /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + F6AB0F63AE202FB23C01AC44 /* PBXTargetDependency */, + ); + name = Setline; + packageProductDependencies = ( + ); + productName = Setline; + productReference = BA87F29CCAE78D4F24E75B1F /* Setline.app */; + productType = "com.apple.product-type.application"; + }; + FD342A852B1288E9F67E7FFC /* SetlineCoreTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6018502E3EB3CF6EEE14B939 /* Build configuration list for PBXNativeTarget "SetlineCoreTests" */; + buildPhases = ( + 3ED318319DC9931CECAF588B /* Sources */, + FE6FCD7F0FB0D03573FB95CA /* Frameworks */, + 53FE46E630D8EDE2E85D098B /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + B0EB3A109B7BBD8C9DE390A5 /* PBXTargetDependency */, + ); + name = SetlineCoreTests; + packageProductDependencies = ( + ); + productName = SetlineCoreTests; + productReference = B7B0A3E4EF45F3C038E3C265 /* SetlineCoreTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7613AF2ED4B7ABAB4CD5A5B0 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 74023CB72D0996B7E5057D87 = { + DevelopmentTeam = 8F7LXHTJZR; + ProvisioningStyle = Automatic; + TestTargetID = D603BD59019B73191F313D99; + }; + B796AD95D7DE9827A13053A1 = { + DevelopmentTeam = 8F7LXHTJZR; + ProvisioningStyle = Automatic; + }; + D603BD59019B73191F313D99 = { + DevelopmentTeam = 8F7LXHTJZR; + ProvisioningStyle = Automatic; + }; + FD342A852B1288E9F67E7FFC = { + DevelopmentTeam = 8F7LXHTJZR; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 49E29386C991D90008DA329A /* Build configuration list for PBXProject "Setline" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 401800249844F0B5D6E836D3; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = C93C5A86494CB1CE017F1623 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B796AD95D7DE9827A13053A1 /* SetlineCore */, + D603BD59019B73191F313D99 /* Setline */, + FD342A852B1288E9F67E7FFC /* SetlineCoreTests */, + 74023CB72D0996B7E5057D87 /* SetlineUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1B15D018AF3479506C1137DC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 06B31DF75BFAA837FA9FAAA6 /* Assets.xcassets in Resources */, + C66AD84C2D8FAD24149B2445 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2515F326A4A6A88F85E1DC4D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 66E5690BB3806C5D8CF41CF8 /* SetlineUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3311BA4559E04C8EE8FE8BB8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E85DFE444A157EC32C671B9B /* AppModel.swift in Sources */, + 3CE2DEDE101DA826A45AC5F8 /* Design.swift in Sources */, + BE5FDC83C2B64F526F64979C /* RootView.swift in Sources */, + A949A76428AB4118FB259A70 /* SecondaryViews.swift in Sources */, + C7E1EDD15D038B815CB78D27 /* SetlineApp.swift in Sources */, + A748F947702FBF73CBE681AD /* WorkoutPlayerView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3ED318319DC9931CECAF588B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 677B5B2EB644215DBE6CE4D0 /* SetlineCoreTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 66C58AB55779EC703D6E1C99 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7DFE7762F43344AFC746CDC6 /* Domain.swift in Sources */, + F5D0CC71C826F541575E7D99 /* Persistence.swift in Sources */, + 309CBBBA2928642CBD999F67 /* Progression.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 9A6BC58D463F35BFC63D2FBD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D603BD59019B73191F313D99 /* Setline */; + targetProxy = CD54B67C4D6E23A943EA6C9B /* PBXContainerItemProxy */; + }; + B0EB3A109B7BBD8C9DE390A5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B796AD95D7DE9827A13053A1 /* SetlineCore */; + targetProxy = C41071549BFD7A53D606E56C /* PBXContainerItemProxy */; + }; + F6AB0F63AE202FB23C01AC44 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B796AD95D7DE9827A13053A1 /* SetlineCore */; + targetProxy = 7D4397B88F1D01E27BFC22E9 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 1631E1255063D7C6E65D540D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.significanthobbies.setline.core; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 38365A5FDBDD6492252A1C84 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.significanthobbies.setline.ui-tests"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Setline; + }; + name = Release; + }; + 469FDE6148ADD158055DFA3B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.significanthobbies.setline.ui-tests"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Setline; + }; + name = Debug; + }; + 4ACB056C452750967D65E5AC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Setline; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.significanthobbies.setline; + PRODUCT_NAME = Setline; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 6FEEE65694124493F370A2E6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.significanthobbies.setline.core; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 861C37FC689DCD2F13A11C90 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Setline; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.significanthobbies.setline; + PRODUCT_NAME = Setline; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 8F8956B62DB3F92223C40702 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.significanthobbies.setline.core-tests"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + A66B8E66BA114ABBF9D9B116 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 8F7LXHTJZR; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + 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; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + AD408DA0F841D737BCF3FB12 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 8F7LXHTJZR; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + D09CB000357DDEDCDDC06E0C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.significanthobbies.setline.core-tests"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 49E29386C991D90008DA329A /* Build configuration list for PBXProject "Setline" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A66B8E66BA114ABBF9D9B116 /* Debug */, + AD408DA0F841D737BCF3FB12 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 6018502E3EB3CF6EEE14B939 /* Build configuration list for PBXNativeTarget "SetlineCoreTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D09CB000357DDEDCDDC06E0C /* Debug */, + 8F8956B62DB3F92223C40702 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 703C9D776AEE43D96D0D8D26 /* Build configuration list for PBXNativeTarget "SetlineUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 469FDE6148ADD158055DFA3B /* Debug */, + 38365A5FDBDD6492252A1C84 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C11ED67EB1FBB73D642FE3CD /* Build configuration list for PBXNativeTarget "Setline" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 861C37FC689DCD2F13A11C90 /* Debug */, + 4ACB056C452750967D65E5AC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C8EAE7F1F8099550E9F81511 /* Build configuration list for PBXNativeTarget "SetlineCore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1631E1255063D7C6E65D540D /* Debug */, + 6FEEE65694124493F370A2E6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 7613AF2ED4B7ABAB4CD5A5B0 /* Project object */; +} diff --git a/ios/Setline.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Setline.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Setline.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Setline.xcodeproj/xcshareddata/xcschemes/Setline.xcscheme b/ios/Setline.xcodeproj/xcshareddata/xcschemes/Setline.xcscheme new file mode 100644 index 0000000..11aef3e --- /dev/null +++ b/ios/Setline.xcodeproj/xcshareddata/xcschemes/Setline.xcscheme @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Sources/Setline/AppModel.swift b/ios/Sources/Setline/AppModel.swift new file mode 100644 index 0000000..cf0fb19 --- /dev/null +++ b/ios/Sources/Setline/AppModel.swift @@ -0,0 +1,171 @@ +import Foundation +import Observation +import SetlineCore + +@MainActor +@Observable +final class AppModel { + private(set) var document: SetlineDocument = .sample + var isLoading = true + var isWorkoutPresented = false + var selectedTab = 0 + var message: String? + var importPreview: SetlineDocument? + var isImportConfirmationPresented = false + + private let store: SetlineStore + + init(store: SetlineStore = SetlineStore()) { + self.store = store + if ProcessInfo.processInfo.arguments.contains("--plan-demo") { selectedTab = 1 } + if ProcessInfo.processInfo.arguments.contains("--history-demo") { selectedTab = 2 } + } + + func load() async { + defer { isLoading = false } + do { + if ProcessInfo.processInfo.arguments.contains("--fresh-demo") { + document = .sample + } else { + document = try await store.load() + } + if ProcessInfo.processInfo.arguments.contains("--active-demo"), document.activeSession == nil, + let first = document.templates.first { + try document.startWorkout(templateID: first.id) + isWorkoutPresented = true + } + if ProcessInfo.processInfo.arguments.contains("--rest-demo"), document.activeSession == nil, + let first = document.templates.first { + try document.startWorkout(templateID: first.id) + try document.completeCurrent(with: [SetSegment(weight: 40, repetitions: 8)]) + isWorkoutPresented = true + } + } catch { + document = .sample + message = error.localizedDescription + } + } + + func startWorkout(_ template: WorkoutTemplate) async { + await mutate { + try $0.startWorkout(templateID: template.id) + } + if document.activeSession != nil { isWorkoutPresented = true } + } + + func completeCurrent(segments: [SetSegment]) async { + await mutate { try $0.completeCurrent(with: segments) } + } + + func skipCurrent() async { + await mutate { try $0.skipCurrent() } + } + + func deferCurrent() async { + await mutate { try $0.deferCurrent() } + } + + func addExtraSet() async { + await mutate { try $0.addExtraSet() } + } + + func adjustRest(by seconds: Int) async { + await mutate { $0.adjustRest(by: seconds) } + } + + func endRest() async { + await mutate { $0.endRest() } + } + + func finishWorkout() async { + await mutate { try $0.finishWorkout() } + if document.activeSession == nil { isWorkoutPresented = false } + } + + func duplicateTemplate(_ template: WorkoutTemplate) async { + await mutate { try $0.duplicateTemplate(template.id) } + message = "Independent copy created." + } + + func saveTemplate(_ template: WorkoutTemplate) async { + await mutate { document in + var saved = template + saved.isBundled = false + if let index = document.templates.firstIndex(where: { $0.id == saved.id }) { + document.templates[index] = saved + } else { + document.templates.append(saved) + } + } + message = "Template saved." + } + + func assignTemplate(_ templateID: UUID?, to weekday: Int) async { + await mutate { document in + guard let index = document.programme?.days.firstIndex(where: { $0.weekday == weekday }) else { return } + document.programme?.days[index].templateID = templateID + } + } + + func setProgrammeWeeks(_ weekCount: Int) async { + await mutate { document in + document.programme?.weekCount = min(16, max(1, weekCount)) + } + } + + func toggleProgramme() async { + await mutate { $0.programme?.enabled.toggle() } + } + + func exportData() async -> Data? { + do { + return try await store.export(document) + } catch { + message = error.localizedDescription + return nil + } + } + + func prepareImport(_ data: Data) async { + do { + importPreview = try await store.previewImport(data) + isImportConfirmationPresented = true + } catch { + message = error.localizedDescription + } + } + + func confirmImport() async { + guard let importPreview else { return } + do { + try await store.replace(with: importPreview) + document = importPreview + self.importPreview = nil + isImportConfirmationPresented = false + message = "Setline data replaced." + } catch { + message = error.localizedDescription + } + } + + func resetLocalData() async { + do { + try await store.reset() + document = .sample + message = "Local data reset." + } catch { + message = error.localizedDescription + } + } + + private func mutate(_ operation: (inout SetlineDocument) throws -> Void) async { + do { + var next = document + try operation(&next) + try await store.save(next) + document = next + } catch { + message = error.localizedDescription + } + } +} diff --git a/ios/Sources/Setline/Design.swift b/ios/Sources/Setline/Design.swift new file mode 100644 index 0000000..fdac0a6 --- /dev/null +++ b/ios/Sources/Setline/Design.swift @@ -0,0 +1,63 @@ +import SwiftUI + +enum SetlinePalette { + static let chalk = Color(red: 247 / 255, green: 246 / 255, blue: 240 / 255) + static let paper = Color.white + static let ink = Color(red: 24 / 255, green: 38 / 255, blue: 46 / 255) + static let steel = Color(red: 221 / 255, green: 225 / 255, blue: 220 / 255) + static let lime = Color(red: 185 / 255, green: 232 / 255, blue: 63 / 255) + static let coral = Color(red: 255 / 255, green: 97 / 255, blue: 77 / 255) + static let blue = Color(red: 185 / 255, green: 216 / 255, blue: 232 / 255) +} + +struct SetlineBackground: ViewModifier { + func body(content: Content) -> some View { + content + .foregroundStyle(SetlinePalette.ink) + .background(SetlinePalette.chalk.ignoresSafeArea()) + .tint(SetlinePalette.ink) + } +} + +struct SectionLabel: View { + var text: String + + var body: some View { + Text(text.uppercased()) + .font(.caption.weight(.bold)) + .tracking(1.2) + .foregroundStyle(SetlinePalette.ink.opacity(0.62)) + } +} + +struct InkRule: View { + var body: some View { + Rectangle() + .fill(SetlinePalette.ink.opacity(0.16)) + .frame(height: 1) + } +} + +struct ActionSlabStyle: ButtonStyle { + var destructive = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.headline.weight(.black)) + .foregroundStyle(SetlinePalette.ink) + .frame(maxWidth: .infinity, minHeight: 56) + .background(destructive ? SetlinePalette.coral : SetlinePalette.lime) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(alignment: .bottom) { + Rectangle() + .fill(SetlinePalette.ink.opacity(0.82)) + .frame(height: configuration.isPressed ? 1 : 4) + } + .offset(y: configuration.isPressed ? 3 : 0) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + } +} + +extension View { + func setlineBackground() -> some View { modifier(SetlineBackground()) } +} diff --git a/ios/Sources/Setline/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/ios/Sources/Setline/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..e5fe2a0 Binary files /dev/null and b/ios/Sources/Setline/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/ios/Sources/Setline/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Sources/Setline/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f22e10c --- /dev/null +++ b/ios/Sources/Setline/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Sources/Setline/Resources/Assets.xcassets/Contents.json b/ios/Sources/Setline/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/Sources/Setline/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Sources/Setline/Resources/PrivacyInfo.xcprivacy b/ios/Sources/Setline/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..8a9617d --- /dev/null +++ b/ios/Sources/Setline/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/ios/Sources/Setline/RootView.swift b/ios/Sources/Setline/RootView.swift new file mode 100644 index 0000000..78775b6 --- /dev/null +++ b/ios/Sources/Setline/RootView.swift @@ -0,0 +1,231 @@ +import SetlineCore +import SwiftUI +import UniformTypeIdentifiers + +struct RootView: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var model = model + TabView(selection: $model.selectedTab) { + NavigationStack { TodayView() } + .tabItem { Label("Today", systemImage: "scope") } + .tag(0) + NavigationStack { PlanView() } + .tabItem { Label("Plan", systemImage: "list.bullet.rectangle") } + .tag(1) + NavigationStack { HistoryView() } + .tabItem { Label("History", systemImage: "clock.arrow.circlepath") } + .tag(2) + NavigationStack { SettingsView() } + .tabItem { Label("You", systemImage: "person.crop.circle") } + .tag(3) + } + .setlineBackground() + .fullScreenCover(isPresented: $model.isWorkoutPresented) { + WorkoutPlayerView() + } + .alert("Setline", isPresented: Binding( + get: { model.message != nil }, + set: { if !$0 { model.message = nil } } + )) { + Button("OK", role: .cancel) { model.message = nil } + } message: { + Text(model.message ?? "") + } + } +} + +struct TodayView: View { + @Environment(AppModel.self) private var model + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + private var plannedTemplate: WorkoutTemplate? { + let weekday = Calendar.current.component(.weekday, from: .now) + guard let id = model.document.programme?.days.first(where: { $0.weekday == weekday })?.templateID else { + return model.document.templates.first + } + return model.document.templates.first(where: { $0.id == id }) ?? model.document.templates.first + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + header + if let active = model.document.activeSession { + activeReceipt(active) + } else if let plannedTemplate { + workoutHero(plannedTemplate) + } + weekStrip + recentEvidence + } + .padding(.horizontal, 20) + .padding(.bottom, 32) + } + .background(SetlinePalette.chalk) + .navigationBarHidden(true) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + Group { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 6) { + brandLabel + dateLabel + } + } else { + HStack(alignment: .firstTextBaseline) { + brandLabel + Spacer() + dateLabel + } + } + } + .padding(.top, 18) + InkRule() + Text("Follow the plan.\nRecord the truth.") + .font(.system(.largeTitle, design: .rounded, weight: .black)) + .tracking(-1) + .padding(.top, 10) + } + } + + private var brandLabel: some View { + Text("SETLINE") + .font(.caption.weight(.black)) + .tracking(2.2) + } + + private var dateLabel: some View { + Text(Date.now.formatted(.dateTime.weekday(.wide).day().month(.abbreviated))) + .font(.subheadline.monospacedDigit().weight(.semibold)) + } + + private func workoutHero(_ template: WorkoutTemplate) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + SectionLabel(text: "Today · authored plan") + Spacer() + Image(systemName: "lock.fill") + .font(.caption) + } + .padding(.bottom, 14) + Text(template.name) + .font(.system(.title, design: .rounded, weight: .black)) + .tracking(-0.8) + Text(template.detail) + .font(.title3.weight(.medium)) + .foregroundStyle(SetlinePalette.ink.opacity(0.66)) + .padding(.top, 3) + Group { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 12) { + metric("EXERCISES", "\(template.exercises.count)") + metric("SETS", "\(template.exercises.flatMap(\.sets).count)") + metric("MODE", "OFFLINE") + } + } else { + HStack(spacing: 0) { + metric("EXERCISES", "\(template.exercises.count)") + metric("SETS", "\(template.exercises.flatMap(\.sets).count)") + metric("MODE", "OFFLINE") + } + } + } + .padding(.vertical, 22) + Button { + Task { await model.startWorkout(template) } + } label: { + Label("Start workout", systemImage: "arrow.right") + } + .buttonStyle(ActionSlabStyle()) + .accessibilityHint("Starts an offline workout using the authored order") + } + .padding(20) + .background(SetlinePalette.paper) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(SetlinePalette.ink.opacity(0.12), lineWidth: 1) + } + } + + private func activeReceipt(_ session: WorkoutSession) -> some View { + VStack(alignment: .leading, spacing: 16) { + SectionLabel(text: "Workout in progress") + Text(session.templateName) + .font(.system(.title, design: .rounded, weight: .black)) + ProgressView(value: Double(session.completedCount), total: Double(max(1, session.steps.count))) + .tint(SetlinePalette.lime) + .scaleEffect(x: 1, y: 2, anchor: .center) + Text("\(session.completedCount) of \(session.steps.count) steps recorded") + .font(.subheadline.monospacedDigit()) + Button("Resume workout") { model.isWorkoutPresented = true } + .buttonStyle(ActionSlabStyle()) + } + .padding(20) + .background(SetlinePalette.ink) + .foregroundStyle(SetlinePalette.chalk) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + private func metric(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(value) + .font(.headline.monospacedDigit().weight(.black)) + Text(label) + .font(.caption2.weight(.bold)) + .foregroundStyle(SetlinePalette.ink.opacity(0.55)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var weekStrip: some View { + VStack(alignment: .leading, spacing: 14) { + SectionLabel(text: "This week") + HStack(spacing: 7) { + ForEach(1...7, id: \.self) { index in + let hasPlan = model.document.programme?.days.first(where: { $0.weekday == index })?.templateID != nil + VStack(spacing: 8) { + Text(Calendar.current.veryShortWeekdaySymbols[index - 1]) + .font(.caption2.weight(.bold)) + Circle() + .fill(hasPlan ? SetlinePalette.ink : SetlinePalette.steel) + .frame(width: 9, height: 9) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(hasPlan ? SetlinePalette.blue.opacity(0.65) : .clear) + .clipShape(RoundedRectangle(cornerRadius: 9)) + } + } + } + } + + private var recentEvidence: some View { + VStack(alignment: .leading, spacing: 14) { + SectionLabel(text: "Recorded evidence") + if let latest = model.document.history.first { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(latest.templateName).font(.headline) + Text(latest.completedAt?.formatted(date: .abbreviated, time: .shortened) ?? "In progress") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Text("\(latest.completedCount)") + .font(.system(size: 30, weight: .black, design: .rounded).monospacedDigit()) + } + .padding(.vertical, 12) + InkRule() + } else { + Text("Your first completed workout will establish the record—not an estimate.") + .font(.body) + .foregroundStyle(SetlinePalette.ink.opacity(0.66)) + } + } + } +} diff --git a/ios/Sources/Setline/SecondaryViews.swift b/ios/Sources/Setline/SecondaryViews.swift new file mode 100644 index 0000000..2e6a872 --- /dev/null +++ b/ios/Sources/Setline/SecondaryViews.swift @@ -0,0 +1,491 @@ +import SetlineCore +import SwiftUI +import UniformTypeIdentifiers + +struct PlanView: View { + @Environment(AppModel.self) private var model + @State private var editingTemplate: WorkoutTemplate? + @State private var isCreatingTemplate = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + pageHeader("Plan", subtitle: "Templates stay authored. Sessions record deviations.") + if let programme = model.document.programme { + VStack(alignment: .leading, spacing: 16) { + HStack { + VStack(alignment: .leading, spacing: 4) { + SectionLabel(text: "Active programme") + Text(programme.name).font(.title2.weight(.black)) + Text("\(programme.weekCount) weeks · Monday-based") + .font(.subheadline).foregroundStyle(.secondary) + } + Spacer() + Toggle("Enabled", isOn: Binding( + get: { programme.enabled }, + set: { _ in Task { await model.toggleProgramme() } } + )) + .labelsHidden() + } + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 7), + spacing: 6 + ) { + ForEach(programme.days) { day in + Menu { + Button("Rest day") { + Task { await model.assignTemplate(nil, to: day.weekday) } + } + ForEach(model.document.templates) { template in + Button(template.name) { + Task { await model.assignTemplate(template.id, to: day.weekday) } + } + } + } label: { + VStack(spacing: 7) { + Text(Calendar.current.shortWeekdaySymbols[day.weekday - 1].prefix(2)) + .font(.caption2.weight(.bold)) + Image(systemName: day.templateID == nil ? "minus" : "checkmark") + .font(.caption.weight(.black)) + } + .frame(maxWidth: .infinity, minHeight: 52) + .background(day.templateID == nil ? SetlinePalette.steel.opacity(0.55) : SetlinePalette.lime) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .accessibilityLabel("\(Calendar.current.weekdaySymbols[day.weekday - 1]), \(templateName(for: day.templateID))") + } + } + Stepper( + "Block length: \(programme.weekCount) weeks", + value: Binding( + get: { programme.weekCount }, + set: { weeks in Task { await model.setProgrammeWeeks(weeks) } } + ), + in: 1...16 + ) + } + .padding(18) + .background(SetlinePalette.paper) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + VStack(alignment: .leading, spacing: 10) { + HStack { + SectionLabel(text: "Workout templates") + Spacer() + Button { isCreatingTemplate = true } label: { + Label("New template", systemImage: "plus") + } + .font(.subheadline.weight(.bold)) + .frame(minHeight: 44) + } + ForEach(model.document.templates) { template in + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(template.name).font(.title3.weight(.black)) + Text(template.detail).font(.subheadline).foregroundStyle(.secondary) + } + Spacer() + Text(template.isBundled ? "BUNDLED" : "CUSTOM") + .font(.caption2.weight(.black)) + .padding(6) + .background(template.isBundled ? SetlinePalette.blue : SetlinePalette.lime) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + HStack { + Text("\(template.exercises.count) exercises") + Text("·") + Text("\(template.exercises.flatMap(\.sets).count) sets") + Spacer() + Button("Duplicate") { Task { await model.duplicateTemplate(template) } } + .font(.subheadline.weight(.bold)) + if !template.isBundled { + Button("Edit") { editingTemplate = template } + .font(.subheadline.weight(.bold)) + } + } + .font(.subheadline.monospacedDigit()) + InkRule() + } + .padding(.vertical, 8) + } + } + } + .padding(20) + } + .setlineBackground() + .navigationBarHidden(true) + .sheet(isPresented: $isCreatingTemplate) { + TemplateEditorView() + } + .sheet(item: $editingTemplate) { template in + TemplateEditorView(template: template) + } + } + + private func templateName(for id: UUID?) -> String { + guard let id else { return "rest day" } + return model.document.templates.first(where: { $0.id == id })?.name ?? "unavailable template" + } +} + +struct HistoryView: View { + @Environment(AppModel.self) private var model + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 26) { + pageHeader("History", subtitle: "Recorded, calculated, and unavailable stay distinct.") + HStack(spacing: 10) { + historyMetric("SESSIONS", "\(model.document.history.count)", SetlinePalette.blue) + historyMetric("SETS", "\(model.document.history.reduce(0) { $0 + $1.completedCount })", SetlinePalette.lime) + } + progressionSection + if model.document.history.isEmpty { + ContentUnavailableView( + "No recorded workouts", + systemImage: "clock.arrow.circlepath", + description: Text("Complete a workout to create evidence. Setline will not invent a chart first.") + ) + .frame(minHeight: 320) + } else { + ForEach(model.document.history) { session in + NavigationLink { + SessionDetailView(session: session) + } label: { + HStack(alignment: .top, spacing: 14) { + VStack(spacing: 3) { + Text(session.completedAt?.formatted(.dateTime.day()) ?? "–") + .font(.title2.monospacedDigit().weight(.black)) + Text(session.completedAt?.formatted(.dateTime.month(.abbreviated)) ?? "") + .font(.caption.weight(.bold)) + } + .frame(width: 52, height: 58) + .background(SetlinePalette.blue) + .clipShape(RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 5) { + Text(session.templateName).font(.headline.weight(.black)) + Text("\(session.completedCount) completed · \(session.steps.count - session.completedCount) skipped") + .font(.subheadline.monospacedDigit()) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + } + .foregroundStyle(SetlinePalette.ink) + .padding(.vertical, 8) + } + InkRule() + } + } + } + .padding(20) + } + .setlineBackground() + .navigationBarHidden(true) + } + + private var progressionSection: some View { + let exerciseNames = Array(Set(model.document.history.flatMap(\.steps).map(\.exerciseName))).sorted() + let recommendations = exerciseNames.compactMap { + ProgressionEngine.recommendation(for: $0, history: model.document.history) + } + return VStack(alignment: .leading, spacing: 10) { + SectionLabel(text: "Next-session suggestions") + if recommendations.isEmpty { + Text("Unavailable until at least two comparable completed strength sets establish evidence.") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + ForEach(recommendations, id: \.exerciseName) { recommendation in + VStack(alignment: .leading, spacing: 5) { + Text(recommendation.exerciseName).font(.headline) + Text("\(recommendation.previousWeight.formatted()) → \(recommendation.recommendedWeight.formatted()) kg") + .font(.title3.monospacedDigit().weight(.black)) + Text(recommendation.rationale).font(.caption).foregroundStyle(.secondary) + } + .padding(14) + .background(SetlinePalette.paper) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + } + } + + private func historyMetric(_ label: String, _ value: String, _ color: Color) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(value).font(.system(size: 36, weight: .black, design: .rounded).monospacedDigit()) + SectionLabel(text: label) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(color) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +private struct TemplateEditorView: View { + @Environment(AppModel.self) private var model + @Environment(\.dismiss) private var dismiss + @State private var draft: WorkoutTemplate + + init(template: WorkoutTemplate? = nil) { + _draft = State(initialValue: template ?? WorkoutTemplate( + name: "", + detail: "", + isBundled: false, + exercises: [ + Exercise( + name: "", + cue: "", + sets: [PlannedSet(label: "Working 1", kind: .strength, target: "", restSeconds: 90)] + ), + ] + )) + } + + var body: some View { + NavigationStack { + Form { + Section("Template") { + TextField("Name", text: $draft.name) + TextField("Short description", text: $draft.detail, axis: .vertical) + } + ForEach($draft.exercises) { $exercise in + Section { + TextField("Exercise name", text: $exercise.name) + TextField("Coaching cue", text: $exercise.cue, axis: .vertical) + ForEach($exercise.sets) { $set in + VStack(alignment: .leading, spacing: 10) { + TextField("Set label", text: $set.label) + Picker("Activity", selection: $set.kind) { + ForEach(ActivityKind.allCases, id: \.self) { kind in + Text(kind.rawValue.capitalized).tag(kind) + } + } + TextField("Target", text: $set.target) + Stepper("Rest: \(set.restSeconds) seconds", value: $set.restSeconds, in: 0...600, step: 15) + Button("Remove set", role: .destructive) { + exercise.sets.removeAll { $0.id == set.id } + } + } + } + Button { + exercise.sets.append(PlannedSet( + label: "Working \(exercise.sets.count + 1)", + kind: .strength, + target: "", + restSeconds: 90 + )) + } label: { + Label("Add set", systemImage: "plus") + } + Button("Remove exercise", role: .destructive) { + draft.exercises.removeAll { $0.id == exercise.id } + } + } header: { + Text(exercise.name.isEmpty ? "Exercise" : exercise.name) + } + } + Section { + Button { + draft.exercises.append(Exercise( + name: "", + cue: "", + sets: [PlannedSet(label: "Working 1", kind: .strength, target: "", restSeconds: 90)] + )) + } label: { + Label("Add exercise", systemImage: "plus") + } + } + } + .navigationTitle(draft.name.isEmpty ? "New template" : "Edit template") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + Task { + await model.saveTemplate(draft) + dismiss() + } + } + .disabled( + draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + draft.exercises.isEmpty || + draft.exercises.contains { exercise in + exercise.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || exercise.sets.isEmpty + } + ) + } + } + } + } +} + +private struct SessionDetailView: View { + let session: WorkoutSession + + var body: some View { + List { + Section("Session receipt") { + LabeledContent("Workout", value: session.templateName) + LabeledContent("Started", value: session.startedAt.formatted(date: .abbreviated, time: .shortened)) + if let completedAt = session.completedAt { + LabeledContent("Completed", value: completedAt.formatted(date: .abbreviated, time: .shortened)) + } + } + Section("Execution ledger") { + ForEach(Array(session.steps.enumerated()), id: \.element.id) { performed, step in + VStack(alignment: .leading, spacing: 5) { + HStack { + Text(step.exerciseName).font(.headline) + Spacer() + Text(step.status.rawValue.uppercased()).font(.caption.weight(.black)) + } + Text("Planned #\(step.authoredPosition + 1) · performed \(step.performedPosition.map { "#\($0 + 1)" } ?? "—")") + .font(.caption.monospacedDigit()).foregroundStyle(.secondary) + Text("Target: \(step.target)").font(.subheadline) + if !step.segments.isEmpty { + Text("Recorded: " + step.segments.map(\.recordedDescription).joined(separator: " + ")) + .font(.subheadline.weight(.semibold)) + } + } + .padding(.vertical, 4) + } + } + } + .navigationTitle("Recorded workout") + } +} + +struct SettingsView: View { + @Environment(AppModel.self) private var model + @State private var isImporterPresented = false + @State private var showResetConfirmation = false + + var body: some View { + @Bindable var model = model + ScrollView { + VStack(alignment: .leading, spacing: 28) { + pageHeader("You", subtitle: "Device-first. Nothing leaves this iPhone unless you choose it.") + settingsSection("Account & sync") { + HStack { + Image(systemName: "iphone.gen3") + .font(.title2) + .frame(width: 44, height: 44) + .background(SetlinePalette.blue) + .clipShape(RoundedRectangle(cornerRadius: 9)) + VStack(alignment: .leading, spacing: 3) { + Text("Device-only mode").font(.headline) + Text("Workout actions work offline.").font(.subheadline).foregroundStyle(.secondary) + } + Spacer() + Text(model.document.syncState.rawValue.uppercased()) + .font(.caption2.weight(.black)) + } + Link(destination: URL(string: "https://setline.significanthobbies.com")!) { + Label("Open Setline account", systemImage: "safari") + .frame(maxWidth: .infinity, minHeight: 48) + } + .buttonStyle(.bordered) + } + settingsSection("Your data") { + ShareLink( + item: SetlineExportPayload(document: model.document), + preview: SharePreview("Setline data") + ) { + Label("Export complete Setline data", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(minHeight: 48) + Button { + isImporterPresented = true + } label: { + Label("Preview an import", systemImage: "doc.badge.plus") + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(minHeight: 48) + Button(role: .destructive) { showResetConfirmation = true } label: { + Label("Reset local data", systemImage: "trash") + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(minHeight: 48) + } + settingsSection("About") { + LabeledContent("Version", value: "1.0.0 (1)") + Link("Privacy", destination: URL(string: "https://setline.significanthobbies.com/privacy")!) + .frame(minHeight: 44) + Link("Support", destination: URL(string: "https://setline.significanthobbies.com")!) + .frame(minHeight: 44) + } + } + .padding(20) + } + .setlineBackground() + .navigationBarHidden(true) + .fileImporter(isPresented: $isImporterPresented, allowedContentTypes: [.json]) { result in + guard case let .success(url) = result else { return } + let accessed = url.startAccessingSecurityScopedResource() + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + if let data = try? Data(contentsOf: url) { + Task { await model.prepareImport(data) } + } + } + .alert("Replace all Setline data?", isPresented: $model.isImportConfirmationPresented) { + Button("Replace", role: .destructive) { Task { await model.confirmImport() } } + Button("Cancel", role: .cancel) { model.importPreview = nil } + } message: { + Text("The import contains \(model.importPreview?.templates.count ?? 0) templates and \(model.importPreview?.history.count ?? 0) completed workouts. Your current device state will be replaced.") + } + .confirmationDialog("Reset local Setline data?", isPresented: $showResetConfirmation) { + Button("Reset local data", role: .destructive) { Task { await model.resetLocalData() } } + Button("Cancel", role: .cancel) {} + } + } + + private func settingsSection(_ title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 10) { + SectionLabel(text: title) + VStack(alignment: .leading, spacing: 10) { content() } + .padding(16) + .background(SetlinePalette.paper) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + } +} + +private func pageHeader(_ title: String, subtitle: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("SETLINE").font(.caption.weight(.black)).tracking(2.2) + InkRule() + Text(title).font(.system(.largeTitle, design: .rounded, weight: .black)) + Text(subtitle).font(.body).foregroundStyle(.secondary) + } + .padding(.top, 18) +} + +private struct SetlineExportPayload: Transferable { + let document: SetlineDocument + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .json) { payload in + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(payload.document) + } + } +} + +private extension SetSegment { + var recordedDescription: String { + if let weight, let repetitions { return "\(weight.formatted()) kg × \(repetitions)" } + if let repetitions { return "\(repetitions) reps" } + if let durationSeconds { return "\(durationSeconds)s" } + if let distanceKilometres { return "\(distanceKilometres.formatted()) km" } + return "Recorded" + } +} diff --git a/ios/Sources/Setline/SetlineApp.swift b/ios/Sources/Setline/SetlineApp.swift new file mode 100644 index 0000000..f401755 --- /dev/null +++ b/ios/Sources/Setline/SetlineApp.swift @@ -0,0 +1,15 @@ +import SetlineCore +import SwiftUI + +@main +struct SetlineApp: App { + @State private var model = AppModel() + + var body: some Scene { + WindowGroup { + RootView() + .environment(model) + .task { await model.load() } + } + } +} diff --git a/ios/Sources/Setline/WorkoutPlayerView.swift b/ios/Sources/Setline/WorkoutPlayerView.swift new file mode 100644 index 0000000..d49ea19 --- /dev/null +++ b/ios/Sources/Setline/WorkoutPlayerView.swift @@ -0,0 +1,344 @@ +import SetlineCore +import SwiftUI + +struct WorkoutPlayerView: View { + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var showFinishConfirmation = false + + var body: some View { + NavigationStack { + if let session = model.document.activeSession { + VStack(spacing: 0) { + sessionBar(session) + if let rest = session.rest { + RestBoard(rest: rest, next: session.currentStep) + .transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity)) + } else if let step = session.currentStep { + AttemptBoard(step: step) + .id(step.id) + } else { + completionBoard(session) + } + SetRail(session: session) + } + .background(SetlinePalette.chalk) + .animation(reduceMotion ? nil : .easeOut(duration: 0.22), value: session.rest != nil) + } else { + ContentUnavailableView("Workout complete", systemImage: "checkmark.seal.fill") + } + } + .interactiveDismissDisabled(model.document.activeSession != nil) + .confirmationDialog("Finish this workout?", isPresented: $showFinishConfirmation) { + Button("Finish and save") { Task { await model.finishWorkout() } } + Button("Keep training", role: .cancel) {} + } message: { + Text("Any remaining planned sets will be recorded as skipped.") + } + } + + private func sessionBar(_ session: WorkoutSession) -> some View { + HStack(spacing: 14) { + Button { + model.isWorkoutPresented = false + } label: { + Image(systemName: "chevron.down") + .frame(width: 44, height: 44) + .background(SetlinePalette.chalk.opacity(0.1)) + .clipShape(Circle()) + } + .accessibilityLabel("Return to Today") + VStack(alignment: .leading, spacing: 2) { + Text(session.templateName) + .font(.headline.weight(.black)) + .lineLimit(1) + Text("\(session.completedCount) / \(session.steps.count) recorded") + .font(.caption.monospacedDigit()) + .foregroundStyle(SetlinePalette.chalk.opacity(0.68)) + } + Spacer() + TimelineView(.periodic(from: .now, by: 1)) { context in + Text(context.date.timeIntervalSince(session.startedAt).durationClock) + .font(.headline.monospacedDigit().weight(.black)) + .accessibilityLabel("Workout elapsed time") + } + Button("Finish") { showFinishConfirmation = true } + .font(.subheadline.weight(.bold)) + .frame(minHeight: 44) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(SetlinePalette.ink) + .foregroundStyle(SetlinePalette.chalk) + } + + private func completionBoard(_ session: WorkoutSession) -> some View { + VStack(alignment: .leading, spacing: 18) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 42)) + .foregroundStyle(SetlinePalette.lime) + Text("The plan is recorded.") + .font(.system(.largeTitle, design: .rounded, weight: .black)) + Text("\(session.completedCount) completed · \(session.steps.count - session.completedCount) skipped") + .font(.headline.monospacedDigit()) + Button("Save workout") { Task { await model.finishWorkout() } } + .buttonStyle(ActionSlabStyle()) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(24) + } +} + +private struct AttemptBoard: View { + @Environment(AppModel.self) private var model + let step: WorkoutStep + @State private var weight = "" + @State private var repetitions = "" + @State private var duration = "" + @State private var distance = "" + @State private var hasDropSegment = false + @State private var dropWeight = "" + @State private var dropRepetitions = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 5) { + SectionLabel(text: step.isExtra ? "Session-only extra" : "\(step.label) · planned") + Text(step.exerciseName) + .font(.system(size: 32, weight: .black, design: .rounded)) + .tracking(-0.7) + } + Spacer() + Text("#\(step.authoredPosition + 1)") + .font(.headline.monospacedDigit().weight(.black)) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(SetlinePalette.blue) + .clipShape(RoundedRectangle(cornerRadius: 7)) + } + VStack(alignment: .leading, spacing: 5) { + Text("TARGET") + .font(.caption.weight(.bold)) + .tracking(1.1) + Text(step.target) + .font(.system(size: 42, weight: .black, design: .rounded).monospacedDigit()) + .minimumScaleFactor(0.7) + .lineLimit(1) + Text(step.cue) + .font(.body.weight(.medium)) + .foregroundStyle(SetlinePalette.ink.opacity(0.65)) + } + .padding(.vertical, 4) + InkRule() + actualInputs + if step.kind == .strength { + Button(hasDropSegment ? "Remove drop segment" : "Add drop segment") { + hasDropSegment.toggle() + } + .font(.subheadline.weight(.bold)) + .frame(minHeight: 44) + } + Button { + Task { await model.completeCurrent(segments: segments) } + } label: { + Label("Record set · start rest", systemImage: "checkmark") + } + .buttonStyle(ActionSlabStyle()) + .disabled(!canComplete) + .opacity(canComplete ? 1 : 0.48) + HStack(spacing: 12) { + Button("Do later") { Task { await model.deferCurrent() } } + .buttonStyle(.bordered) + Button("Add another set") { Task { await model.addExtraSet() } } + .buttonStyle(.bordered) + Spacer() + Button("Skip", role: .destructive) { Task { await model.skipCurrent() } } + .frame(minHeight: 44) + } + .font(.subheadline.weight(.semibold)) + } + .padding(20) + } + .background(SetlinePalette.paper) + } + + @ViewBuilder + private var actualInputs: some View { + VStack(alignment: .leading, spacing: 14) { + SectionLabel(text: "Recorded actuals") + switch step.kind { + case .strength: + HStack(spacing: 12) { + numericField("Weight", value: $weight, unit: "kg") + numericField("Reps", value: $repetitions, unit: "reps") + } + if hasDropSegment { + HStack(spacing: 12) { + numericField("Drop weight", value: $dropWeight, unit: "kg") + numericField("Drop reps", value: $dropRepetitions, unit: "reps") + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + case .repetitions, .mobility: + numericField("Repetitions", value: $repetitions, unit: "reps") + case .timed: + numericField("Duration", value: $duration, unit: "seconds") + case .cardio: + HStack(spacing: 12) { + numericField("Duration", value: $duration, unit: "minutes") + numericField("Distance", value: $distance, unit: "km") + } + } + } + } + + private func numericField(_ title: String, value: Binding, unit: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title).font(.caption.weight(.bold)) + HStack(alignment: .firstTextBaseline, spacing: 5) { + TextField("0", text: value) + .keyboardType(.decimalPad) + .font(.system(size: 30, weight: .black, design: .rounded).monospacedDigit()) + .accessibilityLabel(title) + Text(unit) + .font(.caption.weight(.bold)) + .foregroundStyle(SetlinePalette.ink.opacity(0.55)) + } + .padding(.horizontal, 12) + .frame(minHeight: 58) + .background(SetlinePalette.chalk) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .frame(maxWidth: .infinity) + } + + private var canComplete: Bool { + switch step.kind { + case .strength: Double(weight) != nil && Int(repetitions) != nil + case .repetitions, .mobility: Int(repetitions) != nil + case .timed: Int(duration) != nil + case .cardio: Int(duration) != nil || Double(distance) != nil + } + } + + private var segments: [SetSegment] { + var result = [SetSegment( + weight: Double(weight), + repetitions: Int(repetitions), + durationSeconds: durationSeconds, + distanceKilometres: Double(distance) + )] + if hasDropSegment, let dropWeight = Double(dropWeight), let dropRepetitions = Int(dropRepetitions) { + result.append(SetSegment(weight: dropWeight, repetitions: dropRepetitions)) + } + return result + } + + private var durationSeconds: Int? { + guard let value = Int(duration) else { return nil } + return step.kind == .cardio ? value * 60 : value + } +} + +private struct RestBoard: View { + @Environment(AppModel.self) private var model + let rest: RestState + let next: WorkoutStep? + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + let remaining = rest.remaining(at: context.date) + ScrollView { + VStack(alignment: .leading, spacing: 24) { + SectionLabel(text: remaining > 0 ? "Rest · wall clock" : "Rest target complete") + Text(TimeInterval(remaining).durationClock) + .font(.system(size: 78, weight: .black, design: .rounded).monospacedDigit()) + .tracking(-2) + .contentTransition(.numericText(countsDown: true)) + .accessibilityLabel("\(remaining) seconds remaining") + HStack(spacing: 10) { + Button("−15 sec") { Task { await model.adjustRest(by: -15) } } + Button("+15 sec") { Task { await model.adjustRest(by: 15) } } + Button("+30 sec") { Task { await model.adjustRest(by: 30) } } + } + .buttonStyle(.bordered) + .font(.subheadline.weight(.bold)) + .tint(SetlinePalette.ink) + if let next { + InkRule() + SectionLabel(text: "Next in authored order") + Text(next.exerciseName) + .font(.system(.title, design: .rounded, weight: .black)) + Text("\(next.label) · \(next.target)") + .font(.title3.weight(.semibold).monospacedDigit()) + Button(remaining > 0 ? "Start next early" : "Start next set") { + Task { await model.endRest() } + } + .buttonStyle(ActionSlabStyle()) + } + Text("Authored \(rest.authoredSeconds)s · adjusted \(rest.adjustedSeconds)s · actual \(rest.actual(at: context.date))s") + .font(.caption.monospacedDigit()) + .foregroundStyle(SetlinePalette.ink.opacity(0.62)) + } + .padding(24) + } + } + .background(SetlinePalette.lime) + } +} + +private struct SetRail: View { + let session: WorkoutSession + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + SectionLabel(text: "Session rail") + Spacer() + Text("Authored position retained") + .font(.caption2) + .foregroundStyle(.secondary) + } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 7) { + ForEach(Array(session.steps.enumerated()), id: \.element.id) { index, step in + VStack(alignment: .leading, spacing: 4) { + Text("\(step.authoredPosition + 1)") + .font(.caption2.monospacedDigit().weight(.black)) + Text(step.exerciseName) + .font(.caption.weight(.bold)) + .lineLimit(1) + Text(step.status == .planned && index == session.activeIndex ? "ACTIVE" : step.status.rawValue.uppercased()) + .font(.system(size: 9, weight: .black)) + } + .frame(width: 112, alignment: .leading) + .padding(9) + .background(railColor(step, index: index)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + .padding(12) + .background(SetlinePalette.chalk) + } + + private func railColor(_ step: WorkoutStep, index: Int) -> Color { + if index == session.activeIndex { return SetlinePalette.lime } + return switch step.status { + case .complete: SetlinePalette.blue + case .skipped: SetlinePalette.coral.opacity(0.5) + case .deferred: SetlinePalette.steel + case .planned: SetlinePalette.paper + } + } +} + +private extension TimeInterval { + var durationClock: String { + let total = max(0, Int(self)) + return String(format: "%02d:%02d", total / 60, total % 60) + } +} diff --git a/ios/Sources/SetlineCore/Domain.swift b/ios/Sources/SetlineCore/Domain.swift new file mode 100644 index 0000000..3de6c18 --- /dev/null +++ b/ios/Sources/SetlineCore/Domain.swift @@ -0,0 +1,497 @@ +import Foundation + +public enum ActivityKind: String, Codable, CaseIterable, Sendable { + case strength + case repetitions + case timed + case cardio + case mobility + + public var unitLabel: String { + switch self { + case .strength: "kg × reps" + case .repetitions: "reps" + case .timed: "seconds" + case .cardio: "minutes" + case .mobility: "dose" + } + } +} + +public enum StepStatus: String, Codable, Sendable { + case planned + case complete + case skipped + case deferred +} + +public struct SetSegment: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var weight: Double? + public var repetitions: Int? + public var durationSeconds: Int? + public var distanceKilometres: Double? + + public init( + id: UUID = UUID(), + weight: Double? = nil, + repetitions: Int? = nil, + durationSeconds: Int? = nil, + distanceKilometres: Double? = nil + ) { + self.id = id + self.weight = weight + self.repetitions = repetitions + self.durationSeconds = durationSeconds + self.distanceKilometres = distanceKilometres + } +} + +public struct PlannedSet: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var label: String + public var kind: ActivityKind + public var target: String + public var restSeconds: Int + + public init( + id: UUID = UUID(), + label: String, + kind: ActivityKind, + target: String, + restSeconds: Int + ) { + self.id = id + self.label = label + self.kind = kind + self.target = target + self.restSeconds = restSeconds + } +} + +public struct Exercise: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var name: String + public var cue: String + public var sets: [PlannedSet] + + public init(id: UUID = UUID(), name: String, cue: String, sets: [PlannedSet]) { + self.id = id + self.name = name + self.cue = cue + self.sets = sets + } +} + +public struct WorkoutTemplate: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var name: String + public var detail: String + public var isBundled: Bool + public var exercises: [Exercise] + + public init( + id: UUID = UUID(), + name: String, + detail: String, + isBundled: Bool, + exercises: [Exercise] + ) { + self.id = id + self.name = name + self.detail = detail + self.isBundled = isBundled + self.exercises = exercises + } +} + +public struct WorkoutStep: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var plannedSetID: UUID? + public var exerciseName: String + public var cue: String + public var label: String + public var kind: ActivityKind + public var target: String + public var authoredPosition: Int + public var restSeconds: Int + public var status: StepStatus + public var segments: [SetSegment] + public var isExtra: Bool + public var performedPosition: Int? + public var completedAt: Date? + + public init( + id: UUID = UUID(), + plannedSetID: UUID?, + exerciseName: String, + cue: String, + label: String, + kind: ActivityKind, + target: String, + authoredPosition: Int, + restSeconds: Int, + status: StepStatus = .planned, + segments: [SetSegment] = [], + isExtra: Bool = false, + performedPosition: Int? = nil, + completedAt: Date? = nil + ) { + self.id = id + self.plannedSetID = plannedSetID + self.exerciseName = exerciseName + self.cue = cue + self.label = label + self.kind = kind + self.target = target + self.authoredPosition = authoredPosition + self.restSeconds = restSeconds + self.status = status + self.segments = segments + self.isExtra = isExtra + self.performedPosition = performedPosition + self.completedAt = completedAt + } +} + +public struct RestState: Codable, Equatable, Sendable { + public var authoredSeconds: Int + public var adjustedSeconds: Int + public var startedAt: Date + public var endsAt: Date + + public init(authoredSeconds: Int, adjustedSeconds: Int, startedAt: Date, endsAt: Date) { + self.authoredSeconds = authoredSeconds + self.adjustedSeconds = adjustedSeconds + self.startedAt = startedAt + self.endsAt = endsAt + } + + public func remaining(at date: Date = .now) -> Int { + max(0, Int(ceil(endsAt.timeIntervalSince(date)))) + } + + public func actual(at date: Date = .now) -> Int { + max(0, Int(date.timeIntervalSince(startedAt))) + } +} + +public struct WorkoutSession: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var templateID: UUID + public var templateName: String + public var startedAt: Date + public var completedAt: Date? + public var steps: [WorkoutStep] + public var activeIndex: Int + public var rest: RestState? + + public init( + id: UUID = UUID(), + templateID: UUID, + templateName: String, + startedAt: Date, + completedAt: Date? = nil, + steps: [WorkoutStep], + activeIndex: Int = 0, + rest: RestState? = nil + ) { + self.id = id + self.templateID = templateID + self.templateName = templateName + self.startedAt = startedAt + self.completedAt = completedAt + self.steps = steps + self.activeIndex = activeIndex + self.rest = rest + } + + public var currentStep: WorkoutStep? { + guard steps.indices.contains(activeIndex) else { return nil } + return steps[activeIndex] + } + + public var completedCount: Int { + steps.count { $0.status == .complete } + } +} + +public struct ProgrammeDay: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var weekday: Int + public var templateID: UUID? + + public init(id: UUID = UUID(), weekday: Int, templateID: UUID?) { + self.id = id + self.weekday = weekday + self.templateID = templateID + } +} + +public struct CustomProgramme: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var name: String + public var weekCount: Int + public var enabled: Bool + public var days: [ProgrammeDay] + + public init( + id: UUID = UUID(), + name: String, + weekCount: Int, + enabled: Bool, + days: [ProgrammeDay] + ) { + self.id = id + self.name = name + self.weekCount = min(16, max(1, weekCount)) + self.enabled = enabled + self.days = days + } +} + +public enum SyncState: String, Codable, Sendable { + case deviceOnly + case pending + case synced + case conflict + case failed +} + +public struct SetlineDocument: Codable, Equatable, Sendable { + public var schemaVersion: Int + public var templates: [WorkoutTemplate] + public var programme: CustomProgramme? + public var activeSession: WorkoutSession? + public var history: [WorkoutSession] + public var syncState: SyncState + public var lastSyncedAt: Date? + + public init( + schemaVersion: Int = 1, + templates: [WorkoutTemplate] = [], + programme: CustomProgramme? = nil, + activeSession: WorkoutSession? = nil, + history: [WorkoutSession] = [], + syncState: SyncState = .deviceOnly, + lastSyncedAt: Date? = nil + ) { + self.schemaVersion = schemaVersion + self.templates = templates + self.programme = programme + self.activeSession = activeSession + self.history = history + self.syncState = syncState + self.lastSyncedAt = lastSyncedAt + } +} + +public extension SetlineDocument { + static var sample: SetlineDocument { + let lower = WorkoutTemplate( + name: "Lower strength", + detail: "Squat · hinge · carry", + isBundled: true, + exercises: [ + Exercise(name: "Front squat", cue: "Brace before the descent. Keep elbows tall.", sets: [ + PlannedSet(label: "Warm-up", kind: .strength, target: "40 kg × 8", restSeconds: 60), + PlannedSet(label: "Working 1", kind: .strength, target: "60 kg × 5", restSeconds: 150), + PlannedSet(label: "Working 2", kind: .strength, target: "60 kg × 5", restSeconds: 150), + PlannedSet(label: "Working 3", kind: .strength, target: "60 kg × 5", restSeconds: 150), + ]), + Exercise(name: "Romanian deadlift", cue: "Hips back. Keep the bar close.", sets: [ + PlannedSet(label: "Working 1", kind: .strength, target: "70 kg × 8", restSeconds: 120), + PlannedSet(label: "Working 2", kind: .strength, target: "70 kg × 8", restSeconds: 120), + PlannedSet(label: "Working 3", kind: .strength, target: "70 kg × 8", restSeconds: 120), + ]), + Exercise(name: "Suitcase carry", cue: "Walk tall without leaning.", sets: [ + PlannedSet(label: "Left", kind: .timed, target: "45 seconds", restSeconds: 45), + PlannedSet(label: "Right", kind: .timed, target: "45 seconds", restSeconds: 45), + ]), + ] + ) + let conditioning = WorkoutTemplate( + name: "Engine + mobility", + detail: "Intervals · hips · shoulders", + isBundled: true, + exercises: [ + Exercise(name: "Bike intervals", cue: "Strong, repeatable effort. Do not sprint the first round.", sets: [ + PlannedSet(label: "Round 1", kind: .cardio, target: "4 minutes", restSeconds: 90), + PlannedSet(label: "Round 2", kind: .cardio, target: "4 minutes", restSeconds: 90), + PlannedSet(label: "Round 3", kind: .cardio, target: "4 minutes", restSeconds: 90), + ]), + Exercise(name: "90/90 hip switches", cue: "Move slowly through the available range.", sets: [ + PlannedSet(label: "Mobility", kind: .mobility, target: "8 each side", restSeconds: 30), + ]), + ] + ) + return SetlineDocument( + templates: [lower, conditioning], + programme: CustomProgramme( + name: "Current block", + weekCount: 12, + enabled: true, + days: (1...7).map { day in + ProgrammeDay(weekday: day, templateID: day == 2 || day == 5 ? lower.id : (day == 4 ? conditioning.id : nil)) + } + ) + ) + } + + mutating func startWorkout(templateID: UUID, at date: Date = .now) throws { + guard activeSession == nil else { throw SetlineError.sessionAlreadyActive } + guard let template = templates.first(where: { $0.id == templateID }) else { + throw SetlineError.templateNotFound + } + var position = 0 + let steps = template.exercises.flatMap { exercise in + exercise.sets.map { planned in + defer { position += 1 } + return WorkoutStep( + plannedSetID: planned.id, + exerciseName: exercise.name, + cue: exercise.cue, + label: planned.label, + kind: planned.kind, + target: planned.target, + authoredPosition: position, + restSeconds: planned.restSeconds + ) + } + } + activeSession = WorkoutSession( + templateID: template.id, + templateName: template.name, + startedAt: date, + steps: steps + ) + } + + mutating func completeCurrent(with segments: [SetSegment], at date: Date = .now) throws { + guard var session = activeSession, session.steps.indices.contains(session.activeIndex) else { + throw SetlineError.noActiveStep + } + session.steps[session.activeIndex].segments = segments + session.steps[session.activeIndex].status = .complete + session.steps[session.activeIndex].completedAt = date + session.steps[session.activeIndex].performedPosition = session.completedCount + let authoredRest = session.steps[session.activeIndex].restSeconds + session.activeIndex = nextPendingIndex(in: session.steps, after: session.activeIndex) ?? session.steps.count + if session.activeIndex < session.steps.count && authoredRest > 0 { + session.rest = RestState( + authoredSeconds: authoredRest, + adjustedSeconds: authoredRest, + startedAt: date, + endsAt: date.addingTimeInterval(TimeInterval(authoredRest)) + ) + } else { + session.rest = nil + } + activeSession = session + } + + mutating func skipCurrent() throws { + guard var session = activeSession, session.steps.indices.contains(session.activeIndex) else { + throw SetlineError.noActiveStep + } + session.steps[session.activeIndex].status = .skipped + session.activeIndex = nextPendingIndex(in: session.steps, after: session.activeIndex) ?? session.steps.count + session.rest = nil + activeSession = session + } + + mutating func deferCurrent() throws { + guard var session = activeSession, session.steps.indices.contains(session.activeIndex) else { + throw SetlineError.noActiveStep + } + var step = session.steps.remove(at: session.activeIndex) + step.status = .deferred + session.steps.append(step) + if session.activeIndex >= session.steps.count { session.activeIndex = max(0, session.steps.count - 1) } + session.rest = nil + activeSession = session + } + + mutating func addExtraSet() throws { + guard var session = activeSession, let current = session.currentStep else { + throw SetlineError.noActiveStep + } + let extra = WorkoutStep( + plannedSetID: nil, + exerciseName: current.exerciseName, + cue: current.cue, + label: "Extra set", + kind: current.kind, + target: current.target, + authoredPosition: current.authoredPosition, + restSeconds: current.restSeconds, + isExtra: true + ) + session.steps.insert(extra, at: min(session.activeIndex + 1, session.steps.count)) + activeSession = session + } + + mutating func adjustRest(by seconds: Int, at date: Date = .now) { + guard var session = activeSession, var rest = session.rest else { return } + rest.adjustedSeconds = max(0, rest.adjustedSeconds + seconds) + rest.endsAt = rest.startedAt.addingTimeInterval(TimeInterval(rest.adjustedSeconds)) + session.rest = rest + activeSession = session + } + + mutating func endRest() { + activeSession?.rest = nil + } + + mutating func finishWorkout(at date: Date = .now) throws { + guard var session = activeSession else { throw SetlineError.noActiveSession } + for index in session.steps.indices where session.steps[index].status == .planned || session.steps[index].status == .deferred { + session.steps[index].status = .skipped + } + session.completedAt = date + session.rest = nil + history.insert(session, at: 0) + activeSession = nil + } + + mutating func duplicateTemplate(_ id: UUID) throws { + guard var copy = templates.first(where: { $0.id == id }) else { throw SetlineError.templateNotFound } + copy.id = UUID() + copy.name += " copy" + copy.isBundled = false + copy.exercises = copy.exercises.map { exercise in + var next = exercise + next.id = UUID() + next.sets = next.sets.map { set in + var nextSet = set + nextSet.id = UUID() + return nextSet + } + return next + } + templates.append(copy) + } + + private func nextPendingIndex(in steps: [WorkoutStep], after index: Int) -> Int? { + guard index + 1 < steps.count else { return nil } + return steps.indices[(index + 1)...].first { steps[$0].status == .planned || steps[$0].status == .deferred } + } +} + +public enum SetlineError: LocalizedError, Equatable { + case templateNotFound + case sessionAlreadyActive + case noActiveSession + case noActiveStep + case unsupportedSchema(Int) + + public var errorDescription: String? { + switch self { + case .templateNotFound: "That workout is no longer available." + case .sessionAlreadyActive: "Finish the active workout before starting another." + case .noActiveSession: "There is no active workout." + case .noActiveStep: "There is no remaining set to record." + case let .unsupportedSchema(version): "This Setline data uses unsupported version \(version)." + } + } +} diff --git a/ios/Sources/SetlineCore/Persistence.swift b/ios/Sources/SetlineCore/Persistence.swift new file mode 100644 index 0000000..46022aa --- /dev/null +++ b/ios/Sources/SetlineCore/Persistence.swift @@ -0,0 +1,68 @@ +import Foundation + +public actor SetlineStore { + public let fileURL: URL + + public init(fileURL: URL? = nil) { + if let fileURL { + self.fileURL = fileURL + } else { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + self.fileURL = base.appending(path: "Setline", directoryHint: .isDirectory) + .appending(path: "setline-v1.json") + } + } + + public func load() throws -> SetlineDocument { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return .sample + } + let data = try Data(contentsOf: fileURL) + let document = try Self.decoder.decode(SetlineDocument.self, from: data) + guard document.schemaVersion == 1 else { + throw SetlineError.unsupportedSchema(document.schemaVersion) + } + return document + } + + public func save(_ document: SetlineDocument) throws { + let directory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try Self.encoder.encode(document) + try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUnlessOpen]) + } + + public func export(_ document: SetlineDocument) throws -> Data { + try Self.encoder.encode(document) + } + + public func previewImport(_ data: Data) throws -> SetlineDocument { + let document = try Self.decoder.decode(SetlineDocument.self, from: data) + guard document.schemaVersion == 1 else { + throw SetlineError.unsupportedSchema(document.schemaVersion) + } + return document + } + + public func replace(with document: SetlineDocument) throws { + try save(document) + } + + public func reset() throws { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } + try FileManager.default.removeItem(at: fileURL) + } + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() +} diff --git a/ios/Sources/SetlineCore/Progression.swift b/ios/Sources/SetlineCore/Progression.swift new file mode 100644 index 0000000..bf40c51 --- /dev/null +++ b/ios/Sources/SetlineCore/Progression.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct ProgressionRecommendation: Equatable, Sendable { + public var exerciseName: String + public var previousWeight: Double + public var recommendedWeight: Double + public var rationale: String + + public init(exerciseName: String, previousWeight: Double, recommendedWeight: Double, rationale: String) { + self.exerciseName = exerciseName + self.previousWeight = previousWeight + self.recommendedWeight = recommendedWeight + self.rationale = rationale + } +} + +public enum ProgressionEngine { + public static func recommendation(for exercise: String, history: [WorkoutSession]) -> ProgressionRecommendation? { + let completed = history + .flatMap(\.steps) + .filter { $0.exerciseName == exercise && $0.status == .complete && $0.kind == .strength } + guard completed.count >= 2 else { return nil } + let recent = completed.prefix(3) + let weights = recent.compactMap { $0.segments.first?.weight } + guard weights.count == recent.count, let latest = weights.first, Set(weights).count == 1 else { return nil } + return ProgressionRecommendation( + exerciseName: exercise, + previousWeight: latest, + recommendedWeight: latest + 2.5, + rationale: "The last \(weights.count) comparable working sets were completed at \(latest.formatted()) kg. This suggestion applies to this session only." + ) + } +} diff --git a/ios/Tests/SetlineCoreTests/SetlineCoreTests.swift b/ios/Tests/SetlineCoreTests/SetlineCoreTests.swift new file mode 100644 index 0000000..19dbad7 --- /dev/null +++ b/ios/Tests/SetlineCoreTests/SetlineCoreTests.swift @@ -0,0 +1,126 @@ +import XCTest +@testable import SetlineCore + +final class SetlineCoreTests: XCTestCase { + func testSessionSnapshotKeepsAuthoredOrderWhenTemplateChanges() throws { + var document = SetlineDocument.sample + let templateID = try XCTUnwrap(document.templates.first?.id) + let authoredNames = try XCTUnwrap(document.templates.first).exercises.flatMap { exercise in + exercise.sets.map { _ in exercise.name } + } + + try document.startWorkout(templateID: templateID, at: Date(timeIntervalSince1970: 100)) + document.templates[0].exercises[0].name = "Changed later" + + XCTAssertEqual(document.activeSession?.steps.map(\.exerciseName), authoredNames) + } + + func testDropSegmentsRemainOrderedInsideOnePlannedSet() throws { + var document = SetlineDocument.sample + let templateID = try XCTUnwrap(document.templates.first?.id) + try document.startWorkout(templateID: templateID) + + let segments = [ + SetSegment(weight: 60, repetitions: 5), + SetSegment(weight: 50, repetitions: 3), + ] + try document.completeCurrent(with: segments, at: Date(timeIntervalSince1970: 500)) + + XCTAssertEqual(document.activeSession?.steps.first?.segments, segments) + XCTAssertEqual(document.activeSession?.steps.first?.status, .complete) + } + + func testDeferringChangesQueueButRetainsAuthoredPosition() throws { + var document = SetlineDocument.sample + let templateID = try XCTUnwrap(document.templates.first?.id) + try document.startWorkout(templateID: templateID) + let firstID = try XCTUnwrap(document.activeSession?.steps.first?.id) + + try document.deferCurrent() + + XCTAssertEqual(document.activeSession?.steps.last?.id, firstID) + XCTAssertEqual(document.activeSession?.steps.last?.authoredPosition, 0) + XCTAssertEqual(document.activeSession?.steps.last?.status, .deferred) + } + + func testExtraSetDoesNotModifyTemplate() throws { + var document = SetlineDocument.sample + let template = try XCTUnwrap(document.templates.first) + let originalSetCount = template.exercises.flatMap(\.sets).count + try document.startWorkout(templateID: template.id) + + try document.addExtraSet() + + XCTAssertEqual(document.activeSession?.steps.count, originalSetCount + 1) + XCTAssertEqual(document.templates.first?.exercises.flatMap(\.sets).count, originalSetCount) + XCTAssertTrue(document.activeSession?.steps[1].isExtra == true) + } + + func testRestUsesWallClockTimestamps() throws { + var document = SetlineDocument.sample + let templateID = try XCTUnwrap(document.templates.first?.id) + let start = Date(timeIntervalSince1970: 1_000) + try document.startWorkout(templateID: templateID, at: start) + try document.completeCurrent( + with: [SetSegment(weight: 40, repetitions: 8)], + at: start + ) + let rest = try XCTUnwrap(document.activeSession?.rest) + + XCTAssertEqual(rest.remaining(at: start.addingTimeInterval(17)), rest.adjustedSeconds - 17) + XCTAssertEqual(rest.actual(at: start.addingTimeInterval(31)), 31) + } + + func testPersistenceRestoresActiveSession() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let url = directory.appending(path: "setline.json") + let store = SetlineStore(fileURL: url) + var document = SetlineDocument.sample + let templateID = try XCTUnwrap(document.templates.first?.id) + let timestamp = Date(timeIntervalSince1970: 10_000) + try document.startWorkout(templateID: templateID, at: timestamp) + try document.completeCurrent(with: [SetSegment(weight: 40, repetitions: 8)], at: timestamp) + + try await store.save(document) + let restored = try await store.load() + + XCTAssertEqual(restored, document) + XCTAssertNotNil(restored.activeSession?.rest) + } + + func testDuplicateTemplateIsIndependent() throws { + var document = SetlineDocument.sample + let original = try XCTUnwrap(document.templates.first) + try document.duplicateTemplate(original.id) + + let copy = try XCTUnwrap(document.templates.last) + XCTAssertNotEqual(copy.id, original.id) + XCTAssertNotEqual(copy.exercises.first?.id, original.exercises.first?.id) + XCTAssertFalse(copy.isBundled) + } + + func testProgressionRequiresComparableEvidence() { + let step = WorkoutStep( + plannedSetID: UUID(), + exerciseName: "Front squat", + cue: "", + label: "Working", + kind: .strength, + target: "60 kg × 5", + authoredPosition: 0, + restSeconds: 120, + status: .complete, + segments: [SetSegment(weight: 60, repetitions: 5)] + ) + let history = [ + WorkoutSession(templateID: UUID(), templateName: "A", startedAt: .now, completedAt: .now, steps: [step]), + WorkoutSession(templateID: UUID(), templateName: "A", startedAt: .now, completedAt: .now, steps: [step]), + ] + + let recommendation = ProgressionEngine.recommendation(for: "Front squat", history: history) + + XCTAssertEqual(recommendation?.previousWeight, 60) + XCTAssertEqual(recommendation?.recommendedWeight, 62.5) + XCTAssertTrue(recommendation?.rationale.contains("session only") == true) + } +} diff --git a/ios/Tests/SetlineUITests/SetlineUITests.swift b/ios/Tests/SetlineUITests/SetlineUITests.swift new file mode 100644 index 0000000..6627755 --- /dev/null +++ b/ios/Tests/SetlineUITests/SetlineUITests.swift @@ -0,0 +1,54 @@ +import XCTest + +@MainActor +final class SetlineUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testStartsWorkoutAndShowsTimestampRest() { + let app = XCUIApplication() + app.launchArguments = ["--fresh-demo"] + app.launch() + + XCTAssertTrue(app.staticTexts["Follow the plan.\nRecord the truth."].waitForExistence(timeout: 3)) + app.buttons["Start workout"].tap() + XCTAssertTrue(app.staticTexts["Front squat"].waitForExistence(timeout: 3)) + + let weight = app.textFields["Weight"] + weight.tap() + weight.typeText("40") + let reps = app.textFields["Reps"] + reps.tap() + reps.typeText("8") + app.buttons["Record set · start rest"].tap() + + XCTAssertTrue(app.staticTexts["REST · WALL CLOCK"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["Start next early"].exists) + } + + func testCoreTabsAreReachable() { + let app = XCUIApplication() + app.launchArguments = ["--fresh-demo"] + app.launch() + + for tab in ["Plan", "History", "You"] { + app.tabBars.buttons[tab].tap() + XCTAssertTrue(app.staticTexts[tab].waitForExistence(timeout: 2)) + } + } + + func testPlanOffersNativeTemplateAuthoring() { + let app = XCUIApplication() + app.launchArguments = ["--fresh-demo"] + app.launch() + + app.tabBars.buttons["Plan"].tap() + let newTemplate = app.buttons["New template"] + XCTAssertTrue(newTemplate.waitForExistence(timeout: 3)) + newTemplate.tap() + XCTAssertTrue(app.navigationBars["New template"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.textFields["Name"].exists) + XCTAssertTrue(app.buttons["Add exercise"].exists) + } +} diff --git a/ios/artifacts/simulator/accessibility-xl.png b/ios/artifacts/simulator/accessibility-xl.png new file mode 100644 index 0000000..7b63f90 Binary files /dev/null and b/ios/artifacts/simulator/accessibility-xl.png differ diff --git a/ios/artifacts/simulator/history.png b/ios/artifacts/simulator/history.png new file mode 100644 index 0000000..6c0ffc5 Binary files /dev/null and b/ios/artifacts/simulator/history.png differ diff --git a/ios/artifacts/simulator/plan.png b/ios/artifacts/simulator/plan.png new file mode 100644 index 0000000..7f2a8ba Binary files /dev/null and b/ios/artifacts/simulator/plan.png differ diff --git a/ios/artifacts/simulator/rest-timer.png b/ios/artifacts/simulator/rest-timer.png new file mode 100644 index 0000000..24c1371 Binary files /dev/null and b/ios/artifacts/simulator/rest-timer.png differ diff --git a/ios/artifacts/simulator/today.png b/ios/artifacts/simulator/today.png new file mode 100644 index 0000000..347cf68 Binary files /dev/null and b/ios/artifacts/simulator/today.png differ diff --git a/ios/artifacts/simulator/workout-player.png b/ios/artifacts/simulator/workout-player.png new file mode 100644 index 0000000..c081a6b Binary files /dev/null and b/ios/artifacts/simulator/workout-player.png differ diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..9912916 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,88 @@ +name: Setline +options: + minimumXcodeGenVersion: 2.42.0 + deploymentTarget: + iOS: "17.0" + generateEmptyDirectories: true +configs: + Debug: debug + Release: release +settings: + base: + SWIFT_VERSION: 6.0 + DEVELOPMENT_TEAM: 8F7LXHTJZR + CODE_SIGN_STYLE: Automatic + TARGETED_DEVICE_FAMILY: "1" + CURRENT_PROJECT_VERSION: 1 + MARKETING_VERSION: 1.0.0 + ENABLE_USER_SCRIPT_SANDBOXING: YES +targets: + SetlineCore: + type: framework + platform: iOS + sources: Sources/SetlineCore + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.significanthobbies.setline.core + DEFINES_MODULE: YES + SKIP_INSTALL: YES + GENERATE_INFOPLIST_FILE: YES + Setline: + type: application + platform: iOS + sources: + - path: Sources/Setline + - path: Sources/Setline/Resources + buildPhase: resources + dependencies: + - target: SetlineCore + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.significanthobbies.setline + PRODUCT_NAME: Setline + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_CFBundleDisplayName: Setline + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO + INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.healthcare-fitness + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleDarkContent + INFOPLIST_KEY_UISupportedInterfaceOrientations: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight" + SUPPORTS_MACCATALYST: NO + SetlineCoreTests: + type: bundle.unit-test + platform: iOS + sources: Tests/SetlineCoreTests + dependencies: + - target: SetlineCore + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.significanthobbies.setline.core-tests + GENERATE_INFOPLIST_FILE: YES + SetlineUITests: + type: bundle.ui-testing + platform: iOS + sources: Tests/SetlineUITests + dependencies: + - target: Setline + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.significanthobbies.setline.ui-tests + GENERATE_INFOPLIST_FILE: YES +schemes: + Setline: + build: + targets: + Setline: all + SetlineCoreTests: [test] + SetlineUITests: [test] + test: + targets: + - SetlineCoreTests + - SetlineUITests + gatherCoverageData: true + run: + config: Debug + archive: + config: Release diff --git a/ios/scripts/archive.sh b/ios/scripts/archive.sh new file mode 100755 index 0000000..af893bd --- /dev/null +++ b/ios/scripts/archive.sh @@ -0,0 +1,34 @@ +#!/bin/zsh + +set -euo pipefail + +script_dir="${0:A:h}" +project_root="${script_dir:h}" +personal_team="8F7LXHTJZR" +development_team="${SETLINE_DEVELOPMENT_TEAM:-$personal_team}" +archive_path="${SETLINE_ARCHIVE_PATH:-$project_root/build/Setline.xcarchive}" +allow_updates="${SETLINE_ALLOW_PROVISIONING_UPDATES:-NO}" + +if [[ "$development_team" != "$personal_team" ]]; then + print -u2 "Refusing to archive: Setline is locked to personal team $personal_team." + exit 3 +fi + +cd "$project_root" +xcodegen generate +arguments=() +if [[ "$allow_updates" == "YES" ]]; then + arguments=(-allowProvisioningUpdates) +fi +xcodebuild archive \ + -project Setline.xcodeproj \ + -scheme Setline \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath "$archive_path" \ + "${arguments[@]}" \ + DEVELOPMENT_TEAM="$development_team" + +codesign --verify --deep --strict "$archive_path/Products/Applications/Setline.app" +print "Created and verified local archive at $archive_path" +print "No upload or App Store Connect operation was performed." diff --git a/ios/scripts/check.sh b/ios/scripts/check.sh new file mode 100755 index 0000000..1d339f7 --- /dev/null +++ b/ios/scripts/check.sh @@ -0,0 +1,25 @@ +#!/bin/zsh + +set -euo pipefail + +script_dir="${0:A:h}" +project_root="${script_dir:h}" +destination="${SETLINE_SIMULATOR_DESTINATION:-platform=iOS Simulator,id=38FDB30B-69F2-406E-A253-17183F2348BE}" +derived_data="${SETLINE_DERIVED_DATA:-/private/tmp/setline-ios-derived}" + +cd "$project_root" +xcodegen generate +xcodebuild \ + -project Setline.xcodeproj \ + -scheme Setline \ + -destination "$destination" \ + -derivedDataPath "$derived_data" \ + test +xcodebuild \ + -project Setline.xcodeproj \ + -scheme Setline \ + -configuration Release \ + -destination "$destination" \ + -derivedDataPath "$derived_data" \ + CODE_SIGNING_ALLOWED=NO \ + build diff --git a/openspec/changes/add-native-ios-app/.openspec.yaml b/openspec/changes/add-native-ios-app/.openspec.yaml new file mode 100644 index 0000000..d7bc011 --- /dev/null +++ b/openspec/changes/add-native-ios-app/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-10 diff --git a/openspec/changes/add-native-ios-app/design.md b/openspec/changes/add-native-ios-app/design.md new file mode 100644 index 0000000..1e58682 --- /dev/null +++ b/openspec/changes/add-native-ios-app/design.md @@ -0,0 +1,61 @@ +## Context + +See `proposal.md` for motivation and `specs/native-ios-client/spec.md` for the behavior contract. Setline currently has a TypeScript web client, a device-first whole-state model, and optional Google-backed synchronization. The native client must coexist with that surface, preserve exact workout order, and remain usable without network access. The owner selected Swift, simulator-first validation, personal Apple signing, and Apple-native tooling. + +## Goals / Non-Goals + +**Goals:** + +- Build a maintainable SwiftUI iPhone app with a testable domain layer and no third-party runtime packages. +- Preserve Setline's authored-versus-recorded distinctions and make every active-workout mutation durable immediately. +- Keep local and remote state boundaries explicit enough to add or adjust server adapters without rewriting screens. +- Produce a locally signed archive and complete metadata/privacy checklist without uploading it. + +**Non-Goals:** + +- Replacing or embedding the web client. +- Adding coaching, AI, HealthKit, sensors, social features, or a Fleet-wide hub. +- Publishing, monetization, App Store Connect setup, or production service changes. + +## Decisions + +### Native Apple stack with a generated, checked-in Xcode project + +The app uses SwiftUI, Observation, Foundation, URLSession, UserNotifications, UniformTypeIdentifiers, XCTest, and OSLog. A small `project.yml` keeps the project reproducible; the generated `.xcodeproj` is checked in so everyday work happens entirely in Xcode. This avoids runtime dependencies and preserves the owner's preference for Apple's frameworks. Hand-maintaining a large project file was rejected as fragile. + +### Codable domain document behind an actor + +The complete local product state is a versioned Codable document written atomically in Application Support by an actor. Active-session checkpoints occur after every meaningful action. This matches Setline's existing whole-state export/sync semantics and keeps offline recovery deterministic. SwiftData was considered, but a relational migration layer adds complexity without improving the current whole-state contract. + +### Feature slices over screen-owned state + +Domain types and operations live in `SetlineCore`; the app target owns SwiftUI composition. Screens observe one app model and call explicit domain operations. Timers store end timestamps rather than decrementing persisted counters. This keeps tests independent of the simulator and prevents views from becoming the source of truth. + +### Existing service contract through native web authentication + +Optional account connection uses `ASWebAuthenticationSession` and URLSession against existing Setline endpoints. Credentials are stored only in Keychain. The remote adapter exchanges versioned whole-state payloads and never performs per-set merging or silent template rewrites. No secrets or environment files are embedded. + +### Preserve-mode visual adaptation + +The native app inherits `DESIGN.md`: chalk/paper daylight surfaces, ink structure, lime execution signals, tabular numerals, Scoreboard Split hierarchy, sparse rounding, and one dominant action. Native navigation, sheets, focus, Dynamic Type, VoiceOver, and Reduce Motion behavior take precedence where platform conventions improve operation. + +### Release boundary is local archive + +Bundle identifier is `com.significanthobbies.setline`; version starts at `1.0.0` build `1`; minimum deployment is iOS 17. The archive script accepts a team override but defaults to personal team `8F7LXHTJZR`, creates an archive outside source control, verifies its signature, and has no upload command. + +## Risks / Trade-offs + +- [Existing API assumptions can drift] → Isolate remote DTOs, add fixture contract tests, and keep offline behavior independent. +- [Whole-state sync can overwrite newer work] → Show last-sync provenance, require explicit conflict choice, and never auto-replace a dirty local document. +- [Long workouts can expose persistence races] → Serialize mutations through one actor and test relaunch recovery at each session phase. +- [Simulator cannot validate physical gym ergonomics or notifications] → Record those as device-only release checks rather than claiming completion. + +## Migration Plan + +1. Add the iOS project without changing web code or production services. +2. Seed bundled programme data from a versioned native resource. +3. Validate local-only parity and recovery with unit/UI tests and simulator evidence. +4. Validate optional contract fixtures without production writes. +5. Create a personal-team archive and stop before App Store Connect. + +Removing `ios/` rolls back the change without affecting web users or stored server data. diff --git a/openspec/changes/add-native-ios-app/proposal.md b/openspec/changes/add-native-ios-app/proposal.md new file mode 100644 index 0000000..b78d6fc --- /dev/null +++ b/openspec/changes/add-native-ios-app/proposal.md @@ -0,0 +1,26 @@ +## Why + +Setline is designed around one-handed workout execution, but its only maintained client is a web application. A native iPhone client can make active workouts, timers, records, and offline recovery feel dependable in the gym while preserving Setline's exact authored-session semantics. + +## What Changes + +- Add a first-party SwiftUI iPhone application using Apple frameworks and no new production dependencies. +- Match the current Setline product surface: schedule, workout playback, substitutions, rest timing, records, templates, history, analytics, settings, data transfer, and account controls. +- Keep active-workout state available offline and recover interrupted sessions without reordering authored exercises. +- Reuse existing Setline API and synchronization contracts where available; retain a useful local-only path when signed out. +- Add native tests, privacy metadata, app metadata, icons, simulator verification, and a signed archive workflow that stops before upload. +- Keep the web application intact and do not add a unified Fleet hub. + +## Capabilities + +### New Capabilities + +- `native-ios-client`: Native iPhone behavior, parity requirements, local-first recovery, synchronization boundaries, accessibility, and submission preparation. + +### Modified Capabilities + +None. + +## Impact + +Adds an `ios/` Swift/Xcode surface beside the existing web application. Existing web routes, API behavior, data formats, dependencies, and production deployment remain unchanged. The iOS app uses the personal Apple development team for local signing and produces no App Store Connect records or uploads. diff --git a/openspec/changes/add-native-ios-app/specs/native-ios-client/spec.md b/openspec/changes/add-native-ios-app/specs/native-ios-client/spec.md new file mode 100644 index 0000000..0f78cca --- /dev/null +++ b/openspec/changes/add-native-ios-app/specs/native-ios-client/spec.md @@ -0,0 +1,61 @@ +## Purpose + +Defines the observable behavior of Setline's native iPhone client, including workout parity, offline execution, synchronization boundaries, accessibility, and submission preparation. + +## ADDED Requirements + +### Requirement: Native workout execution preserves authored order +The iOS client SHALL start every workout from an immutable snapshot of the selected bundled or custom template and SHALL present exercises and planned sets in their authored order. It SHALL distinguish planned, completed, skipped, deferred, modified, and session-only extra work without rewriting the source template. + +#### Scenario: User changes the active session +- **WHEN** the user skips a set, adds a session-only set, or moves the current step to Do later +- **THEN** the client records the deviation in the session while future workouts retain the authored template + +### Requirement: Activity-specific recording is available +The iOS client SHALL support weight-and-repetition sets including ordered drop segments, repetition-only work, timed work, cardio duration or distance, and mobility doses. Recorded values SHALL remain distinct from authored targets and calculated values. + +#### Scenario: User records a drop set +- **WHEN** the user records multiple ordered weight-and-repetition segments for one planned set +- **THEN** the client saves and displays every segment in order as one completed planned set + +### Requirement: Rest cadence uses wall-clock timestamps +The iOS client SHALL retain the authored rest target, user adjustment, completion timestamp, next-step timestamp, and actual elapsed rest as separate values. + +#### Scenario: App is backgrounded during rest +- **WHEN** the user backgrounds and later returns to the app during an active rest period +- **THEN** the displayed countdown and actual rest derive from timestamps rather than paused process time + +### Requirement: Active workouts work offline and recover +Starting, recording, navigating, timing, and completing an active workout SHALL not require network access. An interrupted active workout SHALL restore its current position, recorded values, and running rest state after relaunch. + +#### Scenario: Device loses connectivity mid-workout +- **WHEN** connectivity is unavailable after a workout starts +- **THEN** all workout actions remain usable and the completed session is retained locally + +### Requirement: Native client provides current Setline planning and history surfaces +The iOS client SHALL provide Today and weekly schedule views, bundled and custom templates, bounded custom-programme scheduling, workout history, record detail, planned-versus-actual ledgers, basic progress summaries, and deterministic session-only progression recommendations requiring explicit user acceptance. + +#### Scenario: User reviews a prior workout +- **WHEN** the user opens a completed session from History +- **THEN** the client shows its authored snapshot, recorded results, deviations, rest evidence, and provenance labels + +### Requirement: Local data transfer and account controls are present +The iOS client SHALL support versioned whole-state export, preview-before-replace import, local data reset, optional account connection using existing Setline service contracts, synchronization status, sign out, and account deletion where the service supports it. + +#### Scenario: User previews an import +- **WHEN** the user selects a compatible Setline export +- **THEN** the client validates and summarizes the replacement before requiring explicit confirmation + +### Requirement: The native experience is accessible and honest +The iOS client SHALL support Dynamic Type, VoiceOver labels and values, Reduce Motion, sufficient contrast, 44-point targets, and status cues that do not rely on color alone. Unsupported sensor or health values SHALL be omitted or labelled unavailable rather than estimated. + +#### Scenario: User enables accessibility settings +- **WHEN** the user enables a larger accessibility text size and Reduce Motion +- **THEN** primary workout actions remain reachable and state changes remain understandable without nonessential animation + +### Requirement: Submission preparation stops before publication +The repository SHALL include privacy metadata, app icons, version/build configuration, support and privacy links or copy, automated tests, a simulator verification path, and a personal-team archive path. Preparation SHALL not create or modify App Store Connect records or upload a build. + +#### Scenario: Maintainer prepares a release candidate +- **WHEN** the documented release checks and archive command complete +- **THEN** a locally verifiable archive and metadata checklist exist with publication left as a later manual action diff --git a/openspec/changes/add-native-ios-app/tasks.md b/openspec/changes/add-native-ios-app/tasks.md new file mode 100644 index 0000000..2a14aa0 --- /dev/null +++ b/openspec/changes/add-native-ios-app/tasks.md @@ -0,0 +1,25 @@ +## 1. Native Project Foundation + +- [x] 1.1 Add the SwiftUI app, core library, unit-test, and UI-test targets with a checked-in Xcode project +- [x] 1.2 Add bundle/version settings, privacy manifest, complete app icon set, launch treatment, and personal-team archive script +- [x] 1.3 Add versioned Codable domain models, atomic local persistence, fixture programme data, and recovery tests + +## 2. Workout Execution + +- [x] 2.1 Implement immutable session snapshots, activity-specific recording, drop segments, skip, extra set, and Do later operations +- [x] 2.2 Implement timestamp-derived rest state, background/relaunch recovery, and completion summaries +- [x] 2.3 Build the polished Scoreboard Split workout UI with accessible actuals, action slab, rest board, and set rail +- [x] 2.4 Add unit and UI tests for authored order, deviations, offline actions, timer recovery, and completion + +## 3. Planning And Records + +- [x] 3.1 Build Today, weekly schedule, bundled/custom templates, duplicate/edit, and bounded programme scheduling +- [x] 3.2 Build History, detailed execution ledger, progress summaries, and deterministic progression recommendations +- [x] 3.3 Add versioned export, import preview/replace, and local reset + +## 4. Accounts And Quality + +- [ ] 4.1 Add isolated native authentication, Keychain session storage, whole-state sync status, conflicts, sign out, and deletion controls +- [x] 4.2 Complete Dynamic Type, VoiceOver, Reduce Motion, contrast, empty/error/loading states, and native polish review +- [x] 4.3 Add release metadata, privacy/support copy, simulator screenshots, and documented device-only checks +- [x] 4.4 Run strict OpenSpec validation, tests, Release simulator build, personal-team archive, and signature verification without upload