Compare commits
58 Commits
revamp-tim
...
ci/xcode-n
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d9a89e637 | |||
| 97b61d3afb | |||
| a9eda61967 | |||
| a382ad62dc | |||
| 069b7d417f | |||
| 63fbae3698 | |||
| 4b7548501a | |||
| 5079de0fb1 | |||
| 52346fb4ac | |||
| abf926e4bc | |||
| 7c50282284 | |||
| 6cc25300d1 | |||
| 09e77b477c | |||
| 5c73d6bf29 | |||
| d614e72031 | |||
| 3ca3e54837 | |||
| 4e2e807fd9 | |||
| b48178f167 | |||
| c45e85edb6 | |||
| 00e02b7166 | |||
| 2aa312ab61 | |||
| 595140e2f9 | |||
| afc161c8ce | |||
| 58177102a4 | |||
| 9739a739f5 | |||
| 7fb44198e7 | |||
| 8ce38332b8 | |||
| 3202ea9b5b | |||
| f14186aeab | |||
| 60ac624487 | |||
| a6ea4ca904 | |||
| dde01ce957 | |||
| 5b67aea7ce | |||
| ebfaa15e38 | |||
| 86fb683428 | |||
| a48ee0007a | |||
| cfe8e8cd1b | |||
| fdf4b6d558 | |||
| 44cebf834c | |||
| da62d6fa58 | |||
| 96654d61c7 | |||
| 1b3ce24fc9 | |||
| f5a777aee8 | |||
| 836cf9a16d | |||
| 7464357787 | |||
| 8f3979c8a0 | |||
| 1d4d43f3f2 | |||
| e43f197139 | |||
| 46bf8cae9b | |||
| 9b06c0ae5c | |||
| 6a659c41fa | |||
| 7a02a8949e | |||
| 001062c6d3 | |||
| bd81ce92ad | |||
| 77c17046d5 | |||
|
|
310124ad63 | ||
|
|
72ad247136 | ||
| f71ba55e8b |
192
.gitea/workflows/pr-iphone-deploy.yml
Normal file
192
.gitea/workflows/pr-iphone-deploy.yml
Normal 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
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -54,4 +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
|
||||
|
||||
593
AGENTS.md
593
AGENTS.md
@@ -1,491 +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** (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.
|
||||
|
||||
## 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"})`.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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 |
|
||||
|
||||
## 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
|
||||
|
||||
@@ -44,7 +44,7 @@ Or run manually:
|
||||
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: `com.tabatago.app`)
|
||||
5. The first upload **automatically creates** the app record in App Store Connect (bundle ID: `fr.millianlmx.tabatago`)
|
||||
|
||||
## After Upload
|
||||
|
||||
@@ -60,7 +60,7 @@ Or run manually:
|
||||
| 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 `com.tabatago.app` isn't registered yet — check Apple Developer portal |
|
||||
| "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 |
|
||||
|
||||
|
||||
200
docs/ci-cd-setup.md
Normal file
200
docs/ci-cd-setup.md
Normal 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
47
skills-lock.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,62 +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>1</integer>
|
||||
</dict>
|
||||
<key>TabataGoTests.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</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>
|
||||
<key>TabataGoWidget.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -137,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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,6 @@
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>com.tabatago.app</string>
|
||||
<string>fr.millianlmx.tabatago</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -112,7 +112,7 @@ targets:
|
||||
CFBundleShortVersionString: "1.0"
|
||||
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
|
||||
@@ -152,7 +152,7 @@ targets:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user