64 Commits

Author SHA1 Message Date
7d9a89e637 fix(ci): correct derived data path in install step
All checks were successful
PR → Build → WiFi/USB Deploy → LGTM / Build & Deploy to iPhone (WiFi/USB) (pull_request) Successful in 5m31s
PR → Build → WiFi/USB Deploy → LGTM / Wait for LGTM comment (pull_request) Successful in 1m3s
2026-06-28 09:22:43 +00:00
97b61d3afb fix(ci): switch from Firebase OTA to Xcode WiFi/USB direct deploy
Some checks failed
PR → Build → WiFi/USB Deploy → LGTM / Build & Deploy to iPhone (WiFi/USB) (pull_request) Failing after 2m59s
PR → Build → WiFi/USB Deploy → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-28 09:14:51 +00:00
a9eda61967 Merge pull request 'ci: add iPhone deploy workflow + CI/CD setup docs' (#4) from ci/iphone-deploy-workflow into main 2026-06-27 22:49:52 +02:00
a382ad62dc fix: move #if outside array literal in TabataGoComplication.supportedFamilies
All checks were successful
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Successful in 4m59s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Successful in 4m5s
2026-06-27 20:40:39 +00:00
069b7d417f fix(ci): keep SPM cache — remove aggressive clean that forces RevenueCat re-clone
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 2m13s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 20:35:47 +00:00
63fbae3698 fix(ci): restore automatic code signing — Apple account now configured on runner
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 11m7s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 20:28:01 +00:00
4b7548501a fix(ci): disable code signing — Mac runner has no Apple Developer account
Some checks failed
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been cancelled
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Has been cancelled
STOPGAP: CODE_SIGN_IDENTITY="-" CODE_SIGNING_REQUIRED=NO to bypass
signing on the Mac CI runner which has no Apple account signed into Xcode.

REAL FIX: Sign into Xcode → Settings → Accounts with the Apple Developer
account on the Mac runner machine. Then -allowProvisioningUpdates +
DEVELOPMENT_TEAM will auto-provision profiles for any bundle IDs.
2026-06-27 20:23:56 +00:00
5079de0fb1 fix(ci): checkout PR branch instead of main
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 6m12s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 20:13:28 +00:00
52346fb4ac fix: align bundleIdPrefix with Firebase app (com.tabatago → fr.millianlmx.tabatago)
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 10m58s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 20:00:35 +00:00
abf926e4bc fix(ci): remove -derivedDataPath from resolve step (requires -scheme)
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 8m40s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 08:42:41 +00:00
7c50282284 fix(ci): clean SPM/Xcode caches agressivement pour éviter les segfaults linker après changement de bundle ID
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 2m4s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
- Supprime TOUT DerivedData (pas seulement TabataGo-*) car le changement
  de bundle ID (com.tabatago.app → fr.millianlmx.tabatago) a rendu les
  anciens caches SPM incohérents
- Ajoute la suppression de ~/Library/org.swift.swiftpm, ~/.swiftpm,
  ~/Library/Caches/com.apple.dt.Xcode et du ModuleCache
- Supprime les build/ locaux et .build avant xcodegen generate
- Supprime les Package.resolved stale
- Utilise -derivedDataPath et -clonedSourcePackagesDirPath cohérents
  entre la résolution SPM et le build
2026-06-27 08:38:27 +00:00
6cc25300d1 fix: change bundle ID from com.tabatago.app to fr.millianlmx.tabatago
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 4m2s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 08:29:33 +00:00
09e77b477c fix(ci): switch to --token auth and add --project flag for Firebase deploy
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 9m15s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 08:00:57 +00:00
5c73d6bf29 fix(ci): use npx firebase to avoid PATH issues with npm global install
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 8m42s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 02:33:33 +00:00
d614e72031 fix(ci): add mkdir -p build to Package IPA step
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 7m31s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 02:14:54 +00:00
3ca3e54837 fix(ci): add -derivedDataPath and target find to build/derived — compatible with -scheme
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 7m41s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 02:02:12 +00:00
4e2e807fd9 fix(ci): remove grep -v DerivedData from Package IPA step — app builds to DerivedData
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 6m28s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 01:54:33 +00:00
b48178f167 fix(ci): switch from -target to -scheme for Xcode 26 SPM module path resolution
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 6m32s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 01:45:52 +00:00
c45e85edb6 fix(ci): remove stale .xcodeproj from git, nuke before xcodegen to prevent PostHog ghost dependency
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 7m8s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 01:35:07 +00:00
00e02b7166 fix(ci): clean SPM caches and DerivedData on CI runner to remove stale PostHog resolution
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 4m30s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 01:25:11 +00:00
2aa312ab61 fix(ci): use xcodebuild -version instead of xcode-select, clean only runner caches
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 3m30s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 01:10:44 +00:00
595140e2f9 fix(ci): add sudo back to xcode-select — must be run as root
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 3h8m4s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 00:48:57 +00:00
afc161c8ce fix: remove PostHog from project.yml — EmitSwiftModule crashes on Xcode 26
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 2s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 00:48:00 +00:00
58177102a4 fix: permanently fix PostHog module emission error
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 0s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
- Change PostHog from exactVersion to revision (tag 3.62.0) in project.yml
- Add deep cache clean step before xcodegen in CI workflow
- Add ONLY_ACTIVE_ARCH=NO to xcodebuild command
- Wrap PostHog usage in #if canImport(PostHog) for graceful degradation
- Add Package.resolved, .build/, DerivedData/ to .gitignore
- Remove stale SPM caches between CI runs (DerivedData, swiftpm caches, act cache)
2026-06-27 00:39:52 +00:00
9739a739f5 fix(ci): remove sudo from Setup Xcode step to prevent password prompt hang
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 2s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 00:37:08 +00:00
7fb44198e7 fix: kill stale Package.resolved, pin PostHog exact, add module emission flags
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 8m39s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-27 00:28:40 +00:00
8ce38332b8 fix(ci): remove -derivedDataPath and -clonedSourcePackagesDirPath incompatible with -target
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 6m36s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
Xcode demands -scheme when using -derivedDataPath or -clonedSourcePackagesDirPath.
Since we use -target TabataGo, these flags must be removed.

Changes:
- Removed -derivedDataPath and -clonedSourcePackagesDirPath from build command
- Moved SWIFT_ENABLE_EXPLICIT_MODULES=NO to its own line for clarity
- Updated Package IPA step to search from repo root (find ..) with explicit
  TabataGo.app name instead of relying on the old derived data path
2026-06-27 00:18:13 +00:00
3202ea9b5b fix(ci): revert build command from -scheme TabataGo to -target TabataGo
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 3m37s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
xcodegen 2.45.4 does not generate a "TabataGo" scheme (only
RevenueCatUI, RevenueCatUITests, TabataGoUITests). The -scheme flag
therefore fails. Reverting to -target TabataGo as was previously used.

Verified: all .accessoryCorner usages in TabataGoComplication.swift are
properly guarded with #if canImport(WatchKit), so the watch target
transitive dependency build will not surface accessoryCorner issues.
2026-06-26 23:50:46 +00:00
f14186aeab fix(ci): isolate SPM and DerivedData to prevent cache corruption
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 3m55s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
- Remove destructive clean step that causes corruption when dirs are in use
- Add -derivedDataPath to isolate CI build from user Xcode cache
- Add -clonedSourcePackagesDirPath to isolate SPM clones
- Fix Package IPA step to use new isolated derived data path
2026-06-26 23:44:19 +00:00
60ac624487 fix: use canImport(WatchKit) guard for accessoryCorner, use -scheme in CI build
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 2m8s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
- Replace #if os(watchOS) with #if canImport(WatchKit) for all .accessoryCorner
  usages. When the CI builds with forced iphoneos SDK, os(watchOS) evaluates
  to true (target platform is watchOS) but the SDK doesn't have WatchKit, so
  accessoryCorner is unavailable. canImport(WatchKit) correctly checks SDK
  availability rather than target platform.

- Wrap CornerComplicationView struct and its preview in #if canImport(WatchKit)
  with @available(watchOS 10.0, *) annotation.

- Change CI build from -target TabataGo -sdk iphoneos to -scheme TabataGo
  (without -sdk). This lets xcodebuild pick the correct SDK per target:
  iphoneos for iOS, watchos for watchOS. The scheme already exists in
  project.yml.
2026-06-26 23:39:38 +00:00
a6ea4ca904 fix: wrap accessoryCorner in #if os(watchOS) for Xcode 26 compat
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 6m41s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 23:35:12 +00:00
dde01ce957 fix: clean SPM cache, disable explicit modules
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 5m3s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 23:22:50 +00:00
5b67aea7ce fix: update PostHog to 3.62.0 to fix SwiftDriver compilation error
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 9m28s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
- Pin PostHog to 3.62.0 (vendored PLCrashReporter, drops external
  plcrashreporter dependency that fails with Xcode 26/Swift 6)
- Update project.yml and pbxproj minimumVersion
- Delete Package.resolved to force clean SPM resolution
- Add -resolvePackageDependencies step in CI workflow
- Add -skipPackagePluginValidation to build command
- Fix docs/ci-cd-setup.md manual build command
2026-06-26 23:07:57 +00:00
ebfaa15e38 fix: add DEVELOPMENT_TEAM via xcodebuild CLI
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 4m10s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:59:06 +00:00
86fb683428 fix: add DEVELOPMENT_TEAM to project.yml
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 3m30s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:48:01 +00:00
a48ee0007a fix: use xcodebuild build + manual ipa packaging (no scheme needed)
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 3m40s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:43:37 +00:00
cfe8e8cd1b fix: add TabataGo scheme to project.yml (xcodegen 2.45.4 compatibility)
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 50s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:41:13 +00:00
fdf4b6d558 fix: add xcodebuild -list debug, use -project flag
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 45s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:39:29 +00:00
44cebf834c fix: add /opt/homebrew/bin to PATH for all steps
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 4m2s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:34:29 +00:00
da62d6fa58 fix: use full path for npm
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 5s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:33:21 +00:00
96654d61c7 fix: tolerate brew post-install warning exit code
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 10s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:32:36 +00:00
1b3ce24fc9 fix: complete rewrite — add git clone checkout, install node+npm, fix all paths
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 22s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:31:09 +00:00
f5a777aee8 fix: use full brew path with arch -arm64 for Rosetta runner
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 6s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:29:12 +00:00
836cf9a16d chore: remove temp files
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 3s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:27:40 +00:00
7464357787 fix: remove arch -arm64 from xcodegen (runner already handles it)
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 7s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:27:13 +00:00
8f3979c8a0 fix: remove actions/checkout@v4 (no Node.js on Mac runner)
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 8s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:26:25 +00:00
1d4d43f3f2 fix: replace brew with npm for firebase-tools, use arch -arm64 for xcodegen
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 2s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 22:24:10 +00:00
e43f197139 fix: rename GITEA_TOKEN to PR_API_TOKEN (GITEA_ prefix reserved)
Some checks failed
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Failing after 14s
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been skipped
2026-06-26 21:59:29 +00:00
46bf8cae9b docs: update ci-cd-setup.md for Firebase migration
Some checks failed
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been cancelled
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Has been cancelled
- Remove all ios-deploy references (brew install, USB setup, device discovery)
- Remove iPhone Preparation section (UDID, libimobiledevice)
- Add Firebase App Distribution setup section:
  - Firebase project creation via console.firebase.google.com
  - Enable App Distribution, get App ID
  - Service account with Firebase App Distribution Admin role
  - Base64 encoding instructions for service account key
  - Gitea secrets: FIREBASE_APP_ID, FIREBASE_SERVICE_ACCOUNT
  - Add testers group 'millian-test' and invite flow
- Add Compilation Error Troubleshooting section:
  - xcodegen clearing signing team
  - TabataGoWatchWidget needing explicit team ID in project.yml
  - General build debugging tips
- Update workflow overview to reflect Firebase flow
- Update secrets table and manual test run command
2026-06-26 21:48:36 +00:00
9b06c0ae5c feat: switch to Firebase OTA deployment, drop ios-deploy
Some checks failed
PR → Build → Firebase OTA → LGTM / Wait for LGTM comment (pull_request) Has been cancelled
PR → Build → Firebase OTA → LGTM / Build & Deploy to Firebase OTA (pull_request) Has been cancelled
2026-06-26 21:48:07 +00:00
6a659c41fa docs: add ios-deploy, system_profiler UDID, Bonjour/VPN notes to CI/CD setup
Some checks failed
PR → iPhone → LGTM / Wait for LGTM comment (pull_request) Has been cancelled
PR → iPhone → LGTM / Build & Install on iPhone (pull_request) Has been cancelled
- Add ios-deploy install step (brew install ios-deploy)
- Add system_profiler SPUSBDataType method for finding iPhone UDID
- Add note about Bonjour/mDNS not working over VPN — use UDID-based connection
- Add ios-deploy troubleshooting entry for VPN/Bonjour issues
2026-06-26 21:25:51 +00:00
7a02a8949e fix: gitea→github context, add ios-deploy, remove -k
Some checks failed
PR → iPhone → LGTM / Build & Install on iPhone (pull_request) Has been cancelled
PR → iPhone → LGTM / Wait for LGTM comment (pull_request) Has been cancelled
2026-06-26 21:24:47 +00:00
001062c6d3 ci: add iPhone deploy workflow + CI/CD setup docs
Some checks failed
PR → iPhone → LGTM / Wait for LGTM comment (pull_request) Has been cancelled
PR → iPhone → LGTM / Build & Install on iPhone (pull_request) Has been cancelled
- .gitea/workflows/pr-iphone-deploy.yml: PR → Build → iPhone → LGTM → Merge
- docs/ci-cd-setup.md: Mac runner + secrets setup guide
2026-06-26 20:50:53 +00:00
bd81ce92ad docs: add AGENTS.md for Hermes agent context
All checks were successful
CI / Detect Changes (push) Successful in 3s
CI / Admin Web CI (push) Has been skipped
CI / YouTube Worker (push) Has been skipped
CI / Deploy (push) Has been skipped
2026-06-26 18:30:17 +00:00
77c17046d5 Merge pull request 'feat: move HealthKit permission to onboarding, remove HR write' (#3) from fix/healthkit-setup-popup into main
All checks were successful
CI / Detect Changes (push) Successful in 3s
CI / Admin Web CI (push) Has been skipped
CI / YouTube Worker (push) Has been skipped
CI / Deploy (push) Has been skipped
Reviewed-on: #3
2026-05-24 15:29:12 +02:00
Millian Lamiaux
310124ad63 feat: move HealthKit permission to onboarding, remove HR write
All checks were successful
CI / Detect Changes (pull_request) Successful in 3s
CI / Admin Web CI (pull_request) Has been skipped
CI / YouTube Worker (pull_request) Has been skipped
CI / Deploy (pull_request) Has been skipped
- Add .health step to onboarding between frequency and ready
- HealthStep with non-blocking permission flow (Not Now skips)
- Remove requestAuthorization() from PlayerViewModel.startWorkout()
- Guard live session start with isAuthorized check
- Remove heart rate write from HealthKit authorization popup
- Remove HR sample writing from saveWorkout (now without permission)
- Add L10n keys: healthAccess, healthAccessSubtitle, allowHealthAccess, notNow
- Add EN/DE/ES/FR translations
- Track permission decisions through analytics
- Entry animation on HealthStep (fade-in + slide-up)

HealthKit permission is now asked once during onboarding,
never interrupting workouts again.
2026-05-24 15:18:11 +02:00
Millian Lamiaux
72ad247136 chore: update .gitignore.
All checks were successful
CI / Detect Changes (push) Successful in 3s
CI / Admin Web CI (push) Has been skipped
CI / YouTube Worker (push) Has been skipped
CI / Deploy (push) Has been skipped
2026-05-23 12:29:54 +02:00
f71ba55e8b Merge pull request 'feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift' (#2) from revamp-timer-video-layout into main
Some checks failed
CI / Detect Changes (push) Successful in 3s
CI / Admin Web CI (push) Failing after 34s
CI / YouTube Worker (push) Failing after 5s
CI / Deploy (push) Failing after 2s
Reviewed-on: #2
2026-05-23 12:24:34 +02:00
Millian Lamiaux
38576fd528 ci: replace dead Expo CI with linux-only monorepo pipeline
Some checks failed
CI / Admin Web CI (pull_request) Failing after 34s
CI / YouTube Worker (pull_request) Failing after 5s
CI / Deploy (pull_request) Has been skipped
CI / Detect Changes (pull_request) Successful in 6s
Remove: root Expo typecheck/lint/test/build-check (project deleted Apr 19)
Remove: swift-build-test + app-store.yml (no macOS runner on Gitea)
Add: path-filtered conditional jobs with dorny/paths-filter@v3
Add: youtube-worker deploy to deploy-functions job
Fix: deploy-functions always() to handle skipped upstream jobs
Fix: SPM cache key includes Package.resolved (before removal)
Fix: remove continue-on-error from vitest/playwright steps
2026-05-23 12:09:28 +02:00
Millian Lamiaux
df9fd48964 chore: update tabatago-swift submodule (Live Activity fix)
Some checks failed
CI / TypeScript (pull_request) Failing after 4s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 6s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m7s
CI / Deploy Edge Functions (pull_request) Has been skipped
Includes fix for stale Live Activity persisting after workout
cancel/background. See submodule commit e42c121 for details.
2026-05-23 00:41:41 +02:00
Millian Lamiaux
e42c1217db fix: Live Activity persists after workout cancel/background
Root cause: observeActivityState() prematurely set workoutActivity=nil
when the activity went .stale (e.g. app backgrounded >2 minutes). This
prevented endActivity() from calling .end() on the stale activity,
leaving it visible on the Lock Screen and Dynamic Island indefinitely.

Fixes (all in PlayerViewModel.swift):

1. observeActivityState(): Split the monolithic stale/ended/dismissed
   handler. .stale now only stops the sync timer but keeps the
   workoutActivity reference so endActivity() can still call .end()
   to properly dismiss the stale Live Activity.

2. syncActivity() nil guard: Changed from != .active to explicit
   == .ended || == .dismissed so stale activities are not prematurely
   discarded when tick() re-enters syncActivity().

3. endActivity(): Added stopActivitySyncTimer() defensive call at top
   to prevent orphaned timer from racing in and recreating the activity
   during .end(). Also relaxed the guard from == .active to
   != .ended && != .dismissed so stale activities can be ended.

4. abandonWorkout(): Explicitly set isRunning=false + isPaused=false
   before cleanup to prevent accidental Live Activity recreation.
2026-05-23 00:40:41 +02:00
Millian Lamiaux
cd6fea9b53 ci: add App Store submission pipeline via GitHub Actions
Some checks failed
CI / TypeScript (pull_request) Failing after 4s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 12s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m7s
CI / Deploy Edge Functions (pull_request) Has been skipped
2026-05-21 10:47:48 +02:00
Millian Lamiaux
d31b769ab8 chore: update docs and remove stale skill files 2026-05-21 10:21:57 +02:00
Millian Lamiaux
c152c22ffb feat: redesign Dynamic Island with phase-driven UI and animations 2026-05-21 10:21:22 +02:00
45 changed files with 1489 additions and 4490 deletions

View File

@@ -1,369 +0,0 @@
# Skill — Audio Engine (useAudioEngine)
> Lis ce skill AVANT d'implémenter quoi que ce soit lié à l'audio.
## Responsabilité de ce module
L'audio engine gère 4 couches sonores indépendantes qui s'activent
en réponse aux événements du timer. Il ne connaît pas le timer
directement — il s'abonne à ses événements via useTimerSync.
**Objectif principal : zéro latence audio sur les transitions de phase.**
Les sons de phase doivent jouer dans les 50ms suivant l'événement.
---
## Architecture du module audio
```
src/features/audio/
types.ts
hooks/
useAudioEngine.ts ← moteur central expo-av
useAudioSettings.ts ← préférences utilisateur persistées
data/
tracks.ts ← catalogue musique (offline, paths locaux)
sounds.ts ← catalogue signaux de phase
components/
MusicPicker.tsx ← sélecteur d'ambiance musicale
SoundPicker.tsx ← sélecteur de signal de phase
AudioSettingsPanel.tsx ← panneau complet des réglages
index.ts
```
---
## Types
```typescript
// src/features/audio/types.ts
export type MusicAmbiance = 'ELECTRO' | 'HIP_HOP' | 'ROCK' | 'SILENCE'
export type MusicIntensity = 'LOW' | 'MEDIUM' | 'HIGH'
export type PhaseSound = 'beep' | 'whistle' | 'voice_go' | 'air_horn' | 'bell' | 'silence'
export interface AudioTrack {
id: string
ambiance: MusicAmbiance
intensity: MusicIntensity
asset: number // require('../assets/audio/...') — offline
bpm: number
durationMs: number
}
export interface AudioSettings {
musicEnabled: boolean
ambiance: MusicAmbiance
musicVolume: number // 0.0 → 1.0
soundsEnabled: boolean
soundsVolume: number // 0.0 → 1.0
hapticsEnabled: boolean
voiceEnabled: boolean // annonces vocales
overrideMuteSwitch: boolean // iOS : jouer même en mode silencieux
workPhaseSound: PhaseSound
restPhaseSound: PhaseSound
countdownSound: PhaseSound
completionSound: PhaseSound
}
export interface AudioState {
isLoaded: boolean
isMusicPlaying: boolean
currentAmbiance: MusicAmbiance
currentIntensity: MusicIntensity
error: string | null
}
export interface AudioActions {
preloadAll: () => Promise<void>
startMusic: (ambiance: MusicAmbiance, intensity: MusicIntensity) => Promise<void>
switchIntensity: (intensity: MusicIntensity, fadeMs?: number) => Promise<void>
stopMusic: (fadeMs?: number) => Promise<void>
playPhaseSound: (sound: PhaseSound) => Promise<void>
playCountdown: (seconds: number) => Promise<void> // 3, 2, 1
unloadAll: () => Promise<void>
}
```
---
## Implémentation — useAudioEngine
### Configuration de la session audio iOS/Android
**Critique à faire en premier, avant tout chargement de son.**
```typescript
import { Audio } from 'expo-av'
// À appeler une seule fois au démarrage de l'app (dans _layout.tsx)
export async function configureAudioSession(): Promise<void> {
await Audio.setAudioModeAsync({
// iOS : jouer par-dessus la musique de l'utilisateur
playsInSilentModeIOS: true, // ignore le switch mute (si overrideMuteSwitch)
allowsRecordingIOS: false,
staysActiveInBackground: true, // CRITIQUE — timer continue en background
// Android
shouldDuckAndroid: true, // baisser le volume autres apps sur nos signaux
playThroughEarpieceAndroid: false,
interruptionModeIOS: 1, // DO_NOT_MIX = 1 (nos sons ont priorité sur signaux)
interruptionModeAndroid: 1,
})
}
```
### Preloading — charger AVANT la séance
Tous les sons doivent être chargés en mémoire AVANT que la séance commence.
Si on charge pendant la séance → latence inacceptable.
```typescript
const soundRefs = useRef<Record<string, Audio.Sound>>({})
async function preloadAll(settings: AudioSettings): Promise<void> {
// 1. Charger la track musicale correspondante à l'ambiance + les 2 intensités
const trackWork = getTrack(settings.ambiance, 'HIGH')
const trackRest = getTrack(settings.ambiance, 'LOW')
soundRefs.current['music_work'] = await loadSound(trackWork.asset)
soundRefs.current['music_rest'] = await loadSound(trackRest.asset)
// 2. Charger tous les signaux de phase
const soundAssets = getSoundAssets(settings)
for (const [key, asset] of Object.entries(soundAssets)) {
soundRefs.current[key] = await loadSound(asset)
}
// 3. Mettre en loop les tracks musicales
await soundRefs.current['music_work'].setIsLoopingAsync(true)
await soundRefs.current['music_rest'].setIsLoopingAsync(true)
}
async function loadSound(asset: number): Promise<Audio.Sound> {
const { sound } = await Audio.Sound.createAsync(asset, {
shouldPlay: false,
volume: 1.0,
})
return sound
}
```
### Crossfade entre phases
```typescript
async function switchIntensity(
to: MusicIntensity,
fadeMs: number = 500
): Promise<void> {
const from = currentIntensityRef.current
if (from === to) return
const outKey = from === 'HIGH' ? 'music_work' : 'music_rest'
const inKey = to === 'HIGH' ? 'music_work' : 'music_rest'
const outSound = soundRefs.current[outKey]
const inSound = soundRefs.current[inKey]
if (!outSound || !inSound) return
// Démarrer la track entrante depuis le début (ou depuis la position actuelle ?)
await inSound.setPositionAsync(0)
await inSound.setVolumeAsync(0)
await inSound.playAsync()
// Fade simultané
const steps = 10
const stepMs = fadeMs / steps
const volumeStep = settings.musicVolume / steps
for (let i = 0; i <= steps; i++) {
await Promise.all([
outSound.setVolumeAsync(Math.max(0, settings.musicVolume - i * volumeStep)),
inSound.setVolumeAsync(Math.min(settings.musicVolume, i * volumeStep)),
])
await delay(stepMs)
}
await outSound.stopAsync()
currentIntensityRef.current = to
}
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
```
### Répondre aux événements timer
```typescript
// Dans useTimerSync — pont entre timer et audio
import { useTimerEngine } from '../timer'
import { useAudioEngine } from '../audio'
export function useTimerSync() {
const timer = useTimerEngine()
const audio = useAudioEngine()
useEffect(() => {
const unsubscribe = timer.addEventListener((event) => {
switch (event.type) {
case 'PHASE_CHANGED':
handlePhaseChange(event.from, event.to)
break
case 'COUNTDOWN_TICK':
audio.playCountdown(event.secondsLeft)
break
case 'SESSION_COMPLETE':
audio.playPhaseSound('completion')
audio.stopMusic(1000)
break
}
})
return unsubscribe
}, [])
async function handlePhaseChange(from: TimerPhase, to: TimerPhase) {
switch (to) {
case 'GET_READY':
await audio.startMusic(settings.ambiance, 'MEDIUM')
break
case 'WORK':
await audio.playPhaseSound(settings.workPhaseSound)
await audio.switchIntensity('HIGH', 500)
break
case 'REST':
await audio.playPhaseSound(settings.restPhaseSound)
await audio.switchIntensity('LOW', 500)
break
}
}
}
```
---
## Catalogue des tracks (offline)
```typescript
// src/features/audio/data/tracks.ts
import { MusicAmbiance, MusicIntensity, AudioTrack } from '../types'
export const TRACKS: AudioTrack[] = [
{
id: 'electro_high',
ambiance: 'ELECTRO',
intensity: 'HIGH',
asset: require('../../../assets/audio/music/electro_high.mp3'),
bpm: 140,
durationMs: 180000,
},
{
id: 'electro_low',
ambiance: 'ELECTRO',
intensity: 'LOW',
asset: require('../../../assets/audio/music/electro_low.mp3'),
bpm: 90,
durationMs: 180000,
},
// ... (9 tracks au total)
]
export function getTrack(ambiance: MusicAmbiance, intensity: MusicIntensity): AudioTrack {
const track = TRACKS.find(t => t.ambiance === ambiance && t.intensity === intensity)
if (!track) throw new Error(`Track non trouvée : ${ambiance} ${intensity}`)
return track
}
```
## Catalogue des sons de phase
```typescript
// src/features/audio/data/sounds.ts
export const PHASE_SOUNDS = {
beep: require('../../../assets/audio/sounds/beep_long.mp3'),
whistle: require('../../../assets/audio/sounds/whistle.mp3'),
voice_go: require('../../../assets/audio/sounds/voice_go.mp3'),
air_horn: require('../../../assets/audio/sounds/air_horn.mp3'),
bell: require('../../../assets/audio/sounds/bell.mp3'),
double_beep: require('../../../assets/audio/sounds/beep_double.mp3'),
countdown_3: require('../../../assets/audio/sounds/count_3.mp3'),
countdown_2: require('../../../assets/audio/sounds/count_2.mp3'),
countdown_1: require('../../../assets/audio/sounds/count_1.mp3'),
fanfare: require('../../../assets/audio/sounds/fanfare.mp3'),
clap: require('../../../assets/audio/sounds/applause.mp3'),
}
```
---
## Gestion du switch silencieux iOS
```typescript
// Si overrideMuteSwitch = true dans les settings
await Audio.setAudioModeAsync({
playsInSilentModeIOS: settings.overrideMuteSwitch,
})
```
**Règle UX :** Les signaux de phase ont l'override activé par défaut
(l'utilisateur a besoin de savoir quand changer d'exercice même en mode silencieux).
La musique d'ambiance respecte le switch silencieux par défaut.
---
## Cleanup obligatoire
```typescript
// Dans useAudioEngine — cleanup au unmount ou fin de séance
async function unloadAll(): Promise<void> {
await Promise.all(
Object.values(soundRefs.current).map(sound => sound.unloadAsync())
)
soundRefs.current = {}
}
useEffect(() => {
return () => {
// Cleanup synchrone au unmount
Object.values(soundRefs.current).forEach(sound => {
sound.unloadAsync().catch(console.error)
})
}
}, [])
```
---
## Tests minimaux
```typescript
describe('useAudioEngine', () => {
it('configure la session audio au mount', () => {})
it('preloadAll charge les sons sans erreur', () => {})
it('playPhaseSound joue le bon son dans les 50ms', () => {})
it('switchIntensity effectue un crossfade', () => {})
it('stopMusic fait un fade-out', () => {})
it('unloadAll libère toutes les ressources', () => {})
it('ne plante pas si un asset est manquant (fallback)', () => {})
})
```
---
## Erreurs classiques à éviter
```typescript
// ❌ Charger les sons à la demande → latence
case 'PHASE_CHANGED':
const { sound } = await Audio.Sound.createAsync(asset) // ← trop lent !
await sound.playAsync()
// ✅ Utiliser les sons préchargés
case 'PHASE_CHANGED':
await soundRefs.current['work_start'].replayAsync() // ← instantané
// ❌ Oublier de configurer la session audio → muet sur iOS background
await sound.playAsync() // sans Audio.setAudioModeAsync() → silencieux en background
// ❌ Pas de gestion du duck Android → nos sons se mélangent avec d'autres apps
interruptionModeAndroid: 2 // DUCK_OTHERS — OK pour la musique d'ambiance
interruptionModeAndroid: 1 // DO_NOT_MIX — pour les signaux de phase
```

View File

@@ -1 +0,0 @@
../../.agents/skills/building-native-ui

View File

@@ -1,576 +0,0 @@
---
name: tabata-kine-design-system
description: >
Design system complet pour l'application Tabata Kiné. Utilise ce skill pour
toute tâche liée au design, aux composants UI, aux écrans, aux couleurs, à la
typographie ou aux décisions d'interface de l'app Tabata Kiné. Déclenche ce
skill dès que l'utilisateur mentionne : un écran de l'app (onboarding, séance,
dashboard, paywall, programmes), un composant (bouton, carte, timer, badge,
input), une couleur, une typographie, une animation, un espacement, ou demande
à coder un élément UI. Ce skill contient les règles non négociables du design
"Dark Medical" — le style qui différencie l'app de tous les concurrents fitness.
---
# Design System — Tabata Kiné
## Principe directeur : Dark Medical
L'app Tabata Kiné n'est **pas** une app fitness classique. C'est une app médicale
qui utilise le format tabata. Le style "Dark Medical" traduit visuellement ce
positionnement : fond sombre professionnel, vert santé comme seule couleur d'action,
expertise kiné visible à chaque écran.
**Règles absolues :**
- Pas de mode clair. Dark only, sans exception.
- Le vert (#00C896) ne sert qu'aux actions et à la validation.
- L'orange (#FF8A5C) ne sert qu'aux conseils kiné et alertes positives.
- Le rouge (#FF4444) est réservé au timer en phase d'urgence (<10s).
- Touch target minimum : 44×44px pour tous les éléments interactifs.
---
## 1. Tokens de couleur
### Fonds — Navy
| Token | Valeur | Usage |
|-------|--------|-------|
| `navy-900` | `#0D1B2A` | Fond principal de l'app |
| `navy-800` | `#112240` | Surface 1 — cartes par défaut |
| `navy-700` | `#1A3050` | Surface 2 — cartes surélevées |
| `navy-600` | `#243C5E` | Bordures actives |
### Vert Kiné — action & santé
| Token | Valeur | Usage |
|-------|--------|-------|
| `green-500` | `#00C896` | CTA principal, timer effort, progress |
| `green-600` | `#00A67C` | État hover / pressed |
| `green-700` | `#00875F` | État active deep |
| `green-dim` | `rgba(0,200,150,0.12)` | Fond badge, chip, card accent |
| `green-border` | `rgba(0,200,150,0.35)` | Bordure card accent |
### Texte & bordures
| Token | Valeur | Usage |
|-------|--------|-------|
| `white-100` | `#E6F1FF` | Texte primaire |
| `slate-300` | `#A8B2D8` | Texte secondaire |
| `slate-400` | `#8892B0` | Texte tertiaire, placeholders |
| `border-dim` | `rgba(168,178,216,0.15)` | Bordure par défaut |
| `border-hover` | `rgba(168,178,216,0.25)` | Bordure hover |
### Orange — conseils kiné uniquement
| Token | Valeur | Usage |
|-------|--------|-------|
| `orange-500` | `#FF8A5C` | Tip card border, badge Kiné+ |
| `orange-600` | `#E06A3C` | Hover orange |
| `orange-dim` | `rgba(255,138,92,0.12)` | Fond tip card |
### Sémantique
| Token | Valeur | Usage |
|-------|--------|-------|
| `red-500` | `#FF4444` | Timer urgence <10s UNIQUEMENT |
---
## 2. Typographie
### Familles
| Rôle | Famille | Notes |
|------|---------|-------|
| Titres émotionnels | Serif italique (ex: DM Serif Display, Georgia) | Célébration, fin de séance, accroches |
| Interface & corps | Sans-serif géométrique (ex: Outfit, DM Sans) | Navigation, descriptions, labels |
| Données & timer | Monospace (ex: DM Mono, JetBrains Mono) | Timer, stats, codes, metadata |
### Échelle
| Style | Famille | Taille | Poids | Usage |
|-------|---------|--------|-------|-------|
| `display` | Serif italic | 2832px | 400 | Fin de séance, titres forts |
| `heading-1` | Serif | 2224px | 500 | Titres de section |
| `heading-2` | Sans | 18px | 500 | Titre exercice, carte programme |
| `body` | Sans | 1516px | 400 | Corps, conseil kiné |
| `label` | Mono | 1113px | 500 | Tags, metadata, uppercase tracking |
| `timer` | Mono | **80100px** | 500 | Timer séance — lisible à 2 mètres |
| `caption` | Sans | 12px | 400 | Sous-labels, hints |
**Règle typographie :** La taille du timer est la décision de design la plus
importante de l'écran séance. Tout se dimensionne autour de lui.
---
## 3. Espacement
Base : **4px**
| Token | Valeur | Usage |
|-------|--------|-------|
| `space-1` | 4px | Gap minimal entre éléments liés |
| `space-2` | 8px | Gap interne composant |
| `space-3` | 12px | Gap entre composants proches |
| `space-4` | 16px | Padding carte, gap standard |
| `space-6` | 24px | Espacement sections |
| `space-8` | 32px | Padding écran horizontal |
| `space-12` | 48px | Espacement majeur |
| `space-16` | 64px | Espacement entre blocs screens |
---
## 4. Border Radius
| Token | Valeur | Usage |
|-------|--------|-------|
| `radius-sm` | 4px | Badge, chip, tag |
| `radius-md` | 8px | Bouton, input, tip card |
| `radius-lg` | 12px | Carte programme standard |
| `radius-xl` | 16px | Carte large, modal |
| `radius-pill` | 9999px | Pill, toggle, progress bar |
| `radius-circle` | 50% | Icon button, avatar, streak dot |
---
## 5. Système d'élévation (surfaces)
```
Fond (navy-900)
└── Surface 1 (navy-800) — cartes par défaut
└── Surface 2 (navy-700) — cartes surélevées / hover
└── Surface active (navy-800 + border green-500 1.5px)
```
Différencier les surfaces **uniquement par la couleur de fond**, jamais par des
ombres portées (box-shadow : non). La bordure active verte est le seul signal
d'état sélectionné.
---
## 6. Composants
### Boutons
```
PrimaryButton
background: green-500
color: navy-900
padding: 14px 24px
height: 5256px
border-radius: radius-md
font: sans 15px 500
width: 100% (full-width dans les screens)
hover: background green-600
active: background green-700 + scale(0.98)
SecondaryButton
background: transparent
color: green-500
border: 1.5px solid green-500
padding: 13px 24px
hover: background green-dim
GhostButton
background: transparent
color: slate-300
no border
usage: actions secondaires (Passer, Annuler)
DangerButton
background: rgba(255,68,68,0.12)
color: #FF6B6B
border: 1px solid rgba(255,68,68,0.3)
usage: Quitter la séance UNIQUEMENT
IconButton
width: 44px
height: 44px
border-radius: 50%
background: rgba(168,178,216,0.10)
color: slate-300
JAMAIS en dessous de 44×44px (accessibilité)
```
### Inputs
```
TextField
background: navy-800
border: 1px solid border-dim
border-radius: radius-md
padding: 12px 16px
color: white-100
font: sans 15px 400
focus: border green-500
error: border red-500
height: 48px
```
### Badges & Pills
```
Badge (tier)
font: mono 11px 500
padding: 3px 10px
border-radius: radius-sm
UPPERCASE + letter-spacing: 0.08em
.free: background green-dim, color green-500
.premium: background orange-dim, color orange-500
.kine: background rgba(168,178,216,0.12), color slate-300
Pill (metadata)
font: sans 12px 400
padding: 4px 12px
border-radius: radius-pill
border: 1px solid (couleur correspondante à 0.3 opacity)
```
### Cartes
```
CardDefault
background: navy-800
border: 1px solid border-dim
border-radius: radius-lg
padding: 16px
CardAccent (CTA, prochaine séance)
background: rgba(0,200,150,0.05)
border: 1.5px solid green-border
border-radius: radius-lg
CardTip (conseil kiné)
background: orange-dim
border-left: 3px solid orange-500
border-radius: 0 radius-lg radius-lg 0
NE PAS arrondir le côté gauche (border-left unique)
Structure: icône 💡 + texte + signature "— Prénom, kiné"
CardProgram
border-radius: radius-xl
overflow: hidden
Thumbnail: 120px height, gradient navy-700→navy-600
Body: padding 14px
Toujours afficher: progression bar + "X/12 séances"
```
### Timer
```
Timer (composant le plus critique de l'app)
font: mono 80100px 500
text-align: center
État effort normal (>10s):
color: green-500
État urgence (<10s):
color: red-500
animation: pulse subtil (scale 1→1.02→1, 1s infinite)
Label sous le chiffre:
font: mono 14px 400
color: slate-400
letter-spacing: 0.1em
text: "SECONDES"
Contexte repos:
color: slate-300 (pas de vert, signal visuel de repos)
```
### Progress Bar
```
ProgressBar
track: background rgba(168,178,216,0.12), height 4px, border-radius pill
fill: background green-500, border-radius pill
Variante séance (épaisseur réduite):
height: 3px
Variante programme:
height: 4px
Afficher le % à droite en mono 11px green-500
Animation: transition width 300ms ease
```
### Feedback ressenti
```
FeedbackButton
width: flex (3 boutons égaux)
height: 72px
border-radius: radius-lg
background: navy-800
border: 1px solid border-dim
flex-direction: column
gap: 4px
Emoji: 28px
Label: sans 12px slate-400
État sélectionné:
border: 1.5px solid green-500
background: green-dim
```
### Streak hebdomadaire
```
StreakDot
width: 32px
height: 32px
border-radius: 50%
.done: background rgba(0,200,150,0.15) → afficher ✓
.today: background green-500 → afficher ✓
.empty: background rgba(168,178,216,0.06), border 1px border-dim
Label jour: mono 10px slate-400, centré sous chaque dot
```
---
## 7. Écran séance — règles spéciales
L'écran séance est le plus critique de l'app. Il doit être utilisable **les mains
sur les genoux, en sueur, à 2 mètres de l'écran**. Chaque décision de design doit
passer ce test.
### Architecture visuelle
```
[Vidéo plein écran en boucle — fond de tout l'écran]
↓ Gradient top navy→transparent (40% opacité, 100px height)
→ Contrôles pause/audio en overlay
→ Indicateur exercice X/8 centré
↓ Zone centrale nette (pas de gradient — l'utilisateur voit le mouvement)
↓ Gradient bottom transparent→navy (70% opacité, 220px height)
→ Timer géant centré
→ Progress bar
→ Tip card conseil kiné
```
### Transitions séance
```
Effort → Repos:
Fond passe de vidéo plein écran → navy-800 uni
Transition: fade 300ms
Vibration haptique légère (si disponible)
Le repos a une identité visuelle différente (pas de vidéo, couleur unie)
Repos → Effort:
Countdown audio "3... 2... 1..."
Vibration haptique + transition fade
Exercice suivant pendant le repos:
Afficher un thumbnail 56×56px du prochain exercice
Nom en sans 14px 500
Label "PROCHAIN EXERCICE" en mono 11px slate-400
```
### Phase repos
L'écran repos doit être **visuellement différent** de l'écran effort.
- Fond : `navy-800` uni (plus de vidéo plein écran)
- Timer couleur : `slate-300` (pas de vert — c'est le repos)
- Mot "REPOS" en mono 13px slate-400, letter-spacing 0.15em
- Aperçu prochain exercice centré
---
## 8. Navigation
```
Tab Bar (5 onglets, fixé en bas)
height: 56px + safe area inset
background: navy-800
border-top: 1px solid border-dim
Onglets:
- Accueil (home icon)
- Programmes (grid icon)
- Minuteur (timer icon)
- Progression (chart icon)
- Profil (person icon)
Onglet actif: icône green-500 + label green-500
Onglet inactif: icône slate-400 + label slate-400
Font label: sans 11px 400
Icon size: 22×22px
Touch target: 44×44px minimum
```
---
## 9. Animations & micro-interactions
```
FadeIn:
opacity: 0 → 1
duration: 300ms
easing: ease
SlideUp (bottom sheet, modal):
translateY(100%) → 0
duration: 400ms
easing: cubic-bezier(0.4, 0, 0.2, 1)
Pulse (CTA bouton, timer urgence):
scale: 1 → 1.02 → 1
duration: 2s
infinite, ease-in-out
Bounce (célébration fin de séance):
scale: 0.5 → 1.05 → 0.98 → 1
duration: 600ms
easing: spring
StaggerList (items qui apparaissent en séquence):
Délai: 100ms entre chaque item
Chaque item: FadeIn + translateY(12px→0)
ScalePress (tous les boutons):
active: scale(0.97)
duration: 100ms
```
**Règle d'or animations :** Une animation bien exécutée au chargement d'écran
vaut mieux que des micro-interactions dispersées partout.
---
## 10. Paywall — règles de design conversion
```
Structure obligatoire du paywall:
1. Célébration des accomplissements (TOUJOURS en premier)
→ Font serif italic, emoji, stats concrètes
2. Valeur du contenu débloqué (liste concrète)
3. Pricing transparent (pas de dark patterns)
→ "Essai gratuit 7 jours · puis 24,99€/an · soit 2,08€/mois"
4. CTA principal (PrimaryButton full-width)
5. Réassurance ("Annulation facile à tout moment")
6. Alternative gratuite visible (GhostButton ou lien)
Couleur encadré pricing: CardAccent (vert)
Bouton fermeture: TOUJOURS visible en haut à gauche
Pas de compte à rebours fictif, pas de stock limité : anti dark patterns
```
---
## 11. Accessibilité
```
Contraste texte:
Texte primaire (#E6F1FF) sur navy-900 → ratio 15:1 ✓
Texte secondaire (#A8B2D8) sur navy-900 → ratio 7:1 ✓
Vert (#00C896) sur navy-900 → ratio 8:1 ✓
Tous conformes WCAG AA (4.5:1 minimum requis)
Touch targets:
Minimum 44×44px pour TOUS les éléments interactifs
Espacement minimum 8px entre deux éléments interactifs adjacents
Timer:
La couleur n'est pas le seul signal d'urgence
Ajouter aussi: pulse animation + vibration haptique + signal audio
Audio:
Toujours proposer une alternative visuelle à chaque signal audio
Le toggle audio est accessible en 1 tap depuis l'écran séance
```
---
## 12. Tokens React Native / Expo
```typescript
// design-tokens.ts
export const colors = {
// Navy
navy900: '#0D1B2A',
navy800: '#112240',
navy700: '#1A3050',
navy600: '#243C5E',
// Green
green500: '#00C896',
green600: '#00A67C',
green700: '#00875F',
greenDim: 'rgba(0,200,150,0.12)',
greenBorder: 'rgba(0,200,150,0.35)',
// Text
white100: '#E6F1FF',
slate300: '#A8B2D8',
slate400: '#8892B0',
// Borders
borderDim: 'rgba(168,178,216,0.15)',
borderHover: 'rgba(168,178,216,0.25)',
// Orange (tip/kine only)
orange500: '#FF8A5C',
orange600: '#E06A3C',
orangeDim: 'rgba(255,138,92,0.12)',
// Semantic
red500: '#FF4444', // timer urgence ONLY
} as const
export const spacing = {
1: 4,
2: 8,
3: 12,
4: 16,
6: 24,
8: 32,
12: 48,
16: 64,
} as const
export const radius = {
sm: 4,
md: 8,
lg: 12,
xl: 16,
pill: 9999,
} as const
export const fontSizes = {
caption: 12,
label: 13,
body: 15,
heading2: 18,
heading1: 22,
display: 28,
timer: 88, // taille par défaut du timer
} as const
export const timerThreshold = 10 // secondes — passage vert → rouge
```
---
## Checklist avant livraison d'un écran
- [ ] Fond `navy-900` utilisé comme base
- [ ] Aucun shadow/élévation — différenciation par couleur uniquement
- [ ] Tous les touch targets ≥ 44×44px
- [ ] Le vert n'est utilisé que pour des actions ou validations
- [ ] L'orange n'est utilisé que pour des conseils kiné ou alertes positives
- [ ] Le rouge n'apparaît que sur le timer en urgence
- [ ] Timer ≥ 80px de haut sur l'écran séance
- [ ] Vidéo plein écran sur l'écran séance (pas un bloc vidéo)
- [ ] Gradients top + bottom sur l'écran séance pour la lisibilité
- [ ] Phase repos visuellement différente de la phase effort
- [ ] Paywall : célébration en premier, alternative gratuite visible
- [ ] Typographie : serif pour les moments émotionnels, mono pour les données

View File

@@ -1,428 +0,0 @@
# Skill — Exercices (useExercise + ExerciseDisplay)
> Lis ce skill AVANT d'implémenter quoi que ce soit lié aux exercices.
## Responsabilité de ce module
Gérer la bibliothèque d'exercices, la sélection selon le programme,
et l'affichage contextuel selon la phase du timer.
**Offline-first** : toutes les données et les GIFs sont dans le bundle.
---
## Architecture du module exercices
```
src/features/exercises/
types.ts
hooks/
useExercise.ts ← sélection + navigation entre exercices
useExerciseLibrary.ts ← recherche, filtrage, favoris
components/
ExerciseDisplay.tsx ← affichage contextuel selon la phase timer
ExerciseGif.tsx ← Image GIF avec fallback + accessibilité
ExerciseCard.tsx ← carte pour la bibliothèque/programme
ExerciseCues.tsx ← affichage des cues de forme
data/
exercises.ts ← les 38 exercices (source de vérité)
categories.ts ← définition des catégories
index.ts
```
---
## Types
```typescript
// src/features/exercises/types.ts
export type ExerciseCategory =
| 'CARDIO'
| 'LOWER_BODY'
| 'UPPER_BODY'
| 'CORE'
| 'LOW_IMPACT'
| 'EQUIPMENT'
export type ExerciseDifficulty = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'
export interface Exercise {
id: string
name: Record<string, string> // clés = codes langue : { fr, en, es, de, pt }
category: ExerciseCategory
difficulty: ExerciseDifficulty
musclesTargeted: string[]
description: Record<string, string> // max 80 caractères par langue
cues: Record<string, string[]> // 2-3 points clés de forme par langue
gifAsset: number | null // require(...) ou null si pas de GIF
thumbnailAsset: number | null
hasModification: boolean // variante plus facile disponible
modificationId: string | null // id de l'exercice modifié
equipmentNeeded: string[] // [] = aucun matériel
estimatedCaloriesPerMinute: number // approximatif
}
export type ProgramMode = 'SINGLE' | 'DUO' | 'CIRCUIT_4' | 'FREE'
export interface ExerciseProgram {
id: string
name: Record<string, string>
description: Record<string, string>
mode: ProgramMode
// Pour SINGLE : [exerciseId] — même exercice répété
// Pour DUO : [exerciseIdA, exerciseIdB] — alternance
// Pour CIRCUIT_4 : [a, b, c, d] — 4 exercices × 2 rounds
// Pour FREE : exerciseId par round [r1, r2, r3, r4, r5, r6, r7, r8]
exerciseIds: string[]
difficulty: ExerciseDifficulty
totalRounds: number
isDefault: boolean // programmes pré-définis vs créés par user
}
// Résout l'exercice pour un round donné selon le mode du programme
export interface ExerciseState {
current: Exercise
next: Exercise | null // null sur le dernier round
roundIndex: number // 0-indexé
}
```
---
## Données — les 38 exercices V1
Structure à respecter pour chaque exercice :
```typescript
// src/features/exercises/data/exercises.ts
import { Exercise } from '../types'
export const EXERCISES: Exercise[] = [
{
id: 'burpee_classic',
name: { fr: 'Burpee', en: 'Burpee', es: 'Burpee', de: 'Burpee', pt: 'Burpee' },
category: 'CARDIO',
difficulty: 'INTERMEDIATE',
musclesTargeted: ['quadriceps', 'pectoraux', 'épaules', 'cardio'],
description: {
fr: 'Position debout → squat → planche → pompe → saut vertical',
en: 'Stand → squat → plank → push-up → jump',
es: 'De pie → sentadilla → plancha → flexión → salto',
de: 'Stehen → Hocke → Plank → Liegestütz → Sprung',
pt: 'Em pé → agachamento → prancha → flexão → salto',
},
cues: {
fr: ['Atterris doucement sur les orteils', 'Garde le dos droit en planche', 'Explose vers le haut'],
en: ['Land softly on toes', 'Keep back flat in plank', 'Explode upward'],
es: ['Aterriza suavemente', 'Espalda recta en plancha', 'Explota hacia arriba'],
de: ['Sanft auf den Zehen landen', 'Rücken gerade halten', 'Nach oben explodieren'],
pt: ['Aterrissar suavemente', 'Costas retas na prancha', 'Explodir para cima'],
},
gifAsset: require('../../../assets/exercises/burpee_classic.gif'),
thumbnailAsset: require('../../../assets/exercises/thumbs/burpee_classic.jpg'),
hasModification: true,
modificationId: 'burpee_modified',
equipmentNeeded: [],
estimatedCaloriesPerMinute: 12,
},
// ... 37 autres exercices
]
// Accès rapide par ID
export const EXERCISES_MAP = Object.fromEntries(
EXERCISES.map(ex => [ex.id, ex])
) as Record<string, Exercise>
export function getExerciseById(id: string): Exercise {
const ex = EXERCISES_MAP[id]
if (!ex) throw new Error(`Exercice non trouvé : ${id}`)
return ex
}
```
**Liste des 38 exercices V1 à implémenter :**
```
CARDIO (8) : burpee_classic, burpee_modified, jumping_jacks,
mountain_climbers, high_knees, jump_rope_sim,
box_jump_sim, lateral_shuffles
LOWER_BODY (8) : squat_classic, squat_jump, lunge_alternating,
lunge_jump, glute_bridge, wall_sit, calf_raises,
sumo_squat
UPPER_BODY (6) : pushup_classic, pushup_modified, pike_pushup,
tricep_dip, shoulder_taps, inchworm
CORE (6) : crunch_classic, plank_hold, russian_twist,
bicycle_crunch, leg_raises, dead_bug
LOW_IMPACT (6) : march_in_place, step_touch, modified_squat,
standing_oblique, slow_pushup, chair_stand
EQUIPMENT (4) : kb_swing, db_thruster, resistance_band_row,
medicine_ball_slam
```
---
## Hook useExercise
```typescript
// src/features/exercises/hooks/useExercise.ts
export function useExercise(program: ExerciseProgram): {
getExerciseForRound: (round: number) => ExerciseState
currentExercise: Exercise
nextExercise: Exercise | null
} {
function getExerciseForRound(round: number): ExerciseState {
// round est 1-indexé
const index = round - 1
let currentId: string
let nextId: string | null
switch (program.mode) {
case 'SINGLE':
currentId = program.exerciseIds[0]
nextId = null // même exercice → pas de "suivant" différent
break
case 'DUO':
currentId = program.exerciseIds[index % 2]
nextId = program.exerciseIds[(index + 1) % 2]
break
case 'CIRCUIT_4':
currentId = program.exerciseIds[index % 4]
nextId = program.exerciseIds[(index + 1) % 4]
break
case 'FREE':
currentId = program.exerciseIds[index] ?? program.exerciseIds[0]
nextId = program.exerciseIds[index + 1] ?? null
break
}
return {
current: getExerciseById(currentId),
next: nextId ? getExerciseById(nextId) : null,
roundIndex: index,
}
}
return { getExerciseForRound, /* ... */ }
}
```
---
## Composant ExerciseDisplay — affichage contextuel
Le composant adapte son layout selon la phase du timer.
```typescript
interface ExerciseDisplayProps {
phase: TimerPhase
exercise: Exercise
nextExercise: Exercise | null
lang: string // code langue actuel
}
export function ExerciseDisplay({ phase, exercise, nextExercise, lang }: ExerciseDisplayProps) {
switch (phase) {
case 'GET_READY':
return <GetReadyView exercise={exercise} lang={lang} />
// ↑ GIF grand format + nom + "Prépare-toi !"
case 'WORK':
return <WorkView exercise={exercise} lang={lang} />
// ↑ Nom en haut + 2 cues + GIF petit coin bas-droit
case 'REST':
return <RestView exercise={exercise} nextExercise={nextExercise} lang={lang} />
// ↑ "Repos" + si nextExercise : "Prochain : [nom]" + vignette
default:
return null
}
}
```
### GetReadyView
```
┌─────────────────────────────┐
│ │
│ ┌─────────────────┐ │
│ │ [GIF 200x200] │ │ ← Grand GIF centré
│ └─────────────────┘ │
│ │
│ Burpees │ ← Nom, police bold 28px
│ ← Dos droit en planche │ ← Cue #1
│ ← Explose vers le haut │ ← Cue #2
│ │
└─────────────────────────────┘
```
### WorkView
```
┌─────────────────────────────┐
│ Burpees │ ← Nom, 22px, coin haut-gauche
│ ← Dos droit │ ← Cue #1, 16px
│ ← Explose vers le haut │ ← Cue #2, 16px
│ │
│ [Timer central] │
│ │
│ ┌────┐ │
│ │GIF │ │ ← GIF 80x80, coin bas-droit
│ └────┘ │
└─────────────────────────────┘
```
### RestView
```
┌─────────────────────────────┐
│ │
│ REPOS │ ← "REPOS" centré, grand
│ │
│ Prochain : Mountain │ ← Si nextExercise != null
│ Climbers │
│ ┌──────┐ │
│ │ GIF │ │ ← Vignette 60x60 de nextExercise
│ └──────┘ │
└─────────────────────────────┘
```
---
## Composant ExerciseGif — robuste et accessible
```typescript
interface ExerciseGifProps {
exercise: Exercise
size: 'small' | 'medium' | 'large'
lang: string
}
const GIF_SIZES = { small: 80, medium: 120, large: 200 }
export function ExerciseGif({ exercise, size, lang }: ExerciseGifProps) {
const [hasError, setHasError] = useState(false)
const dimension = GIF_SIZES[size]
if (!exercise.gifAsset || hasError) {
// Fallback : icône + initiales de l'exercice
return (
<View
style={[styles.fallback, { width: dimension, height: dimension }]}
accessible={true}
accessibilityLabel={exercise.name[lang] ?? exercise.name['en']}
>
<Text style={styles.fallbackIcon}>💪</Text>
<Text style={styles.fallbackText}>{getInitials(exercise.name[lang])}</Text>
</View>
)
}
return (
<Image
source={exercise.gifAsset}
style={{ width: dimension, height: dimension }}
onError={() => setHasError(true)}
accessible={true}
accessibilityLabel={`Démonstration de ${exercise.name[lang]}`}
/>
)
}
```
---
## Localisation des exercices
```typescript
// Utilitaire pour extraire la bonne langue avec fallback
export function getLocalizedText(
record: Record<string, string>,
lang: string
): string {
return record[lang] ?? record['en'] ?? Object.values(record)[0] ?? ''
}
// Utilisation
const name = getLocalizedText(exercise.name, userLang)
const cues = (exercise.cues[userLang] ?? exercise.cues['en'] ?? [])
```
---
## Programmes pré-définis V1
```typescript
// src/features/exercises/data/defaultPrograms.ts
export const DEFAULT_PROGRAMS: ExerciseProgram[] = [
{
id: 'beginner_classic',
name: { fr: 'Débutant Classic', en: 'Beginner Classic' },
description: { fr: 'Parfait pour commencer le Tabata', en: 'Perfect to start Tabata' },
mode: 'SINGLE',
exerciseIds: ['squat_classic'],
difficulty: 'BEGINNER',
totalRounds: 8,
isDefault: true,
},
{
id: 'full_body_duo',
name: { fr: 'Full Body Duo', en: 'Full Body Duo' },
description: { fr: 'Squats + Pompes — le duo parfait', en: 'Squats + Push-ups — the perfect duo' },
mode: 'DUO',
exerciseIds: ['squat_jump', 'pushup_classic'],
difficulty: 'INTERMEDIATE',
totalRounds: 8,
isDefault: true,
},
{
id: 'cardio_blast',
name: { fr: 'Cardio Blast', en: 'Cardio Blast' },
description: { fr: 'Circuit intense 4 exercices cardio', en: '4-exercise intense cardio circuit' },
mode: 'CIRCUIT_4',
exerciseIds: ['burpee_classic', 'high_knees', 'mountain_climbers', 'jumping_jacks'],
difficulty: 'ADVANCED',
totalRounds: 8,
isDefault: true,
},
// + programmes par objectif (perte de poids, force, low impact, etc.)
]
```
---
## Tests minimaux
```typescript
describe('useExercise', () => {
it('MODE SINGLE : retourne le même exercice à chaque round', () => {})
it('MODE DUO : alterne A/B correctement sur 8 rounds', () => {})
it('MODE CIRCUIT_4 : cycle sur 4 exercices', () => {})
it('retourne null pour nextExercise sur le dernier round en FREE', () => {})
it('getExerciseById lance une erreur si id inconnu', () => {})
})
describe('ExerciseDisplay', () => {
it('affiche le GIF grand format en GET_READY', () => {})
it('affiche les cues en WORK', () => {})
it('affiche nextExercise en REST si disponible', () => {})
it('affiche le fallback si gifAsset est null', () => {})
it('est accessible (accessibilityLabel présent)', () => {})
})
```
---
## Checklist assets exercices
Avant de lancer en production, vérifier :
- [ ] 38 GIFs présents dans `assets/exercises/` (200×200px, loop, < 200KB chacun)
- [ ] 38 thumbnails présents dans `assets/exercises/thumbs/` (< 30KB chacun)
- [ ] Toutes les traductions présentes pour EN, FR, ES, DE, PT
- [ ] Tous les `modificationId` pointent vers un exercice existant
- [ ] Bundle total des assets exercices < 10MB

View File

@@ -21,8 +21,9 @@ Run from the project root. This parses all source files, builds the knowledge gr
| -------------- | ---------------------------------------------------------------- |
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
### status — Check index freshness

View File

@@ -1,349 +0,0 @@
# Skill — Timer Engine (useTimerEngine)
> Lis ce skill AVANT d'implémenter quoi que ce soit lié au timer.
## Responsabilité de ce module
Le timer est le cœur battant de l'app. Il doit être :
- **Précis** : drift < 50ms sur une séance complète (8 rounds)
- **Résilient** : continue en background, survit aux appels téléphoniques
- **Découplé** : aucune dépendance vers l'audio ou les exercices
- **Testable** : logique pure, facile à tester sans simulateur
---
## Architecture du module timer
```
src/features/timer/
types.ts ← Commencer ICI
hooks/
useTimerEngine.ts ← Moteur central (logique pure)
useTimerSync.ts ← Pont vers audio + exercices
useTimerPersistence.ts ← Sauvegarde config dans AsyncStorage
components/
TimerDisplay.tsx ← Écran plein écran (props-only)
TimerControls.tsx ← Boutons start/pause/stop/skip
TimerPhaseIndicator.tsx ← Barre de progression des rounds
TimerRing.tsx ← Anneau circulaire animé
index.ts ← Barrel export
```
---
## Types — commencer par ici
```typescript
// src/features/timer/types.ts
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
export interface TimerConfig {
workDuration: number // défaut : 20s
restDuration: number // défaut : 10s
rounds: number // défaut : 8
getReadyDuration: number // défaut : 10s
cycles: number // défaut : 1 (Premium : jusqu'à 10)
cyclePauseDuration: number // défaut : 60s (pause entre cycles, Premium)
}
export interface TimerState {
phase: TimerPhase
secondsLeft: number
currentRound: number // 1-indexé
totalRounds: number
currentCycle: number // 1-indexé
totalCycles: number
isRunning: boolean
isPaused: boolean
totalElapsedSeconds: number // pour le résumé de séance
}
export interface TimerActions {
start: (config: TimerConfig) => void
pause: () => void
resume: () => void
stop: () => void
skip: () => void // passer à la phase suivante immédiatement
}
// Événements émis par le timer — consommés par useTimerSync
export type TimerEvent =
| { type: 'PHASE_CHANGED'; from: TimerPhase; to: TimerPhase }
| { type: 'ROUND_COMPLETED'; round: number }
| { type: 'SESSION_COMPLETE'; totalSeconds: number }
| { type: 'COUNTDOWN_TICK'; secondsLeft: number } // pour les dernières 3s
export type TimerEventListener = (event: TimerEvent) => void
```
---
## Implémentation — useTimerEngine
### Principe de précision : Date.now() delta
**JAMAIS** se fier uniquement à setInterval pour le compte à rebours.
setInterval drift de 10-100ms par tick sur mobile. Sur 8 rounds
de 20s + 10s, ça peut représenter plusieurs secondes d'écart.
```typescript
// ✅ Approche correcte — delta sur Date.now()
const tickRef = useRef<number | null>(null)
const targetEndTimeRef = useRef<number>(0)
function tick() {
const now = Date.now()
const remaining = Math.max(0, targetEndTimeRef.current - now)
const secondsLeft = Math.ceil(remaining / 1000)
setSecondsLeft(secondsLeft)
if (remaining <= 0) {
advancePhase()
} else {
// Planifier le prochain tick dans ~100ms (pas 1000ms)
// pour une réactivité maximale sur les transitions
tickRef.current = setTimeout(tick, 100)
}
}
function startPhase(duration: number) {
targetEndTimeRef.current = Date.now() + duration * 1000
tickRef.current = setTimeout(tick, 100)
}
```
### Gestion du background (AppState)
```typescript
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState) => {
if (nextState === 'background' || nextState === 'inactive') {
// Sauvegarder le timestamp cible — pas l'état courant
// Quand on revient en foreground, on recalcule
saveBackgroundTimestamp(targetEndTimeRef.current)
}
if (nextState === 'active') {
const savedTarget = loadBackgroundTimestamp()
if (savedTarget && isRunningRef.current) {
// Recalculer le temps restant depuis le timestamp sauvegardé
targetEndTimeRef.current = savedTarget
// Si le temps est dépassé, avancer aux phases manquées
reconcilePhaseAfterBackground(savedTarget, Date.now())
}
}
})
return () => subscription.remove()
}, [])
```
### Notification sticky pendant la séance
Utiliser expo-notifications pour afficher le décompte en background :
```typescript
async function updateBackgroundNotification(phase: TimerPhase, secondsLeft: number) {
await Notifications.scheduleNotificationAsync({
identifier: 'tabata-timer-sticky',
content: {
title: phase === 'WORK' ? '🔥 Travail !' : '💨 Repos',
body: `${secondsLeft}s — Round ${currentRound}/${totalRounds}`,
sticky: true,
autoDismiss: false,
},
trigger: null,
})
}
```
### Ordre des transitions de phase
```
IDLE
↓ start()
GET_READY (getReadyDuration)
↓ temps écoulé
WORK (workDuration)
↓ temps écoulé
REST (restDuration)
↓ temps écoulé
→ si currentRound < totalRounds : retour à WORK
→ si currentRound === totalRounds ET currentCycle < totalCycles :
PAUSE_BETWEEN_CYCLES → WORK (round 1 du cycle suivant)
→ si currentRound === totalRounds ET currentCycle === totalCycles :
COMPLETE
↓ stop() ou auto-reset après 3s
IDLE
```
### Événements à émettre aux abonnés
```typescript
// À chaque changement de phase
emitEvent({ type: 'PHASE_CHANGED', from: prevPhase, to: nextPhase })
// À chaque fin de round
emitEvent({ type: 'ROUND_COMPLETED', round: currentRound })
// Décompte final (3, 2, 1)
if (secondsLeft <= 3) {
emitEvent({ type: 'COUNTDOWN_TICK', secondsLeft })
}
// Fin de séance
emitEvent({ type: 'SESSION_COMPLETE', totalSeconds: totalElapsedSeconds })
```
---
## Interface publique du hook
```typescript
export function useTimerEngine(): TimerState & TimerActions & {
addEventListener: (listener: TimerEventListener) => () => void
config: TimerConfig
}
```
Le retour doit permettre :
```typescript
const {
phase, secondsLeft, currentRound, totalRounds, isRunning, isPaused,
start, pause, resume, stop, skip,
addEventListener,
config,
} = useTimerEngine()
```
---
## Composant TimerDisplay — layout plein écran
```
┌─────────────────────────────┐ ← StatusBar cachée
│ [Exercice] Round 3/8 ●○○ │ ← Zone haute (20%) — fond semi-transparent
├─────────────────────────────┤
│ │
│ │
│ ┌───────┐ │
│ │ :17 │ │ ← Anneau + chiffre (50% de l'écran)
│ └───────┘ │ ← Police monospace, taille ~120px
│ │
│ ████████████░░░░░░░░░░░░░ │ ← Barre de progression totale (15%)
│ │
│ ⏸ ■ ⏭ │ ← Boutons discrets (15%) — Pause/Stop/Skip
└─────────────────────────────┘
```
Props obligatoires du composant :
```typescript
interface TimerDisplayProps {
state: TimerState
exerciseName: string
nextExerciseName: string
onPause: () => void
onStop: () => void
onSkip: () => void
}
```
### Animations requises
- **Fond** : transition de couleur animée entre phases (600ms ease-in-out)
- `Animated.timing` sur la backgroundColor avec interpolation de couleurs
- **Chiffre** : légère pulsation à chaque seconde (`Animated.sequence` scale 1→1.05→1)
- **Anneau** : `Animated.Value` sur stroke-dashoffset (SVG) ou rotation
- **Décompte final** : flash rouge sur les 3 dernières secondes
```typescript
// Interpolation de couleur entre phases
const backgroundAnim = useRef(new Animated.Value(0)).current
const backgroundColor = backgroundAnim.interpolate({
inputRange: [0, 1, 2, 3],
outputRange: [
PHASE_COLORS.IDLE,
PHASE_COLORS.GET_READY,
PHASE_COLORS.WORK,
PHASE_COLORS.REST,
],
})
// Déclencher quand la phase change
useEffect(() => {
const phaseIndex = { IDLE: 0, GET_READY: 1, WORK: 2, REST: 3, COMPLETE: 0 }
Animated.timing(backgroundAnim, {
toValue: phaseIndex[phase],
duration: 600,
useNativeDriver: false, // obligatoire pour backgroundColor
}).start()
}, [phase])
```
---
## expo-keep-awake — écran allumé pendant la séance
```typescript
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
// Dans useTimerEngine
useEffect(() => {
if (isRunning) {
activateKeepAwakeAsync('tabata-session')
} else {
deactivateKeepAwake('tabata-session')
}
}, [isRunning])
```
---
## Tests minimaux à écrire
```typescript
describe('useTimerEngine', () => {
it('démarre en phase IDLE avec la config par défaut', () => {})
it('passe à GET_READY quand start() est appelé', () => {})
it('passe à WORK après la fin de GET_READY', () => {})
it('passe à REST après la fin de WORK', () => {})
it('incrémente currentRound après chaque REST', () => {})
it('passe à COMPLETE après le dernier round', () => {})
it('retourne à IDLE après stop()', () => {})
it('skip() avance immédiatement à la phase suivante', () => {})
it('émet PHASE_CHANGED à chaque transition', () => {})
it('émet COUNTDOWN_TICK pour 3, 2, 1', () => {})
it('cleanup useEffect enlève tous les listeners', () => {})
})
```
---
## Erreurs classiques à éviter
```typescript
// ❌ Drift — setInterval seul
setInterval(() => setSecondsLeft(prev => prev - 1), 1000)
// ✅ Delta sur Date.now()
const remaining = Math.max(0, targetEndTimeRef.current - Date.now())
setSecondsLeft(Math.ceil(remaining / 1000))
// ❌ State React pour les valeurs temps-critique
const [targetEndTime, setTargetEndTime] = useState(0)
// ✅ Ref pour les valeurs utilisées dans les closures de timeout
const targetEndTimeRef = useRef(0)
// ❌ Pas de cleanup — memory leak
useEffect(() => {
setTimeout(tick, 100)
})
// ✅ Cleanup systématique
useEffect(() => {
return () => {
if (tickRef.current) clearTimeout(tickRef.current)
}
}, [])
```

View File

@@ -1,233 +0,0 @@
# Skill — Workflow de Développement TabataGo
> Lis ce skill en début de session ou quand tu commences une nouvelle feature.
## Principe fondamental
**Plan d'abord, code ensuite.** Pour toute feature non triviale :
1. Analyse les specs dans le PRD (`docs/PRD.md`)
2. Liste les edge cases et les dépendances
3. Propose l'architecture (types, hooks, composants)
4. Attends validation avant de générer du code
Ne jamais écrire de code avant d'avoir une architecture approuvée
sur les features cœur (timer, audio, exercices).
---
## Cycle de développement par feature
### Étape 1 — Lire les specs
Avant toute implémentation, lis la section PRD correspondante.
Identifie exactement :
- Les entrées/sorties du système
- Les états possibles
- Les cas limites (interruptions, background, erreurs)
### Étape 2 — Typer en premier
Commence TOUJOURS par le fichier `types.ts` de la feature.
Les types sont le contrat — ils précèdent l'implémentation.
```typescript
// Exemple pour le timer — types.ts
export type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
export interface TimerState {
phase: TimerPhase
secondsLeft: number
currentRound: number
totalRounds: number
isRunning: boolean
}
export interface TimerConfig {
workDuration: number // secondes, défaut 20
restDuration: number // secondes, défaut 10
rounds: number // défaut 8
getReadyDuration: number // secondes, défaut 10
}
export interface TimerActions {
start: () => void
pause: () => void
resume: () => void
stop: () => void
skip: () => void
}
```
**Ne passe à l'étape 3 que si les types semblent corrects.**
### Étape 3 — Implémenter le hook (logique métier)
Implémenter le hook central de la feature.
Règles :
- Aucun import depuis React Native UI dans les hooks métier
- Chaque hook expose un état + des actions (pattern Zustand-like)
- Les side effects sont dans useEffect avec cleanup systématique
- Logger les transitions d'état en dev (`__DEV__ && console.log(...)`)
### Étape 4 — Implémenter les composants
Les composants reçoivent tout par props — ils ne fetchent rien,
ne font aucune logique métier.
```typescript
// ✅ Correct
function TimerDisplay({ phase, secondsLeft, currentRound }: TimerDisplayProps) {
return <Text>{secondsLeft}</Text>
}
// ❌ Interdit
function TimerDisplay() {
const timer = useTimerEngine() // logique dans le composant
return <Text>{timer.secondsLeft}</Text>
}
```
### Étape 5 — Tester avant de continuer
Avant de passer à la feature suivante, écrire les tests minimaux :
- État initial correct
- Transitions de phase correctes
- Cleanup des side effects (pas de memory leak)
---
## Patterns obligatoires
### Gestion d'état avec state machine explicite
Toujours utiliser une state machine pour les flows complexes.
Jamais de booléens en cascade (`isRunning && !isPaused && !isComplete`).
```typescript
// ✅ State machine explicite
type TimerPhase = 'IDLE' | 'GET_READY' | 'WORK' | 'REST' | 'COMPLETE'
const VALID_TRANSITIONS: Record<TimerPhase, TimerPhase[]> = {
IDLE: ['GET_READY'],
GET_READY: ['WORK', 'IDLE'],
WORK: ['REST', 'IDLE'],
REST: ['WORK', 'COMPLETE', 'IDLE'],
COMPLETE: ['IDLE'],
}
function canTransition(from: TimerPhase, to: TimerPhase): boolean {
return VALID_TRANSITIONS[from].includes(to)
}
```
### Cleanup systématique des useEffect
```typescript
useEffect(() => {
const subscription = AppState.addEventListener('change', handleAppState)
return () => subscription.remove() // TOUJOURS un cleanup
}, [])
```
### AsyncStorage — wrapper typé uniquement
Ne jamais appeler AsyncStorage directement dans les composants.
Passer par un hook ou un service :
```typescript
// shared/utils/storage.ts
export async function saveTimerConfig(config: TimerConfig): Promise<void> {
await AsyncStorage.setItem('timer_config', JSON.stringify(config))
}
export async function loadTimerConfig(): Promise<TimerConfig | null> {
const raw = await AsyncStorage.getItem('timer_config')
return raw ? JSON.parse(raw) : null
}
```
### Constantes centralisées
```typescript
// shared/constants/timer.ts
export const TIMER_DEFAULTS = {
WORK_DURATION: 20,
REST_DURATION: 10,
ROUNDS: 8,
GET_READY_DURATION: 10,
} as const
// shared/constants/colors.ts
export const PHASE_COLORS = {
GET_READY: '#EAB308', // Jaune
WORK: '#F97316', // Orange
REST: '#3B82F6', // Bleu
COMPLETE: '#22C55E', // Vert
IDLE: '#1E1E2E', // Dark
} as const
```
---
## Gestion des erreurs
### Pattern try/catch dans les hooks async
```typescript
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
async function loadData() {
try {
setIsLoading(true)
setError(null)
const data = await fetchSomething()
setData(data)
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur inconnue')
} finally {
setIsLoading(false)
}
}
```
### Fallback dans les composants
Tout composant qui charge des ressources (GIF, audio) doit avoir
un état de fallback visible et non-bloquant.
---
## Checklist avant de soumettre du code
Avant de considérer une feature terminée, vérifier :
- [ ] Les types sont définis dans `types.ts`
- [ ] La logique est dans des hooks, pas dans des composants
- [ ] Tous les `useEffect` ont un cleanup
- [ ] Aucun `console.log` en dehors de `if (__DEV__)`
- [ ] Les constantes sont dans `shared/constants/`
- [ ] Les fonctions async ont un try/catch
- [ ] Un test minimal existe pour les cas principaux
- [ ] Le barrel export `index.ts` est mis à jour
---
## Workflow de debugging avec les logs Expo
Pour surveiller les logs en temps réel et donner du contexte à Claude :
```bash
# Terminal 1 — démarrer expo avec logs dans un fichier
npx expo start 2>&1 | tee .expo-logs/dev.log
# Ensuite dans Claude Code
> Surveille .expo-logs/dev.log et identifie les warnings
liés au module [nom du module en cours]
```
Ajouter des logs structurés dans les hooks critiques :
```typescript
if (__DEV__) {
console.log('[TimerEngine]', { phase, secondsLeft, currentRound })
}
```
---
## Commandes Claude Code utiles
| Situation | Commande |
|---|---|
| Début de projet / onboarding | `/init` |
| Session longue (>45 min) | `/compact` — focus sur la feature en cours |
| Avant chaque commit | `/review` |
| Quand Claude part en vrille | Donner un fichier précis : `@src/features/timer/hooks/useTimerEngine.ts` |
| Architecture à valider | Demander explicitement "ne génère pas de code, propose seulement l'architecture" |

View File

@@ -0,0 +1,192 @@
# =============================================================================
# TabataGo — PR → Build → WiFi/USB Direct Deploy → Wait LGTM → Merge
# =============================================================================
name: PR → Build → WiFi/USB Deploy → LGTM
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
jobs:
build-deploy:
name: Build & Deploy to iPhone (WiFi/USB)
runs-on: macos
timeout-minutes: 20
steps:
- name: Checkout
run: |
git clone --depth 1 -b ${{ github.head_ref }} https://x-access-token:${PR_TOKEN}@gitea.1000co.fr/${{ github.repository }}.git .
env:
PR_TOKEN: ${{ secrets.PR_API_TOKEN }}
REPO: ${{ github.repository }}
- name: Setup Xcode
run: |
xcodebuild -version
- name: Setup PATH
run: echo "/opt/homebrew/bin" >> $GITHUB_PATH
- name: Clean build artifacts
run: |
echo "🧹 Cleaning build artifacts..."
# Act runner caches
rm -rf ~/.cache/act/*/hostexecutor/tabatago-swift/build 2>/dev/null || true
# SPM caches are KEPT between runs — RevenueCat is 1.10 GiB and takes
# 5+ minutes to clone; re-cloning on every CI run would waste time
# and cause random failures on the act_runner sandbox.
# Xcode DerivedData — suppression COMPLÈTE (le bundle ID a changé,
# l'ancien dossier TabataGo-* peut être ignoré par le nouveau build)
rm -rf ~/Library/Developer/Xcode/DerivedData/*
# Module cache Xcode — des .pcm stale peuvent segfault le linker
rm -rf ~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex 2>/dev/null || true
rm -rf ~/Library/Caches/com.apple.dt.Xcode
# Build artifacts locaux (utilisés par le step "Build app")
rm -rf build/
rm -rf .build
rm -rf tabatago-swift/.build
rm -rf tabatago-swift/TabataGo.xcodeproj
rm -rf tabatago-swift/*.xcworkspace 2>/dev/null || true
# Package.resolved — peut être stale après un changement de bundle ID
find . -name "Package.resolved" -delete 2>/dev/null || true
find . -name ".package.resolved" -delete 2>/dev/null || true
echo "✅ Caches nettoyés"
- name: Install tools (xcodegen, node, ios-deploy)
run: |
brew install xcodegen node ios-deploy || true
- name: Generate Xcode project
run: |
cd tabatago-swift
xcodegen generate
- name: Resolve SPM packages
run: |
cd tabatago-swift
xcodebuild -resolvePackageDependencies \
-project TabataGo.xcodeproj \
-scmProvider system
- name: Build app
run: |
cd tabatago-swift
xcodebuild build \
-project TabataGo.xcodeproj \
-scheme TabataGo \
-configuration Debug \
-allowProvisioningUpdates \
-skipPackagePluginValidation \
-derivedDataPath ../build/derived \
-clonedSourcePackagesDirPath ../build/spm-cache \
SWIFT_ENABLE_EXPLICIT_MODULES=NO \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM=2MJF39L8VY
- name: Install to iPhone (WiFi → USB fallback)
run: |
UDID="00008120-000925CE3672201E"
APP=$(find build/derived -name 'TabataGo.app' -type d | head -1)
# 1. Try WiFi first (Xcode 15+ devicectl network discovery)
echo "📱 Trying WiFi install via devicectl..."
xcrun devicectl device install app --device "$UDID" "$APP" 2>&1 | tail -3
if [ ${PIPESTATUS[0]} -eq 0 ]; then
echo "✅ Installed via WiFi"
exit 0
fi
# 2. Fallback to USB via ios-deploy
echo "🔌 WiFi failed, trying USB..."
ios-deploy --bundle "$APP" --id "$UDID" --justlaunch
if [ $? -eq 0 ]; then
echo "✅ Installed via USB"
exit 0
fi
echo "❌ Install failed (both WiFi and USB)"
exit 1
- name: Post "Ready to test" comment
env:
GT_TOKEN: ${{ secrets.PR_API_TOKEN }}
GITEA_URL: https://gitea.1000co.fr
run: |
PR="${{ github.event.pull_request.number }}"
REPO="${{ github.repository }}"
curl -s -X POST \
-H "Authorization: token ${GT_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\":\"## 📱 Prêt à tester !\\n\\nL'app est déployée sur l'iPhone (USB/WiFi).\\n\\n- Teste les changements\\n- Reply **LGTM** pour merger\\n- Reply **KO** pour bloquer\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${PR}/comments"
wait-approval:
name: Wait for LGTM comment
needs: build-deploy
runs-on: macos
timeout-minutes: 120
steps:
- name: Poll for LGTM
env:
GT_TOKEN: ${{ secrets.PR_API_TOKEN }}
GITEA_URL: https://gitea.1000co.fr
run: |
PR="${{ github.event.pull_request.number }}"
REPO="${{ github.repository }}"
PR_TITLE="${{ github.event.pull_request.title }}"
API="${GITEA_URL}/api/v1/repos/${REPO}"
MAX=240
TRIES=0
echo "⏳ Attente LGTM sur PR #${PR}..."
LAST_ID=$(curl -s -H "Authorization: token ${GT_TOKEN}" \
"${API}/issues/${PR}/comments" | \
python3 -c "import json,sys; cs=json.load(sys.stdin); print(cs[-1]['id'] if cs else 0)" 2>/dev/null || echo 0)
while [ $TRIES -lt $MAX ]; do
sleep 30
TRIES=$((TRIES + 1))
COMMENTS=$(curl -s -H "Authorization: token ${GT_TOKEN}" \
"${API}/issues/${PR}/comments?since_id=${LAST_ID}")
if echo "$COMMENTS" | grep -qi '"body": *"LGTM"'; then
echo "✅ LGTM reçu ! Merge..."
curl -s -X POST \
-H "Authorization: token ${GT_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"Do\":\"merge\"}" \
"${API}/pulls/${PR}/merge"
echo "✅ Mergée."
exit 0
fi
if echo "$COMMENTS" | grep -qi '"body": *"KO"'; then
echo "❌ KO reçu. Bloquée."
exit 1
fi
if [ $((TRIES % 4)) -eq 0 ]; then
echo " ⏳ ... (${TRIES}/240, $(date +%H:%M))"
fi
done
echo "❌ Timeout 2h."
exit 1

View File

@@ -2,150 +2,42 @@ name: CI
on:
push:
branches: [main, master]
branches: [main]
pull_request:
branches: [main, master]
branches: [main]
jobs:
typecheck:
name: TypeScript
# ── Path filter — determines which downstream jobs run ──
changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
admin-web: ${{ steps.filter.outputs.admin-web }}
youtube-worker: ${{ steps.filter.outputs.youtube-worker }}
supabase-functions: ${{ steps.filter.outputs.supabase-functions }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: dorny/paths-filter@v3
id: filter
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
lint:
name: ESLint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
test:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests with coverage
run: npm run test:coverage
- name: Run component render tests
run: npm run test:render
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 7
- name: Coverage summary
if: always()
run: |
echo "## Test Coverage Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -f coverage/coverage-summary.json ]; then
echo '```' >> $GITHUB_STEP_SUMMARY
node -e "
const c = require('./coverage/coverage-summary.json').total;
const fmt = (v) => v.pct + '%';
console.log('Statements: ' + fmt(c.statements));
console.log('Branches: ' + fmt(c.branches));
console.log('Functions: ' + fmt(c.functions));
console.log('Lines: ' + fmt(c.lines));
" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
elif [ -f coverage/coverage-final.json ]; then
echo "Coverage report generated. Download the artifact for details." >> $GITHUB_STEP_SUMMARY
else
echo "Coverage report not found." >> $GITHUB_STEP_SUMMARY
fi
- name: Comment coverage on PR
if: github.event_name == 'pull_request' && always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let body = '## Test Coverage Report\n\n';
try {
const summary = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8'));
const total = summary.total;
const fmt = (v) => `${v.pct}%`;
const icon = (v) => v.pct >= 80 ? '✅' : v.pct >= 60 ? '⚠️' : '❌';
body += '| Metric | Coverage | Status |\n';
body += '|--------|----------|--------|\n';
body += `| Statements | ${fmt(total.statements)} | ${icon(total.statements)} |\n`;
body += `| Branches | ${fmt(total.branches)} | ${icon(total.branches)} |\n`;
body += `| Functions | ${fmt(total.functions)} | ${icon(total.functions)} |\n`;
body += `| Lines | ${fmt(total.lines)} | ${icon(total.lines)} |\n`;
} catch (e) {
body += '_Coverage summary not available._\n';
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('## Test Coverage Report')
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
filters: |
admin-web:
- 'admin-web/**'
- '.github/workflows/ci.yml'
youtube-worker:
- 'youtube-worker/**'
- '.github/workflows/ci.yml'
supabase-functions:
- 'supabase/functions/**'
- 'youtube-worker/**'
- '.github/workflows/ci.yml'
# ── Admin Web: Next.js ──
admin-web-test:
name: Admin Web Tests
name: Admin Web CI
needs: changes
if: needs.changes.outputs.admin-web == 'true'
runs-on: ubuntu-latest
defaults:
run:
@@ -168,19 +60,22 @@ jobs:
- name: Run unit tests
run: npx vitest run
continue-on-error: true
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test
continue-on-error: true
build-check:
name: Build Check
# ── YouTube Worker: Node.js microservice ──
youtube-worker-check:
name: YouTube Worker
needs: changes
if: needs.changes.outputs.youtube-worker == 'true'
runs-on: ubuntu-latest
needs: [typecheck, lint, test]
defaults:
run:
working-directory: youtube-worker
steps:
- uses: actions/checkout@v4
@@ -189,39 +84,78 @@ jobs:
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: youtube-worker/package-lock.json
- name: Install dependencies
run: npm ci
- name: Export web build
run: npx expo export --platform web
continue-on-error: true
- name: Validate syntax
run: node --check server.js
# ── Deploy: Supabase edge functions + YouTube worker ──
deploy-functions:
name: Deploy Edge Functions
name: Deploy
needs: [changes, admin-web-test, youtube-worker-check]
if: |
always() &&
github.ref == 'refs/heads/main' &&
github.event_name == 'push' &&
!contains(needs.*.result, 'failure') &&
(needs.changes.outputs.supabase-functions == 'true' ||
needs.changes.outputs.youtube-worker == 'true')
runs-on: ubuntu-latest
needs: [typecheck, lint, test]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Deploy to self-hosted Supabase
- name: Setup SSH
env:
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.SUPABASE_DEPLOY_PATH }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SUPABASE_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy Supabase Edge Functions
env:
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.SUPABASE_DEPLOY_PATH }}
run: |
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.DS_Store' \
-e "ssh -i ~/.ssh/deploy_key" \
supabase/functions/ \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \
"docker restart supabase-edge-functions"
- name: Deploy YouTube Worker
env:
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
WORKER_PATH: /opt/supabase/youtube-worker
run: |
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.DS_Store' \
-e "ssh -i ~/.ssh/deploy_key" \
youtube-worker/ \
"$DEPLOY_USER@$DEPLOY_HOST:$WORKER_PATH/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" "\
cd $WORKER_PATH && \
docker build -t youtube-worker:latest . && \
docker stop youtube-worker 2>/dev/null || true && \
docker rm youtube-worker 2>/dev/null || true && \
docker run -d \
--name youtube-worker \
--restart unless-stopped \
--network supabase_supabase-network \
-e SUPABASE_URL=\$(docker exec supabase-edge-functions printenv SUPABASE_URL) \
-e SUPABASE_SERVICE_ROLE_KEY=\$(docker exec supabase-edge-functions printenv SUPABASE_SERVICE_ROLE_KEY) \
-e SUPABASE_PUBLIC_URL=https://supabase.1000co.fr \
-e GEMINI_API_KEY=\$(cat /opt/supabase/.env.gemini 2>/dev/null || echo '') \
-e STORAGE_BUCKET=workout-audio \
-e PORT=3001 \
youtube-worker:latest"

12
.gitignore vendored
View File

@@ -54,3 +54,15 @@ coverage/
node-compile-cache/
.gitnexus
Config/Secrets.xcconfig
_Users_*
swift-generated-sources/
tabatago-swift/build/
# Swift Package Manager
Package.resolved
.build/
DerivedData/
# XcodeGen generated — NOT in version control
TabataGo.xcodeproj

651
AGENTS.md
View File

@@ -1,549 +1,142 @@
# AGENTS.md
# TabataGo — iOS Tabata Workout App
This file contains optimizations and best practices for working with this TabataFit Expo project.
> Tabata interval training app for iOS + watchOS with HealthKit integration.
---
## Agent Context
## 🚀 Command Optimizations
This project is part of the **Millian Team** — an AI agent engineering team.
When working here, agents should load the relevant skill:
- `senior-ios` — SwiftUI, Xcode, iOS/watchOS development
- `senior-backend` — Supabase backend
- `senior-devops` — Deploy infrastructure
- `po-pm` — Product vision, specs, validation
Use the following `rtk` commands for optimal performance:
## Tech Stack
| Command | rtk Equivalent | Speedup | Tokens Before | Tokens After | Savings |
|---------|----------------|---------|---------------|--------------|---------|
| `ls` / `tree` | `rtk ls` | 10x | 2,000 | 400 | -80% |
| `cat` / `read` | `rtk read` | 20x | 40,000 | 12,000 | -70% |
| `grep` / `rg` | `rtk grep` | 8x | 16,000 | 3,200 | -80% |
| `git status` | `rtk git status` | 10x | 3,000 | 600 | -80% |
| `git diff` | `rtk git diff` | 5x | 10,000 | 2,500 | -75% |
| `git log` | `rtk git log` | 5x | 2,500 | 500 | -80% |
| `git add/commit/push` | `rtk git` | 8x | 1,600 | 120 | -92% |
| `cargo test` / `npm test` | `rtk test` | 5x | 25,000 | 2,500 | -90% |
| `ruff check` | `rtk lint` | 3x | 3,000 | 600 | -80% |
| `pytest` | `rtk pytest` | 4x | 8,000 | 800 | -90% |
| `go test` | `rtk gotest` | 3x | 6,000 | 600 | -90% |
| `docker ps` | `rtk docker` | - | - | - | - |
| Layer | Tech |
|-------|------|
| App | SwiftUI, iOS 26.0+ |
| Watch | SwiftUI, watchOS 11.0+ |
| Widget | WidgetKit, Live Activities |
| Language | Swift 6.0 (strict concurrency) |
| Architecture | MVVM, @Observable, AppState central |
| Backend | Supabase (PostgreSQL, Auth, Storage) |
| Subscriptions | RevenueCat 5.x |
| Analytics | PostHog 3.x |
| Health | HealthKit (heart rate, calories, workouts) |
| Music | Apple Music integration + YouTube worker |
| Build | XcodeGen (project.yml → .xcodeproj) |
| Package Manager | Swift Package Manager |
### Usage Examples
## Project Structure
```
tabatago/
├── tabatago-swift/ # Main Xcode project
│ ├── project.yml # XcodeGen spec
│ ├── Config/
│ │ ├── Secrets.xcconfig # API keys (not committed)
│ │ └── Secrets.xcconfig.example
│ ├── TabataGo/ # iOS App
│ │ ├── App/ # App entry, state, root view
│ │ │ ├── TabataGoApp.swift
│ │ │ ├── RootView.swift
│ │ │ └── AppState.swift
│ │ ├── Views/
│ │ │ ├── Tabs/ # MainTabView, Home, Activity, Programs, Profile
│ │ │ ├── Player/ # Workout player (PlayerView, NowPlaying)
│ │ │ ├── Programs/ # ProgramDetail, BodyZone
│ │ │ ├── Onboarding/ # Onboarding flow
│ │ │ ├── Paywall/ # RevenueCat paywall
│ │ │ ├── Complete/ # Workout completion screen
│ │ │ ├── Settings/ # Settings, policies
│ │ │ └── Components/ # Shared UI components
│ │ ├── ViewModels/ # HomeVM, HealthVM, PlayerVM, PurchaseVM, MusicVM
│ │ ├── Models/ # WorkoutProgram, WorkoutSession, UserProfile, etc.
│ │ ├── Services/ # Supabase, HealthKit, Music, Audio, Purchase, Analytics
│ │ ├── Theme/ # App theme
│ │ ├── Utilities/ # Strings, Environment
│ │ └── Resources/ # Assets, Localizable, entitlements
│ ├── TabataGoWatch/ # watchOS App
│ │ ├── Views/ # WatchRoot, WatchIdle, WatchActivity, WatchPlayer
│ │ ├── Services/ # WatchConnectivity, WatchPlayerEngine
│ │ ├── Complications/ # Watch face complications
│ │ └── Resources/
│ ├── TabataGoWidget/ # iOS Widget + Live Activities
│ ├── TabataGoTests/ # Unit tests
│ └── TabataGoUITests/ # UI tests
├── supabase/ # Supabase config
│ ├── schema.sql
│ ├── seed.sql
│ ├── setup-admin.sql
│ └── migrations/ # SQL migrations (001-006)
└── youtube-worker/ # Node.js worker for YouTube music
├── server.js
├── package.json
└── Dockerfile
```
## Dependencies (SPM)
- **Supabase** 2.5+ — Backend SDK (Auth, Database, Storage)
- **RevenueCat** 5.0+ — In-app purchases
- **PostHog** 3.0+ — Analytics
## Features
- Tabata/interval workout player with configurable timers
- Pre-built workout programs by body zone
- HealthKit integration (heart rate, calories, workout saving)
- Apple Watch companion with real-time metrics
- Live Activities + Dynamic Island
- Music integration (Apple Music + YouTube)
- RevenueCat subscriptions
- Onboarding flow
- Activity tracking & workout history
## Commands
```bash
# Instead of: ls -la src/
rtk ls src/
# From tabatago-swift/
xcodegen generate # Generate .xcodeproj
open TabataGo.xcodeproj # Open in Xcode
# Instead of: cat package.json
rtk read package.json
# Instead of: grep -r "useEffect" src/
rtk grep "useEffect" src/
# Instead of: git status
rtk git status
# Instead of: npm test
rtk test
# Instead of: ruff check .
rtk lint
# In Xcode
Cmd+R → Run on simulator
Cmd+U → Run tests
```
---
## Configuration
## 📱 Expo Best Practices
- `Config/Secrets.xcconfig` contains API keys (not committed)
- Template: `Config/Secrets.xcconfig.example`
- Env vars: `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `REVENUECAT_API_KEY`, `POSTHOG_API_KEY`
### Development Workflow
## Current State
**CRITICAL: Always try Expo Go first before creating custom builds.**
- **Version**: 1.0 (build 2) — not yet released
- **State**: App functional, needs finishing work
- **Platforms**: iOS 26.0+, watchOS 11.0+
- **Requires**: macOS with Xcode 26+ to build
1. **Start with Expo Go**: Run `npx expo start` and scan the QR code with Expo Go
2. **Check if features work**: Test your app thoroughly in Expo Go
3. **Only create custom builds when required**
## Supabase Schema
#### When Custom Builds Are Required
Tables: `profiles`, `workout_sessions`, `workout_programs`, `download_jobs`, `music_genre`, etc.
See `supabase/schema.sql` and `supabase/migrations/` for full schema.
Use `npx expo run:ios/android` or `eas build` ONLY when using:
- **Local Expo modules** (custom native code in `modules/`)
- **Apple targets** (widgets, app clips, extensions via `@bacons/apple-targets`)
- **Third-party native modules** not included in Expo Go
- **Custom native configuration** that can't be expressed in `app.json`
## Team Rules
#### When Expo Go Works
1. **Swift Concurrency** — async/await everywhere, no completion handlers
2. **@Observable** — not @ObservableObject
3. **No force unwrap**`guard let` always, never `!`
4. **SwiftUI only** — no UIKit unless unavoidable
5. **XcodeGen** — project.yml is source of truth, not .xcodeproj
6. **Secrets** — never commit Secrets.xcconfig
7. **French** — all user-facing strings in French (Localizable.xcstrings)
Expo Go supports a huge range of features out of the box:
- All `expo-*` packages (camera, location, notifications, etc.)
- Expo Router navigation
- Most UI libraries (reanimated, gesture handler, etc.)
- Push notifications, deep links, and more
## Project Context
### Common Commands
```bash
# Development
npx expo start # Start development server
npx expo start --tunnel # If network issues
npx expo start --clear # Clear cache
npx tsc --noEmit # Type check
npx expo install <pkg> # Install Expo-compatible packages
# Building
npx eas-cli@latest build --profile development # Dev build
npx eas-cli@latest build -p ios --profile production --submit # Build and submit iOS
npx testflight # Quick TestFlight shortcut
# Development Client (when native code changes)
npx expo start --dev-client
```
### Code Style Rules
#### File Naming
- Use kebab-case for file names (e.g., `workout-card.tsx`)
- Never use special characters in file names
- Always remove old route files when moving or restructuring navigation
#### Imports
- Use path aliases from `tsconfig.json` instead of relative imports
- Prefer aliases over relative imports for refactors
- Always use import statements at the top of the file
#### Library Preferences
-`expo-audio` not `expo-av`
-`expo-video` not `expo-av`
-`expo-image` with `source="sf:name"` for SF Symbols
-`react-native-safe-area-context` not react-native SafeAreaView
-`process.env.EXPO_OS` not `Platform.OS`
-`React.use` not `React.useContext`
- ❌ Never use modules removed from React Native: Picker, WebView, SafeAreaView, AsyncStorage
- ❌ Never use legacy expo-permissions
- ❌ Never use intrinsic elements like 'img' or 'div' unless in webview
### Route Structure
```
app/
_layout.tsx # Root layout with tabs
(tabs)/ # Group routes for tabs
_layout.tsx # Tabs layout
index.tsx # Home tab
workouts.tsx # Workouts tab
activity.tsx # Activity tab
browse.tsx # Browse tab
profile.tsx # Profile tab
player/
[id].tsx # Workout player
```
#### Route Rules
- Routes belong in the `app` directory
- Never co-locate components, types, or utilities in the app directory
- Ensure the app always has a route that matches "/"
- Always use `_layout.tsx` files to define stacks
### UI Guidelines
#### Responsiveness
- Always wrap root component in a scroll view for responsiveness
- Use `<ScrollView contentInsetAdjustmentBehavior="automatic" />` instead of `<SafeAreaView>`
- `contentInsetAdjustmentBehavior="automatic"` should be applied to FlatList and SectionList
- Use flexbox instead of Dimensions API
- ALWAYS prefer `useWindowDimensions` over `Dimensions.get()`
#### Styling
- Prefer flex gap over margin and padding styles
- Prefer padding over margin where possible
- Inline styles not StyleSheet.create unless reusing styles is faster
- Use `{ borderCurve: 'continuous' }` for rounded corners
- ALWAYS use a navigation stack title instead of a custom text element
- When padding a ScrollView, use `contentContainerStyle` padding
- Add entering and exiting animations for state changes
#### Shadows
Use CSS `boxShadow` style prop. NEVER use legacy React Native shadow or elevation styles.
```tsx
<View style={{ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)" }} />
```
#### Text Styling
- Add the `selectable` prop to every `<Text/>` element displaying important data
- Counters should use `{ fontVariant: 'tabular-nums' }` for alignment
#### Safe Area
- Always account for safe area with stack headers, tabs, or ScrollView/FlatList `contentInsetAdjustmentBehavior="automatic"`
- Ensure both top and bottom safe area insets are accounted for
### Navigation Patterns
#### Stack Configuration
```tsx
import { Stack } from 'expo-router/stack';
<Stack
screenOptions={{
headerTransparent: true,
headerShadowVisible: false,
headerLargeTitleShadowVisible: false,
headerLargeStyle: { backgroundColor: "transparent" },
headerTitleStyle: { color: PlatformColor("label") },
headerLargeTitle: true,
headerBlurEffect: "none",
headerBackButtonDisplayMode: "minimal",
}}
>
<Stack.Screen name="index" options={{ title: "Home" }} />
</Stack>
```
#### Links with Previews
```tsx
import { Link } from 'expo-router';
<Link href="/settings">
<Link.Trigger>
<Pressable>
<Card />
</Pressable>
</Link.Trigger>
<Link.Preview />
<Link.Menu>
<Link.MenuAction title="Share" icon="square.and.arrow.up" />
<Link.MenuAction title="Delete" icon="trash" destructive />
</Link.Menu>
</Link>
```
#### Modal/Sheet Presentations
```tsx
// Modal
<Stack.Screen name="modal" options={{ presentation: "modal" }} />
// Form Sheet
<Stack.Screen
name="sheet"
options={{
presentation: "formSheet",
sheetGrabberVisible: true,
sheetAllowedDetents: [0.5, 1.0],
contentStyle: { backgroundColor: "transparent" },
}}
/>
```
### Data Fetching
#### Environment Variables
- Use `EXPO_PUBLIC_` prefix for client-side environment variables
- Never put secrets in `EXPO_PUBLIC_` variables (visible in built app)
- Restart the dev server after changing `.env` files
```bash
# .env
EXPO_PUBLIC_API_URL=https://api.example.com
EXPO_PUBLIC_SUPABASE_URL=your-supabase-url
```
#### API Client Pattern
```tsx
const BASE_URL = process.env.EXPO_PUBLIC_API_URL;
export const apiClient = {
get: async <T,>(path: string): Promise<T> => {
const response = await fetch(`${BASE_URL}${path}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
},
};
```
#### React Query Setup
```tsx
// app/_layout.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 2,
},
},
});
```
### EAS Build Configuration
```json
{
"cli": {
"version": ">= 16.0.1",
"appVersionSource": "remote"
},
"build": {
"production": {
"autoIncrement": true,
"ios": {
"resourceClass": "m-medium"
}
},
"development": {
"developmentClient": true,
"distribution": "internal"
}
},
"submit": {
"production": {}
}
}
```
### Testing & Type Checking
```bash
# TypeScript
npx tsc --noEmit
# Run unit tests (Vitest)
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage report
npm run test:coverage
# Run Maestro E2E tests
npm run test:maestro
# Lint
npx eslint .
```
#### Test Structure
```
src/__tests__/
setup.ts # Mocks and test configuration
stores/ # Zustand store tests
hooks/ # React hooks tests
services/ # Service layer tests
components/ # Component logic tests
data/ # Data validation tests
```
#### Coverage Goals
- **Stores**: 80%+ (business logic)
- **Services**: 80%+ (API integration)
- **Hooks**: 70%+ (timer, purchases)
- **Components**: 50%+ (critical UI)
### Key Takeaways
1. **Start simple**: Always test in Expo Go before creating custom builds
2. **Follow conventions**: Use Expo Router patterns, kebab-case filenames
3. **Use modern APIs**: Prefer `expo-audio`, `expo-video`, `expo-image`
4. **Handle safe areas**: Use `contentInsetAdjustmentBehavior="automatic"`
5. **Style with CSS**: Use `boxShadow` instead of legacy shadow props
6. **Type everything**: Use TypeScript strict mode, no `any`
7. **Secure tokens**: Use `expo-secure-store` for sensitive data
8. **Environment config**: Use `EXPO_PUBLIC_` prefix for client env vars
---
## 📝 Project Context
**TabataFit** — "Apple Fitness+ for Tabata"
- **Framework**: Expo SDK 52 (managed)
- **Navigation**: Expo Router v3
- **State**: Zustand + AsyncStorage
- **Video**: expo-av → HLS streaming
- **Audio**: expo-av (coaching + music)
- **Animations**: React Native Animated
- **Payments**: RevenueCat
- **Analytics**: PostHog
### Design System
```typescript
BACKGROUND: '#000000' // Pure black
SURFACE: '#1C1C1E' // Charcoal
BRAND: '#FF6B35' // Flame orange
REST: '#5AC8FA' // Ice blue
SUCCESS: '#30D158' // Energy green
```
### Phase Colors (Critical)
```typescript
PREP: '#FF9500' // Orange-yellow
WORK: '#FF6B35' // Flame orange
REST: '#5AC8FA' // Ice blue
COMPLETE: '#30D158' // Green
```
---
*Last updated: March 14, 2026*
# context-mode — MANDATORY routing rules
You have context-mode MCP tools available. These rules are NOT optional — they protect your context window from flooding. A single unrouted command can dump 56 KB into context and waste the entire session.
## BLOCKED commands — do NOT attempt these
### curl / wget — BLOCKED
Any shell command containing `curl` or `wget` will be intercepted and blocked by the context-mode plugin. Do NOT retry.
Instead use:
- `context-mode_ctx_fetch_and_index(url, source)` to fetch and index web pages
- `context-mode_ctx_execute(language: "javascript", code: "const r = await fetch(...)")` to run HTTP calls in sandbox
### Inline HTTP — BLOCKED
Any shell command containing `fetch('http`, `requests.get(`, `requests.post(`, `http.get(`, or `http.request(` will be intercepted and blocked. Do NOT retry with shell.
Instead use:
- `context-mode_ctx_execute(language, code)` to run HTTP calls in sandbox — only stdout enters context
### Direct web fetching — BLOCKED
Do NOT use any direct URL fetching tool. Use the sandbox equivalent.
Instead use:
- `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` to query the indexed content
## REDIRECTED tools — use sandbox equivalents
### Shell (>20 lines output)
Shell is ONLY for: `git`, `mkdir`, `rm`, `mv`, `cd`, `ls`, `npm install`, `pip install`, and other short-output commands.
For everything else, use:
- `context-mode_ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call
- `context-mode_ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context
### File reading (for analysis)
If you are reading a file to **edit** it → reading is correct (edit needs content in context).
If you are reading to **analyze, explore, or summarize** → use `context-mode_ctx_execute_file(path, language, code)` instead. Only your printed summary enters context.
### grep / search (large results)
Search results can flood context. Use `context-mode_ctx_execute(language: "shell", code: "grep ...")` to run searches in sandbox. Only your printed summary enters context.
## Tool selection hierarchy
1. **GATHER**: `context-mode_ctx_batch_execute(commands, queries)` — Primary tool. Runs all commands, auto-indexes output, returns search results. ONE call replaces 30+ individual calls.
2. **FOLLOW-UP**: `context-mode_ctx_search(queries: ["q1", "q2", ...])` — Query indexed content. Pass ALL questions as array in ONE call.
3. **PROCESSING**: `context-mode_ctx_execute(language, code)` | `context-mode_ctx_execute_file(path, language, code)` — Sandbox execution. Only stdout enters context.
4. **WEB**: `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` — Fetch, chunk, index, query. Raw HTML never enters context.
5. **INDEX**: `context-mode_ctx_index(content, source)` — Store content in FTS5 knowledge base for later search.
## Output constraints
- Keep responses under 500 words.
- Write artifacts (code, configs, PRDs) to FILES — never return them as inline text. Return only: file path + 1-line description.
- When indexing content, use descriptive source labels so others can `search(source: "label")` later.
## ctx commands
| Command | Action |
|---------|--------|
| `ctx stats` | Call the `stats` MCP tool and display the full output verbatim |
| `ctx doctor` | Call the `doctor` MCP tool, run the returned shell command, display as checklist |
| `ctx upgrade` | Call the `upgrade` MCP tool, run the returned shell command, display as checklist |
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relationships, 52 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/tabatago/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/tabatago/context` | Codebase overview, check index freshness |
| `gitnexus://repo/tabatago/clusters` | All functional areas |
| `gitnexus://repo/tabatago/processes` | All execution flows |
| `gitnexus://repo/tabatago/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
- **User**: Millian LMX (CEO)
- **Gitea**: https://gitea.1000co.fr/millianlmx/tabatago
- **Supabase**: Managed instance (URL in secrets)
- **App Store**: Not yet published

View File

@@ -217,7 +217,7 @@ Voir `.claude/skills/` pour les guides spécialisés.
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relationships, 52 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **tabatago** (3362 symbols, 9407 relationships, 129 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
@@ -229,19 +229,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/tabatago/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
@@ -249,25 +236,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
@@ -277,32 +245,6 @@ This project is indexed by GitNexus as **tabatago** (1839 symbols, 3401 relation
| `gitnexus://repo/tabatago/processes` | All execution flows |
| `gitnexus://repo/tabatago/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |

View File

@@ -0,0 +1,71 @@
# App Store Submission
## Prerequisites
Before triggering the CI pipeline, you need:
1. **Apple Developer Program membership** (paid, $99/year)
2. **App Store Connect access** with Admin or App Manager role
## Step 1: Create App Store Connect API Key
1. Go to [App Store Connect → Users and Access → Integrations → App Store Connect API](https://appstoreconnect.apple.com/access/integrations/api)
2. Click **Generate API Key** (or "+" button)
3. Name it: `GitHub Actions CI`
4. Set access to: **App Manager** (required for auto-signing + upload)
5. Download the `.p8` file immediately (you cannot re-download it later)
6. Note the **Key ID** and **Issuer ID** displayed on the page
## Step 2: Add GitHub Secrets
Go to your GitHub repo → **Settings → Secrets and variables → Actions → New repository secret**.
| Secret Name | Value |
|---|---|
| `APP_STORE_CONNECT_KEY_ID` | The Key ID from Step 1 (e.g., `ABC123XYZ`) |
| `APP_STORE_CONNECT_ISSUER_ID` | The Issuer ID from Step 1 (UUID format) |
| `APP_STORE_CONNECT_API_KEY_P8` | The **entire contents** of the `.p8` file, including `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----` lines |
## Step 3: Trigger the Submission
Push a version tag to the repository:
```bash
git tag v1.0.0
git push origin v1.0.0
```
Or run manually:
- Go to **Actions** tab → **App Store Submission****Run workflow**
## What Happens
1. GitHub Actions runner (macOS) checks out your code
2. Archives the app with Xcode auto-signing
3. Exports an App Store IPA
4. Uploads to App Store Connect
5. The first upload **automatically creates** the app record in App Store Connect (bundle ID: `fr.millianlmx.tabatago`)
## After Upload
1. Go to [App Store Connect → Apps](https://appstoreconnect.apple.com/apps)
2. Select **TabataGo**
3. Complete the **App Information** (description, screenshots, keywords, etc.)
4. Complete the **Pricing and Availability** section
5. Go to the new build under **TestFlight** or **App Store** tab
6. Submit for review
## Troubleshooting
| Symptom | Likely Cause |
|---|---|
| "No accounts with iTunes Connect access" | API key doesn't have App Manager permissions — recreate the key with correct access |
| "No profiles found" | The bundle ID `fr.millianlmx.tabatago` isn't registered yet — check Apple Developer portal |
| "Duplicate build number" | Build number already used — bump `CFBundleVersion` in `project.yml` and re-tag |
| "Authentication failed" | API key was revoked, expired, or secret is misspelled — verify secrets in repository settings |
## Build Number Increments
Each submission requires a unique build number. Edit `tabatago-swift/project.yml`:
- Change `CFBundleVersion: "2"``"3"` in all three targets (TabataGo, TabataGoWatch, TabataGoWatchWidget)
- Then regenerate the project: `cd tabatago-swift && xcodegen`

200
docs/ci-cd-setup.md Normal file
View File

@@ -0,0 +1,200 @@
# CI/CD Setup — iPhone Deploy Workflow
This guide covers setting up a **Gitea Actions** macOS runner to automatically build, test, and deploy **TabataGo** to a physical iPhone on every PR to `main`.
## Prerequisites
### Hardware
- **Mac** running macOS 15 (Sequoia) or later
- **Xcode 26+** installed (required for iOS 26.0 / watchOS 11.0 targets)
- **iPhone** (iOS 26+) with the Firebase App Tester app installed
### Software
- [Gitea Actions Runner](https://docs.gitea.com/usage/actions/act-runner) installed and registered
- `brew` (Homebrew) installed
- `xcodegen``brew install xcodegen` (also installed automatically by the workflow)
## 1. Register the macOS Runner
```bash
# On the Mac:
./act_runner register \
--instance https://gitea.1000co.fr \
--token <RUNNER_TOKEN> \
--name "macos-runner" \
--labels macos
# Start the runner
./act_runner daemon
```
> **Important:** The runner must have the label `macos` because the workflow uses `runs-on: macos`.
## 2. Configure Secrets
Add these secrets in the Gitea repository:
`Settings → Actions → Secrets`
| Secret Name | Description | How to Get |
|-------------|-------------|------------|
| `FIREBASE_APP_ID` | Firebase App ID (iOS app) | Firebase Console → Project Settings → General → App ID |
| `FIREBASE_SERVICE_ACCOUNT` | Base64-encoded Firebase service account JSON key | See [Firebase Setup](#6-firebase-app-distribution-setup) below |
| `GITEA_TOKEN` | Gitea API token with repo + pull request scope | `Gitea → Settings → Applications → Generate Token` |
### Gitea Token Permissions
The token needs:
- `read:repository` — to read PR metadata
- `write:issue` — to post LGTM comments
- `write:pull_request` — to merge approved PRs
## 3. Xcode Signing
The workflow uses **automatic code signing**. Ensure:
1. The Mac runner is signed into an Apple Developer account in Xcode:
```bash
# Verify:
xcrun security find-identity -v -p codesigning
```
2. The Apple ID used must be part of the team `2MJF39L8VY` (configured in `ExportOptions.plist`).
3. For first-time setup, open Xcode on the runner Mac and sign in:
```
Xcode → Settings → Accounts → Add Apple ID
```
## 4. Workflow Overview
The workflow at `.gitea/workflows/pr-iphone-deploy.yml` triggers on PRs to `main`:
```
PR Opened / Updated
Checkout + XcodeGen generate
Resolve SPM packages
Build + Archive IPA
Upload to Firebase App Distribution
Post "Ready to test" comment on PR
Wait for LGTM → Auto-merge
```
## 5. Manual Test Run
To test the workflow locally on the runner Mac:
```bash
# Simulate the build:
cd tabatago-swift
xcodegen generate
xcodebuild build \
-project TabataGo.xcodeproj \
-target TabataGo \
-sdk iphoneos \
-destination "generic/platform=iOS" \
-configuration Release \
-allowProvisioningUpdates \
-skipPackagePluginValidation \
CODE_SIGN_STYLE=Automatic
```
## 6. Firebase App Distribution Setup
### 6.1 Create a Firebase Project
1. Go to [Firebase Console](https://console.firebase.google.com)
2. Click **Add project** (or select an existing one)
3. Follow the wizard to create the project (Google Analytics is optional)
### 6.2 Enable App Distribution
1. In the Firebase Console, open your project
2. Navigate to **App Distribution** in the left sidebar
3. Click **Get started** to enable the service
### 6.3 Get the App ID
1. Go to **Project Settings** (gear icon) → **General**
2. Scroll down to **Your apps** → find the iOS app
3. Copy the **App ID** (looks like `1:123456789012:ios:abc123def456`)
### 6.4 Create a Service Account
1. Go to **Project Settings** → **Service accounts**
2. Click **Create service account** (or use Firebase Admin SDK → Generate new private key)
3. Give it a name like `github-actions-deploy`
4. Assign the role: **Firebase App Distribution Admin**
5. Click **Generate new private key** → download the JSON file
### 6.5 Encode the Service Account Key
```bash
# Convert the JSON key to a base64 string (single line, no wrapping)
base64 -i firebase-key.json | tr -d '\n'
```
Copy the output — this is your `FIREBASE_SERVICE_ACCOUNT` secret value.
### 6.6 Add Testers and Groups
1. In Firebase Console, go to **App Distribution** → **Testers & Groups**
2. Click **Add group** → name it `millian-test`
3. Click **Add testers** → enter Millian's email
4. Add the tester to the `millian-test` group
### 6.7 Install the Firebase App Tester on the iPhone
1. Send an invite to Millian's iPhone from the Firebase Console
2. Millian accepts the invite and installs the **Firebase App Tester** app from the App Store
3. Open the app and sign in with the invited Google account
4. The app will now receive builds pushed from the CI workflow
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "Code signing failed" | Sign into Xcode on the runner Mac, verify team membership |
| "No provisioning profile" | Run `-allowProvisioningUpdates` or open project in Xcode once |
| `xcodegen: command not found` | `brew install xcodegen` (workflow handles this) |
| Runner doesn't pick up job | Verify runner label is `macos` and runner is connected |
| Token permission errors | Ensure `GITEA_TOKEN` has `write:issue` and `write:pull_request` scopes |
| Firebase upload fails | Verify `FIREBASE_APP_ID` and `FIREBASE_SERVICE_ACCOUNT` secrets are set correctly |
| "App not found" in Firebase | Check that the iOS app is registered in Firebase with the correct bundle ID |
| Testers don't receive build | Verify tester is in the `millian-test` group and has accepted the invite |
## Compilation Error Troubleshooting
### xcodegen Clearing Signing Team
After running `xcodegen generate`, the `.xcodeproj` may have a cleared signing team. Always re-check your team settings:
```bash
cd tabatago-swift
xcodegen generate
# Then check that project.yml has the correct team ID in settings
```
### Complication Targets Missing Team ID
Targets like **TabataGoWatchWidget** (watchOS complications) require an explicit team ID in `project.yml` settings. If you see signing errors for the widget target:
1. Open `tabatago-swift/project.yml`
2. Find the `TabataGoWatchWidget` target section
3. Ensure it has an explicit `settings:` block with the team ID:
```yaml
target: TabataGoWatchWidget
settings:
base:
DEVELOPMENT_TEAM: "2MJF39L8VY"
```
### General Build Debugging
- Run `xcodegen generate` before every build to ensure the project is in sync
- Check `xcodebuild` output for specific file/line errors
- Verify SPM packages resolve: `xcodebuild -resolvePackageDependencies`

47
skills-lock.json Normal file
View File

@@ -0,0 +1,47 @@
{
"version": 1,
"skills": {
"core-data-expert": {
"source": "avdlee/core-data-agent-skill",
"sourceType": "github",
"skillPath": "core-data-expert/SKILL.md",
"computedHash": "b8d2829005b1f2fefbaa8af2ea7d7d64e2fbeca2f2172033176ad0780edc3970"
},
"swift-architecture-skill": {
"source": "efremidze/swift-architecture-skill",
"sourceType": "github",
"skillPath": "swift-architecture-skill/SKILL.md",
"computedHash": "67d3359424b19084631998def14666fd5a77284a45ac0353c41a86a7ed216923"
},
"swift-concurrency-pro": {
"source": "twostraws/swift-concurrency-agent-skill",
"sourceType": "github",
"skillPath": "swift-concurrency-pro/SKILL.md",
"computedHash": "dec65531b4bd37d15e6243dbb0d2d1f554b4f4087bcb2e8deb7273f570fa4069"
},
"swift-testing-pro": {
"source": "twostraws/swift-testing-agent-skill",
"sourceType": "github",
"skillPath": "swift-testing-pro/SKILL.md",
"computedHash": "90504b29146ccd7e88d8ba7244c6c4e4d2b410fb21bdd4ce578f10583b158481"
},
"swiftdata-pro": {
"source": "twostraws/swiftdata-agent-skill",
"sourceType": "github",
"skillPath": "swiftdata-pro/SKILL.md",
"computedHash": "2f979bad98ea3a6744084c5f93e27897f02e8d0ffe15dd03042e88aaae4da14c"
},
"swiftui-pro": {
"source": "twostraws/swiftui-agent-skill",
"sourceType": "github",
"skillPath": "swiftui-pro/SKILL.md",
"computedHash": "07033426e384295a4b49cf0b2ffdefd4098cae4af53fef16bc1f2d9281118c41"
},
"writing-for-interfaces": {
"source": "andrewgleave/skills",
"sourceType": "github",
"skillPath": "writing-for-interfaces/SKILL.md",
"computedHash": "fff061810c3e63b97fea546da1b86d88629f422a5d38d4ac13497b689a18419e"
}
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>2MJF39L8VY</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -1,96 +0,0 @@
{
"originHash" : "42d1f35b4500c2779457daf99841f2333a14d9a2965835305d89fd95beda836e",
"pins" : [
{
"identity" : "plcrashreporter",
"kind" : "remoteSourceControl",
"location" : "https://github.com/microsoft/plcrashreporter.git",
"state" : {
"revision" : "0254f941c646b1ed17b243654723d0f071e990d0",
"version" : "1.12.2"
}
},
{
"identity" : "posthog-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/PostHog/posthog-ios",
"state" : {
"revision" : "c3efdae383a5e7a5a88c34fd774e9d7dc915b9d4",
"version" : "3.55.0"
}
},
{
"identity" : "purchases-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/RevenueCat/purchases-ios",
"state" : {
"revision" : "bd63241b2258ea519020eb32a349db44fb44b119",
"version" : "5.68.0"
}
},
{
"identity" : "supabase-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/supabase/supabase-swift",
"state" : {
"revision" : "17261e93c60aa721e3c17312bfeb2ae6de3d6f8a",
"version" : "2.43.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
"version" : "1.7.0"
}
},
{
"identity" : "swift-clocks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
"version" : "1.0.6"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "476538ccb827f2dd18efc5de754cc87d77127a47",
"version" : "4.4.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca",
"version" : "1.5.1"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad",
"version" : "1.9.0"
}
}
],
"version" : 3
}

View File

@@ -1,79 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "92991789C3A5B2A5FACF07A1"
BuildableName = "TabataGo.app"
BlueprintName = "TabataGo"
ReferencedContainer = "container:TabataGo.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnableBacktraceRecording = "Yes">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "92991789C3A5B2A5FACF07A1"
BuildableName = "TabataGo.app"
BlueprintName = "TabataGo"
ReferencedContainer = "container:TabataGo.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "92991789C3A5B2A5FACF07A1"
BuildableName = "TabataGo.app"
BlueprintName = "TabataGo"
ReferencedContainer = "container:TabataGo.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D1E2CCCC7C9BD41029959883"
BuildableName = "TabataGoTests.xctest"
BlueprintName = "TabataGoTests"
ReferencedContainer = "container:TabataGo.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnableBacktraceRecording = "Yes">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>TabataGo.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
</dict>
<key>TabataGoTests.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>TabataGoUITests.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>TabataGoWatch.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>TabataGoWatchWidget.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>3945C3998B4B66F30759718C</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>90BAF2DB5D7456CD45975E26</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>92991789C3A5B2A5FACF07A1</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>D1E2CCCC7C9BD41029959883</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

View File

@@ -2,12 +2,13 @@ import Foundation
import Observation
/// Global app bootstrap state initialises all services once at launch.
@MainActor
@Observable
final class AppState {
var isBootstrapped = false
static let shared = AppState()
@MainActor
var isBootstrapped = false
func bootstrap() async {
guard !isBootstrapped else { return }
guard !AppEnvironment.isPreview else { isBootstrapped = true; return }
@@ -15,4 +16,6 @@ final class AppState {
AnalyticsService.shared.initialize()
isBootstrapped = true
}
private init() {}
}

View File

@@ -3,20 +3,19 @@ import SwiftData
extension Notification.Name {
static let skipTrackFromActivity = Notification.Name("skipTrackFromActivity")
// togglePauseFromActivity is declared in WorkoutActivityAttributes.swift (shared with widget)
}
@main
struct TabataGoApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.environment(AppState.shared)
.modelContainer(TabataGoSchema.container)
.task {
await appState.bootstrap()
await AppState.shared.bootstrap()
}
.onOpenURL { url in
if url.scheme == "tabatago", url.host == "skipTrack" {

View File

@@ -1,10 +1,19 @@
import ActivityKit
import Foundation
import AppIntents
#if canImport(SwiftUI)
import SwiftUI
#endif
// MARK: Shared notification names (used by LiveActivityIntent + PlayerView)
extension Notification.Name {
static let togglePauseFromActivity = Notification.Name("togglePauseFromActivity")
}
// MARK: Phase enum
enum WorkoutPhase: String, Codable, Hashable, Sendable, CaseIterable {
case prep
case warmup
@@ -32,9 +41,49 @@ enum WorkoutPhase: String, Codable, Hashable, Sendable, CaseIterable {
case .complete: return Color(red: 0.19, green: 0.82, blue: 0.35)
}
}
var dimColor: Color {
color.opacity(0.3)
}
var glowColor: Color {
color.opacity(0.5)
}
var gradientStops: [Gradient.Stop] {
switch self {
case .work:
return [
.init(color: Color(red: 1.0, green: 0.42, blue: 0.21).opacity(0.15), location: 0),
.init(color: .clear, location: 1),
]
case .rest, .interBlockRest:
return [
.init(color: Color(red: 0.35, green: 0.78, blue: 0.98).opacity(0.12), location: 0),
.init(color: .clear, location: 1),
]
case .prep, .warmup:
return [
.init(color: Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.12), location: 0),
.init(color: .clear, location: 1),
]
case .cooldown:
return [
.init(color: Color(red: 0.35, green: 0.78, blue: 0.98).opacity(0.08), location: 0),
.init(color: .clear, location: 1),
]
case .complete:
return [
.init(color: Color(red: 0.19, green: 0.82, blue: 0.35).opacity(0.15), location: 0),
.init(color: .clear, location: 1),
]
}
}
#endif
}
// MARK: Activity Attributes
struct WorkoutActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable, Sendable {
var exerciseName: String
@@ -48,5 +97,47 @@ struct WorkoutActivityAttributes: ActivityAttributes {
var trackArtist: String
var isPlaying: Bool
var isPaused: Bool
var blockIndex: Int = 0
var blockCount: Int = 0
var exerciseShortName: String = ""
var phaseElapsedSeconds: TimeInterval = 0
}
}
// MARK: Concurrency-safe throttle
private actor TogglePauseThrottle {
private var lastFireTime: Date = .distantPast
func shouldFire() -> Bool {
let now = Date()
guard now.timeIntervalSince(lastFireTime) > 3.0 else { return false }
lastFireTime = now
return true
}
}
// MARK: Live Activity Intents (run in the main app process)
struct TogglePauseIntent: LiveActivityIntent {
static let title: LocalizedStringResource = "Toggle Pause"
private static let throttle = TogglePauseThrottle()
@MainActor
func perform() async throws -> some IntentResult {
guard await Self.throttle.shouldFire() else {
print("[LiveActivityIntent] TogglePauseIntent throttled")
return .result()
}
print("[LiveActivityIntent] TogglePauseIntent.perform() fired")
NotificationCenter.default.post(name: .togglePauseFromActivity, object: nil)
// Small sleep keeps widget button in a "processing" visual state
try await Task.sleep(for: .milliseconds(400))
return .result()
}
}

View File

@@ -6425,6 +6425,122 @@
}
}
}
},
"onboarding.allowHealthAccess" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Health-Zugriff erlauben"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Allow Health Access"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Permitir acceso a Salud"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Autoriser l'accès à Santé"
}
}
}
},
"onboarding.healthAccess" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mit Apple Health verbinden"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Connect to Apple Health"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conectar con Apple Salud"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Connecter à Apple Santé"
}
}
}
},
"onboarding.healthAccessSubtitle" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Verfolge Kalorien und Herzfrequenz. Speichere Workouts in der Health App. Deine Daten bleiben privat und auf deinem Gerät."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Track calories and heart rate. Save workouts to your Health app. Your data stays private and on-device."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Registra calorías y frecuencia cardíaca. Guarda entrenamientos en la app Salud. Tus datos permanecen privados y en tu dispositivo."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Suivez les calories et la fréquence cardiaque. Enregistrez vos entraînements dans l'app Santé. Vos données restent privées et sur votre appareil."
}
}
}
},
"onboarding.notNow" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nicht jetzt"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Not Now"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ahora no"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pas maintenant"
}
}
}
}
},
"version" : "1.0"

View File

@@ -1,30 +1,38 @@
import Foundation
#if canImport(PostHog)
import PostHog
#endif
/// PostHog analytics mirrors the event taxonomy from the Expo app.
final class AnalyticsService: @unchecked Sendable {
static let shared = AnalyticsService()
#if canImport(PostHog)
private let apiKey: String =
Bundle.main.infoDictionary?["POSTHOG_API_KEY"] as? String ?? ""
private let host = "https://eu.posthog.com"
#endif
private init() {}
func initialize() {
#if canImport(PostHog)
guard !apiKey.isEmpty else { return }
let config = PostHogConfig(apiKey: apiKey, host: host)
config.captureApplicationLifecycleEvents = true
config.captureScreenViews = false // manual tracking
PostHogSDK.shared.setup(config)
#endif
}
// Screens
func screen(_ name: String, properties: [String: Any] = [:]) {
#if canImport(PostHog)
PostHogSDK.shared.screen(name, properties: properties)
#endif
}
// Onboarding
@@ -100,6 +108,8 @@ final class AnalyticsService: @unchecked Sendable {
// Private
private func capture(_ event: String, properties: [String: Any] = [:]) {
#if canImport(PostHog)
PostHogSDK.shared.capture(event, properties: properties.isEmpty ? nil : properties)
#endif
}
}

View File

@@ -19,7 +19,6 @@ actor HealthKitService {
[
HKWorkoutType.workoutType(),
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.heartRate),
]
}
@@ -87,20 +86,6 @@ actor HealthKitService {
try await builder.addSamples([sample])
}
// Heart rate samples (if captured during workout)
if let avgHR = data.averageHeartRate {
let hrType = HKQuantityType(.heartRate)
let hrUnit = HKUnit.count().unitDivided(by: .minute())
let hrQuantity = HKQuantity(unit: hrUnit, doubleValue: avgHR)
let hrSample = HKQuantitySample(
type: hrType,
quantity: hrQuantity,
start: data.startedAt,
end: data.completedAt
)
try await builder.addSamples([hrSample])
}
try await builder.endCollection(at: data.completedAt)
guard let workout = try await builder.finishWorkout() else {
throw HealthKitError.workoutSaveFailed

View File

@@ -175,7 +175,11 @@ enum L10n {
static let pill4MinWorkouts = LocalizedStringResource("onboarding.pill4MinWorkouts")
static let pillNoEquipment = LocalizedStringResource("onboarding.pillNoEquipment")
static let pillVoiceGuided = LocalizedStringResource("onboarding.pillVoiceGuided")
static let tabataDesc = LocalizedStringResource("onboarding.tabataDesc")
static let healthAccess = LocalizedStringResource("onboarding.healthAccess")
static let healthAccessSubtitle = LocalizedStringResource("onboarding.healthAccessSubtitle")
static let allowHealthAccess = LocalizedStringResource("onboarding.allowHealthAccess")
static let notNow = LocalizedStringResource("onboarding.notNow")
static let tabataDesc = LocalizedStringResource("onboarding.tabataDesc")
enum levelDesc {
static let beginner = LocalizedStringResource("onboarding.level.beginnerDesc")

View File

@@ -57,6 +57,8 @@ final class MusicPlayerViewModel: ObservableObject {
func play() {
guard audio.isMusicEnabled, player != nil else { return }
// Reactivate the audio session in case the system deactivated it while backgrounded
try? AVAudioSession.sharedInstance().setActive(true)
player?.volume = audio.musicVolume
player?.play()
}

View File

@@ -58,6 +58,8 @@ final class PlayerViewModel: ObservableObject {
private var warmupIndex: Int = 0
// Cooldown phase index
private var cooldownIndex: Int = 0
// Throttle rapid toggle from widget
private var lastToggleTimestamp: Date = .distantPast
private var currentBlock: TabataBlock? {
guard currentBlockIndex < program.blocks.count else { return nil }
@@ -91,6 +93,14 @@ final class PlayerViewModel: ObservableObject {
// Controls
func togglePlayPause() {
let now = Date()
guard now.timeIntervalSince(lastToggleTimestamp) > 0.6 else {
print("[PlayerVM] TogglePlayPause throttled (last tap was < 0.6s ago)")
return
}
lastToggleTimestamp = now
print("[PlayerVM] TogglePlayPause — isPaused=\(isPaused), isRunning=\(isRunning)")
if !isRunning {
startWorkout()
} else if isPaused {
@@ -106,6 +116,8 @@ final class PlayerViewModel: ObservableObject {
}
func abandonWorkout() {
isRunning = false
isPaused = false
timer?.invalidate()
stopActivitySyncTimer()
Task { try? await liveSession.end() }
@@ -125,7 +137,11 @@ final class PlayerViewModel: ObservableObject {
// Start HealthKit live session
Task {
try? await HealthKitService.shared.requestAuthorization()
guard await HealthKitService.shared.isAuthorized else {
print("[PlayerVM] HealthKit not authorized — skipping live session")
return
}
liveSession.onHeartRateUpdate = { [weak self] hr in
Task { @MainActor in self?.heartRate = hr }
}
@@ -384,10 +400,15 @@ final class PlayerViewModel: ObservableObject {
trackTitle: currentTrackTitle,
trackArtist: currentTrackArtist,
isPlaying: isPlayingMusic,
isPaused: isPaused
isPaused: isPaused,
blockIndex: currentBlockIndex + 1,
blockCount: program.blocks.count,
exerciseShortName: String(currentExercise?.nameEn.prefix(8) ?? ""),
phaseElapsedSeconds: max(0, Double(totalPhaseTime) - Double(timeRemaining))
)
if let existing = workoutActivity, existing.activityState != .active {
if let existing = workoutActivity,
existing.activityState == .ended || existing.activityState == .dismissed {
workoutActivity = nil
}
@@ -435,12 +456,14 @@ final class PlayerViewModel: ObservableObject {
}
func endActivity() async {
stopActivitySyncTimer()
guard let activity = workoutActivity else { return }
workoutActivity = nil
activityStateTask?.cancel()
activityStateTask = nil
nonisolated(unsafe) let safeActivity = activity
guard safeActivity.activityState == .active else { return }
guard safeActivity.activityState != .ended,
safeActivity.activityState != .dismissed else { return }
let finalState = WorkoutActivityAttributes.ContentState(
exerciseName: safeActivity.content.state.exerciseName,
phase: .complete,
@@ -452,7 +475,11 @@ final class PlayerViewModel: ObservableObject {
trackTitle: safeActivity.content.state.trackTitle,
trackArtist: safeActivity.content.state.trackArtist,
isPlaying: false,
isPaused: false
isPaused: false,
blockIndex: safeActivity.content.state.blockIndex,
blockCount: safeActivity.content.state.blockCount,
exerciseShortName: safeActivity.content.state.exerciseShortName,
phaseElapsedSeconds: safeActivity.content.state.phaseElapsedSeconds
)
await safeActivity.end(ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .immediate)
}
@@ -479,7 +506,11 @@ final class PlayerViewModel: ObservableObject {
activityStateTask = Task { @MainActor [weak self] in
for await state in activity.activityStateUpdates {
guard let self else { return }
if state == .stale || state == .ended || state == .dismissed {
if state == .stale {
// Stop sync timer, but keep the activity reference
// so endActivity() can still call .end() to properly dismiss it.
self.stopActivitySyncTimer()
} else if state == .ended || state == .dismissed {
self.workoutActivity = nil
self.stopActivitySyncTimer()
}

View File

@@ -12,7 +12,7 @@ struct OnboardingView: View {
@Environment(\.modelContext) private var context
enum Step: Int, CaseIterable {
case welcome, name, level, goal, frequency, ready
case welcome, name, level, goal, frequency, health, ready
var progress: Double { Double(rawValue) / Double(Step.allCases.count - 1) }
}
@@ -67,6 +67,7 @@ struct OnboardingView: View {
case .level: LevelStep(selection: $fitnessLevel)
case .goal: GoalStep(selection: $goal)
case .frequency: FrequencyStep(frequency: $weeklyFrequency, barriers: $selectedBarriers, allBarriers: barriers)
case .health: HealthStep(onContinue: { advance() })
case .ready: ReadyStep(name: name)
}
}
@@ -77,11 +78,13 @@ struct OnboardingView: View {
.animation(.spring(duration: 0.45), value: step)
// Pinned bottom button
PrimaryButton(label: buttonLabel, action: buttonAction)
.disabled(step == .name && name.trimmingCharacters(in: .whitespaces).isEmpty)
.padding(.horizontal, 32)
.padding(.bottom, 48)
.padding(.top, 16)
if step != .health {
PrimaryButton(label: buttonLabel, action: buttonAction)
.disabled(step == .name && name.trimmingCharacters(in: .whitespaces).isEmpty)
.padding(.horizontal, 32)
.padding(.bottom, 48)
.padding(.top, 16)
}
}
}
}
@@ -411,6 +414,68 @@ private struct FrequencyStep: View {
}
}
private struct HealthStep: View {
let onContinue: () -> Void
@State private var isRequesting = false
@State private var appeared = false
var body: some View {
VStack(spacing: 36) {
Spacer()
Image(systemName: "heart.text.square")
.font(.system(size: 80))
.foregroundStyle(Theme.brand.gradient)
OnboardingHeader(title: L10n.onboarding.healthAccess, subtitle: L10n.onboarding.healthAccessSubtitle)
// Primary button reuses shared PrimaryButton component
PrimaryButton(label: L10n.onboarding.allowHealthAccess, action: requestHealthAccess)
.disabled(isRequesting)
.padding(.horizontal, 32)
// Skip option
Button {
onContinue()
} label: {
Text(L10n.onboarding.notNow)
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(isRequesting)
Spacer()
}
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 14)
.onAppear {
withAnimation(.spring(duration: 0.45)) { appeared = true }
}
}
private func requestHealthAccess() {
guard !isRequesting else { return }
isRequesting = true
Task {
do {
try await HealthKitService.shared.requestAuthorization()
} catch {
print("[HealthStep] HealthKit authorization error: \(error)")
// Continue user can try later in Settings
}
let authorized = await HealthKitService.shared.isAuthorized
if authorized {
AnalyticsService.shared.healthKitPermissionGranted()
} else {
AnalyticsService.shared.healthKitPermissionDenied()
}
isRequesting = false
onContinue()
}
}
}
private struct ReadyStep: View {
let name: String
@State private var showContent = false

View File

@@ -166,6 +166,10 @@ struct PlayerView: View {
.onReceive(NotificationCenter.default.publisher(for: .skipTrackFromActivity)) { _ in
musicVM.skipTrack()
}
.onReceive(NotificationCenter.default.publisher(for: .togglePauseFromActivity)) { _ in
print("[PlayerView] Received togglePauseFromActivity notification (fallback)")
vm.togglePlayPause()
}
.navigationDestination(isPresented: $vm.isComplete) {
CompletionView(session: vm.completedSession, program: program, onDone: { dismiss() })
.navigationBarBackButtonHidden()

View File

@@ -317,5 +317,5 @@ struct ProgramRow: View {
#Preview {
HomeTab(previewVM: HomeViewModel(previewPrograms: [PreviewData.sampleProgram]))
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
.environment(AppState.shared)
}

View File

@@ -48,5 +48,5 @@ struct MainTabView: View {
#Preview {
MainTabView()
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
.environment(AppState.shared)
}

View File

@@ -139,5 +139,5 @@ struct ProfileRow: View {
#Preview {
ProfileTab()
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
.environment(AppState.shared)
}

View File

@@ -103,5 +103,5 @@ struct ProgramsTab: View {
#Preview {
ProgramsTab()
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
.environment(AppState.shared)
}

View File

@@ -13,7 +13,7 @@ struct TabataEntry: TimelineEntry {
struct TabataProvider: TimelineProvider {
private let sharedDefaults = UserDefaults(suiteName: "group.com.tabatago.app")
private let sharedDefaults = UserDefaults(suiteName: "group.fr.millianlmx.tabatago")
func placeholder(in context: Context) -> TabataEntry {
TabataEntry(date: Date(), streak: 7, lastWorkoutLabel: "Today")
@@ -98,6 +98,8 @@ struct RectangularComplicationView: View {
}
/// `.accessoryCorner` tiny bolt + streak digit in the corner
#if canImport(WatchKit)
@available(watchOS 10.0, *)
struct CornerComplicationView: View {
let entry: TabataEntry
@@ -111,6 +113,7 @@ struct CornerComplicationView: View {
.widgetLabel(String(format: String(localized: LocalizedStringResource("watch.complication.dayStreakFmt")), entry.streak))
}
}
#endif
// Widget definition
@@ -124,11 +127,11 @@ struct TabataGoComplication: Widget {
}
.configurationDisplayName("TabataGo")
.description(String(localized: LocalizedStringResource("watch.complication.description")))
.supportedFamilies([
.accessoryCircular,
.accessoryRectangular,
.accessoryCorner
])
#if canImport(WatchKit)
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryCorner])
#else
.supportedFamilies([.accessoryCircular, .accessoryRectangular])
#endif
}
}
@@ -142,8 +145,10 @@ struct TabataComplicationEntryView: View {
CircularComplicationView(entry: entry)
case .accessoryRectangular:
RectangularComplicationView(entry: entry)
#if canImport(WatchKit)
case .accessoryCorner:
CornerComplicationView(entry: entry)
#endif
default:
CircularComplicationView(entry: entry)
}
@@ -163,3 +168,12 @@ struct TabataComplicationEntryView: View {
} timeline: {
TabataEntry(date: .now, streak: 7, lastWorkoutLabel: "Today")
}
#if canImport(WatchKit)
@available(watchOS 10.0, *)
#Preview("Corner", as: .accessoryCorner) {
TabataGoComplication()
} timeline: {
TabataEntry(date: .now, streak: 7, lastWorkoutLabel: "Today")
}
#endif

View File

@@ -27,6 +27,6 @@
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>com.tabatago.app</string>
<string>fr.millianlmx.tabatago</string>
</dict>
</plist>

View File

@@ -6,7 +6,7 @@
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.tabatago.app</string>
<string>group.fr.millianlmx.tabatago</string>
</array>
</dict>
</plist>

View File

@@ -13,7 +13,7 @@ struct WatchActivityView: View {
@State private var isLoading = true
private let healthStore = HKHealthStore()
private let sharedDefaults = UserDefaults(suiteName: "group.com.tabatago.app")
private let sharedDefaults = UserDefaults(suiteName: "group.fr.millianlmx.tabatago")
var body: some View {
ScrollView {

View File

@@ -41,13 +41,38 @@ struct MusicLiveActivity: Widget {
LiveActivityMusicBars()
}
}
.background(
RadialGradient(
stops: [
.init(color: Color.green.opacity(context.state.isPlaying ? 0.1 : 0.03), location: 0),
.init(color: .clear, location: 1)
],
center: .center,
startRadius: 0,
endRadius: 30
)
)
.opacity(context.state.isPlaying ? 1.0 : 0.5)
.animation(.easeInOut(duration: 0.3), value: context.state.isPlaying)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.artist)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.accessibilityLabel(context.state.artist)
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(LinearGradient(
colors: [.green.opacity(0.4), .mint.opacity(0.3)],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.frame(width: 20, height: 20)
Text(context.state.artist)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.accessibilityLabel(context.state.artist)
}
.opacity(context.state.isPlaying ? 1.0 : 0.5)
.animation(.easeInOut(duration: 0.3), value: context.state.isPlaying)
}
DynamicIslandExpandedRegion(.trailing) {
Button(intent: SkipTrackIntent()) {
@@ -73,10 +98,14 @@ struct MusicLiveActivity: Widget {
.accessibilityLabel("Music playing")
} compactTrailing: {
HStack(spacing: 3) {
Image(systemName: "music.note")
.font(.system(size: 10))
.foregroundStyle(.green)
.accessibilityHidden(true)
if context.state.isPlaying {
LiveActivityMusicBars()
} else {
Image(systemName: "music.note")
.font(.system(size: 10))
.foregroundStyle(.green.opacity(0.5))
.accessibilityHidden(true)
}
Text(context.state.title)
.font(.system(size: 10, weight: .medium))
.lineLimit(1)

View File

@@ -3,18 +3,6 @@ import WidgetKit
import SwiftUI
import AppIntents
// MARK: - App Intents
struct TogglePauseIntent: AppIntent {
static let title: LocalizedStringResource = "Toggle Pause"
@MainActor
func perform() async throws -> some IntentResult & OpensIntent {
let url = URL(string: "tabatago://togglePause")!
return .result(opensIntent: OpenURLIntent(url))
}
}
// MARK: - Phase Icon
struct PhaseIcon: View {
@@ -242,6 +230,109 @@ struct DIBottomInfoRow: View {
}
}
// MARK: - DI Countdown Ring (48pt for Dynamic Island Expanded)
struct DICountdownRing: View {
let endDate: Date
let phaseDuration: TimeInterval
let isPaused: Bool
let frozenSeconds: Int
let phase: WorkoutPhase
let isUrgent: Bool
var isLuminanceReduced: Bool = false
private let diameter: CGFloat = 48
/// Thinner stroke during rest phases for a lighter visual feel.
private var lineWidth: CGFloat {
let isRestOrBreak = phase == .rest || phase == .interBlockRest
return (isRestOrBreak && !isPaused) ? 4 : 5
}
var body: some View {
let isRestOrBreak = phase == .rest || phase == .interBlockRest
TimelineView(.periodic(from: .now, by: 0.5)) { timeline in
let remaining = max(0, endDate.timeIntervalSince(timeline.date))
let activeProgress = phaseDuration > 0 ? CGFloat(remaining / phaseDuration) : 1.0
let frozenProgress = phaseDuration > 0 ? CGFloat(frozenSeconds) / CGFloat(phaseDuration) : 1.0
let arcProgress = isPaused ? frozenProgress : activeProgress
let strokeColor: Color = {
if isPaused { return phase.glowColor }
return isUrgent ? .orange : phase.color
}()
ZStack {
Circle()
.stroke(.white.opacity(0.08), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: arcProgress)
.stroke(
strokeColor,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(isLuminanceReduced ? nil : .linear(duration: 0.3), value: arcProgress)
CountdownText(
endDate: endDate,
isPaused: isPaused,
frozenSeconds: frozenSeconds,
isUrgent: isUrgent,
size: 14
)
}
// Work-phase scale pulse
.scaleEffect(phase == .work && !isPaused ? 1.05 : 1.0)
.animation(isLuminanceReduced ? nil : .spring(response: 0.3, dampingFraction: 0.6), value: phase == .work)
// Rest-phase visual cooling
.opacity(isRestOrBreak && !isPaused ? 0.85 : 1.0)
.animation(isLuminanceReduced ? nil : .easeInOut(duration: 1.0), value: isRestOrBreak)
.frame(width: diameter, height: diameter)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(isPaused
? "Timer paused at \(CountdownText.formatFrozenTime(frozenSeconds))"
: "Countdown timer")
}
}
// MARK: - Compact Countdown Ring (16pt for Dynamic Island Compact)
struct CompactCountdownRing: View {
let endDate: Date
let phaseDuration: TimeInterval
let isPaused: Bool
let frozenSeconds: Int
let phase: WorkoutPhase
let size: CGFloat = 16
let lineWidth: CGFloat = 2.5
var body: some View {
TimelineView(.periodic(from: .now, by: 1.0)) { timeline in
let remaining = max(0, endDate.timeIntervalSince(timeline.date))
let progress = phaseDuration > 0 ? CGFloat(remaining / phaseDuration) : 1.0
let frozen = phaseDuration > 0 ? CGFloat(frozenSeconds) / CGFloat(phaseDuration) : 1.0
let arc = isPaused ? frozen : progress
ZStack {
Circle()
.stroke(.white.opacity(0.12), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: arc)
.stroke(
isPaused ? phase.dimColor : phase.color,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(-90))
}
.frame(width: size, height: size)
}
}
}
// MARK: - Main Widget
struct WorkoutLiveActivity: Widget {
@@ -292,57 +383,171 @@ struct WorkoutLiveActivity: Widget {
let paused = context.state.isPaused
let frozenSeconds = Int(max(0, context.state.phaseEndDate.timeIntervalSinceNow))
let isUrgent = !paused && frozenSeconds > 0 && frozenSeconds <= 5
let progress = CGFloat(context.state.roundCurrent) / CGFloat(max(context.state.roundTotal, 1))
return DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
PhaseIcon(phase: phase, isPaused: paused, size: 16)
Text(paused ? "PAUSED" : phase.capitalized.uppercased())
.font(.subheadline.bold())
.foregroundStyle(paused ? .white.opacity(0.5) : phase.color)
}
Text("Rd \(context.state.roundCurrent)/\(context.state.roundTotal)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
.accessibilityLabel(paused
? "Paused, round \(context.state.roundCurrent) of \(context.state.roundTotal)"
: "\(phase.capitalized) phase, round \(context.state.roundCurrent) of \(context.state.roundTotal)")
EmptyView()
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.exerciseName)
.font(.body.weight(.semibold))
.foregroundStyle(paused ? .white.opacity(0.6) : .white)
.lineLimit(1)
.accessibilityLabel("Exercise \(context.state.exerciseName)")
@Environment(\.isLuminanceReduced) var isLuminanceReduced
let elapsedFraction: CGFloat = {
let duration = context.state.phaseDuration
guard duration > 0 else { return 0 }
if context.state.isPaused {
return CGFloat(context.state.phaseElapsedSeconds) / CGFloat(duration)
}
return min(max(0, 1 - CGFloat(max(0, context.state.phaseEndDate.timeIntervalSinceNow)) / CGFloat(duration)), 1)
}()
VStack(spacing: 4) {
Text(context.state.exerciseName)
.font(.headline.weight(.bold))
.foregroundStyle(.white)
.lineLimit(1)
.accessibilityLabel("Exercise \(context.state.exerciseName)")
let phaseSymbol: String = {
switch phase {
case .rest, .interBlockRest, .cooldown: return "snowflake"
case .complete: return "checkmark.circle.fill"
default: return "flame.fill"
}
}()
HStack(spacing: 4) {
Image(systemName: phaseSymbol)
.font(.system(size: 10))
.foregroundStyle(phase.color)
Text(paused ? "PAUSED" : phase.capitalized.uppercased())
.font(.caption.weight(.semibold))
.foregroundStyle(paused ? .white.opacity(0.4) : phase.color)
.animation(isLuminanceReduced ? nil : .easeInOut(duration: 0.8), value: phase)
Text("· Rd \(context.state.roundCurrent)/\(context.state.roundTotal)")
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityLabel(paused
? "Paused, round \(context.state.roundCurrent) of \(context.state.roundTotal)"
: "\(phase.capitalized), round \(context.state.roundCurrent) of \(context.state.roundTotal)")
HStack(alignment: .center, spacing: 8) {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(.white.opacity(0.1))
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(phase.color)
.frame(width: geo.size.width * elapsedFraction)
}
}
.frame(width: 120, height: 4)
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 14)
.frame(width: 48, alignment: .trailing)
}
.frame(maxWidth: .infinity, alignment: .center)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .combine)
.accessibilityLabel(paused
? "Paused, \(context.state.exerciseName), round \(context.state.roundCurrent) of \(context.state.roundTotal)"
: "\(phase.capitalized) phase, \(context.state.exerciseName), round \(context.state.roundCurrent) of \(context.state.roundTotal), \(CountdownText.formatFrozenTime(frozenSeconds)) remaining")
}
DynamicIslandExpandedRegion(.trailing) {
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 26)
.frame(width: 64, alignment: .trailing)
Button(intent: TogglePauseIntent()) {
Image(systemName: paused ? "play.fill" : "pause.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.8))
.frame(width: 24, height: 24)
.background(.white.opacity(0.12))
.clipShape(Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(paused ? "Resume workout" : "Pause workout")
.padding(8)
}
DynamicIslandExpandedRegion(.bottom) {
VStack(spacing: 8) {
WorkoutProgressBar(progress: progress, color: phase.color, isPaused: paused)
VStack(spacing: 6) {
// Stats row
ZStack {
HStack {
if context.state.heartRate > 0 {
HStack(spacing: 3) {
Image(systemName: "heart.fill")
.font(.system(size: 9))
.foregroundStyle(.red.opacity(0.7))
Text("\(Int(context.state.heartRate))")
.font(.system(size: 11).monospacedDigit())
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
}
.accessibilityLabel("Heart rate \(Int(context.state.heartRate)) beats per minute")
}
Spacer()
}
if !context.state.trackTitle.isEmpty {
HStack(spacing: 3) {
Image(systemName: "music.note")
.font(.system(size: 9))
.foregroundStyle(.green)
Text(context.state.trackTitle)
.font(.system(size: 10))
.lineLimit(1)
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
}
.accessibilityLabel("Now playing \(context.state.trackTitle)")
}
}
DIBottomInfoRow(
heartRate: context.state.heartRate,
trackTitle: context.state.trackTitle,
isPaused: paused
)
// Block indicator dots
if context.state.blockCount > 1 {
HStack(spacing: 3) {
ForEach(1...context.state.blockCount, id: \.self) { idx in
Circle()
.fill(idx <= context.state.blockIndex ? phase.color : .white.opacity(0.1))
.frame(width: 4, height: 4)
}
}
.accessibilityLabel("Block \(context.state.blockIndex) of \(context.state.blockCount)")
}
}
.padding(.top, 6)
.padding(.bottom, 8)
}
} compactLeading: {
PhaseIcon(phase: phase, isPaused: paused, size: 14)
.accessibilityLabel(paused ? "Paused" : "\(phase.capitalized) phase, workout in progress")
CompactCountdownRing(
endDate: context.state.phaseEndDate,
phaseDuration: context.state.phaseDuration,
isPaused: paused,
frozenSeconds: frozenSeconds,
phase: phase
)
.dynamicIsland(verticalPlacement: .belowIfTooWide)
.accessibilityLabel(paused
? "Workout paused"
: "\(phase.capitalized) phase, workout in progress")
} compactTrailing: {
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 12)
if !context.state.exerciseShortName.isEmpty {
Text(context.state.exerciseShortName)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(paused ? .white.opacity(0.3) : .white.opacity(0.7))
.lineLimit(1)
.accessibilityLabel("Exercise \(context.state.exerciseShortName)")
} else {
CountdownText(
endDate: context.state.phaseEndDate,
isPaused: paused,
frozenSeconds: frozenSeconds,
isUrgent: isUrgent,
size: 11
)
}
} minimal: {
PhaseIcon(phase: phase, isPaused: paused, size: 10)
.accessibilityLabel(paused ? "Paused" : "Workout in progress")
Circle()
.fill(phase.color)
.frame(width: 8, height: 8)
.modifier(PulseEffect(active: phase == .work && !paused))
.accessibilityElement(children: .ignore)
.accessibilityLabel(paused ? "Paused workout" : "\(phase.capitalized) phase — workout in progress")
}
.keylineTint(phase.color)
.contentMargins(.leading, 8, for: .expanded)
@@ -536,7 +741,10 @@ private struct PulseEffect: ViewModifier {
trackTitle: "Lose Yourself",
trackArtist: "Eminem",
isPlaying: true,
isPaused: false
isPaused: false,
blockIndex: 1,
blockCount: 4,
exerciseShortName: "BURPEES"
)
}
@@ -554,7 +762,10 @@ private struct PulseEffect: ViewModifier {
trackTitle: "Stronger",
trackArtist: "Kanye West",
isPlaying: true,
isPaused: false
isPaused: false,
blockIndex: 1,
blockCount: 4,
exerciseShortName: "MTN CLMB"
)
}
@@ -572,7 +783,10 @@ private struct PulseEffect: ViewModifier {
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: true
isPaused: true,
blockIndex: 2,
blockCount: 4,
exerciseShortName: "JMP SQTS"
)
}
@@ -590,7 +804,10 @@ private struct PulseEffect: ViewModifier {
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: false
isPaused: false,
blockIndex: 4,
blockCount: 4,
exerciseShortName: ""
)
}
@@ -608,7 +825,10 @@ private struct PulseEffect: ViewModifier {
trackTitle: "Till I Collapse",
trackArtist: "Eminem",
isPlaying: true,
isPaused: false
isPaused: false,
blockIndex: 1,
blockCount: 4,
exerciseShortName: "BURPEES"
)
WorkoutActivityAttributes.ContentState(
exerciseName: "High Knees",
@@ -621,7 +841,31 @@ private struct PulseEffect: ViewModifier {
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: false
isPaused: false,
blockIndex: 2,
blockCount: 4,
exerciseShortName: "HI KNEE"
)
}
#Preview("Dynamic Island Expanded — Rest", as: .dynamicIsland(.expanded), using: WorkoutActivityAttributes()) {
WorkoutLiveActivity()
} contentStates: {
WorkoutActivityAttributes.ContentState(
exerciseName: "Mountain Climbers",
phase: .rest,
phaseEndDate: .now.addingTimeInterval(7),
phaseDuration: 10,
roundCurrent: 1,
roundTotal: 8,
heartRate: 120,
trackTitle: "Stronger",
trackArtist: "Kanye West",
isPlaying: true,
isPaused: false,
blockIndex: 1,
blockCount: 4,
exerciseShortName: "MTN CLMB"
)
}
@@ -639,7 +883,10 @@ private struct PulseEffect: ViewModifier {
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: false
isPaused: false,
blockIndex: 1,
blockCount: 4,
exerciseShortName: "BURPEES"
)
}
@@ -657,6 +904,9 @@ private struct PulseEffect: ViewModifier {
trackTitle: "",
trackArtist: "",
isPlaying: false,
isPaused: false
isPaused: false,
blockIndex: 1,
blockCount: 4,
exerciseShortName: "BURPEES"
)
}

View File

@@ -1,7 +1,7 @@
name: TabataGo
options:
bundleIdPrefix: com.tabatago
bundleIdPrefix: fr.millianlmx.tabatago
deploymentTarget:
iOS: "26.0"
watchOS: "11.0"
@@ -15,7 +15,11 @@ settings:
SWIFT_VERSION: "6.0"
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
SWIFT_STRICT_CONCURRENCY: complete
SWIFT_EMIT_MODULE_FOR_EXPLICIT_BUILD: NO
GCC_TREAT_WARNINGS_AS_ERRORS: NO
SWIFT_TREAT_WARNINGS_AS_ERRORS: NO
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
DEVELOPMENT_TEAM: "2MJF39L8VY"
packages:
Supabase:
@@ -24,9 +28,6 @@ packages:
RevenueCat:
url: https://github.com/RevenueCat/purchases-ios
from: "5.0.0"
PostHog:
url: https://github.com/PostHog/posthog-ios
from: "3.0.0"
targets:
TabataGo:
@@ -46,7 +47,7 @@ targets:
properties:
CFBundleDisplayName: TabataGo
CFBundleShortVersionString: "1.0"
CFBundleVersion: "1"
CFBundleVersion: "2"
UILaunchScreen:
UIColorName: ""
UIImageName: ""
@@ -66,19 +67,18 @@ targets:
com.apple.developer.healthkit.access:
- health-records
com.apple.security.application-groups:
- group.com.tabatago.app
- group.fr.millianlmx.tabatago
dependencies:
- package: Supabase
product: Supabase
- package: RevenueCat
product: RevenueCat
- package: PostHog
product: PostHog
- target: TabataGoWatch
embed: true
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app
PRODUCT_BUNDLE_IDENTIFIER: fr.millianlmx.tabatago
INFOPLIST_FILE: TabataGo/Resources/Info.plist
CODE_SIGN_ENTITLEMENTS: TabataGo/Resources/TabataGo.entitlements
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
@@ -110,9 +110,9 @@ targets:
properties:
CFBundleDisplayName: TabataGo
CFBundleShortVersionString: "1.0"
CFBundleVersion: "1"
CFBundleVersion: "2"
WKApplication: true
WKCompanionAppBundleIdentifier: com.tabatago.app
WKCompanionAppBundleIdentifier: fr.millianlmx.tabatago
NSHealthShareUsageDescription: "TabataGo reads your heart rate and calories during workouts."
NSHealthUpdateUsageDescription: "TabataGo saves workout data to Apple Health directly from your Watch."
entitlements:
@@ -120,13 +120,13 @@ targets:
properties:
com.apple.developer.healthkit: true
com.apple.security.application-groups:
- group.com.tabatago.app
- group.fr.millianlmx.tabatago
dependencies:
- target: TabataGoWatchWidget
embed: true
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app.watchkitapp
PRODUCT_BUNDLE_IDENTIFIER: fr.millianlmx.tabatago.watchkitapp
INFOPLIST_FILE: TabataGoWatch/Resources/Info.plist
CODE_SIGN_ENTITLEMENTS: TabataGoWatch/Resources/TabataGoWatch.entitlements
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
@@ -147,12 +147,12 @@ targets:
properties:
CFBundleDisplayName: TabataGoWidget
CFBundleShortVersionString: "1.0"
CFBundleVersion: "1"
CFBundleVersion: "2"
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app.watchkitapp.widget
PRODUCT_BUNDLE_IDENTIFIER: fr.millianlmx.tabatago.watchkitapp.widget
TARGETED_DEVICE_FAMILY: "4"
WATCHOS_DEPLOYMENT_TARGET: "11.0"
@@ -166,7 +166,7 @@ targets:
- target: TabataGo
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app.tests
PRODUCT_BUNDLE_IDENTIFIER: fr.millianlmx.tabatago.tests
TabataGoUITests:
type: bundle.ui-testing
@@ -178,4 +178,14 @@ targets:
- target: TabataGo
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app.uitests
PRODUCT_BUNDLE_IDENTIFIER: fr.millianlmx.tabatago.uitests
schemes:
TabataGo:
build:
targets:
TabataGo: all
run:
config: Debug
archive:
config: Release