Native watchOS + iOS companion app
Apple Watch Companion App
Surflink ships a native Apple Watch + iOS companion in the apple/ directory, built with SwiftUI and managed by XcodeGen.
Targets
| Target | Platform | Description |
|---|---|---|
| SurfLink | iOS 17+ | Companion app for auth + watch pairing |
| SurfLinkWatch | watchOS 10+ | Coach and student watch interfaces |
| SurfLinkWidgets | watchOS 10+ | WidgetKit complications |
| Shared | Swift Package | Shared models, services, connectivity |
Layout
apple/
├── project.yml # XcodeGen project definition
├── Shared/ # Swift package: Models, Services, Extensions
├── SurfLink/ # iOS companion (entry, Views, Services)
├── SurfLinkWatch/ # watchOS app (CoachViews, StudentViews, Components)
└── SurfLinkWidgets/ # WidgetKit complications
Generate and open
cd apple
xcodegen generate
open SurfLink.xcodeproj
Prerequisites: Xcode 16+, brew install xcodegen, and an Apple Developer account with watchOS provisioning.
Features
- iOS companion: Supabase login, watch pairing (auth tokens transferred via WatchConnectivity), settings.
- Coach watch app: session overview, quick actions (timer, notifications), roster, conditions.
- Student watch app: drill timer, session status, achievement haptics, quick stats.
- Complications: next lesson, conditions, session count, streak.
- Surf-training capture: an
HKWorkoutSessionrecords GPS, heart rate, calories, and high-rate motion, then uploads a session for analysis.
Surf-training capture pipeline
The watch records a session and uploads it to POST /api/wearable/sessions (bearer-auth via getRequestUser), which stores summary columns on wearable_sessions and the full raw timeseries as a sidecar in the session-data bucket (samples_url).
- Sensors:
CMMotionManagerdevice-motion at up to 100 Hz (userAcceleration+gravity+rotationRate+attitude),kCLLocationAccuracyBestForNavigationGPS (speed/course/accuracy),HKLiveWorkoutBuilderactive energy,CMAltimeteraltitude, and best-effort water temperature. - One clock: every sample is stamped against a single monotonic clock as
ms-since-started_at, so GPS and IMU share a timeline for fusion. - Sidecar v2: the wire shape (
apple/Shared/WearableModels.swift) isversion: 2, storing both gravity-separateduserAccelerationand a derived felt-g for back-compat with v1 readers. Adaptive sampling throttles the IMU when the wearer is still. - Offline-first session picker: the iPhone fetches the wearer's day-of training sessions (
GET /api/wearable/my-schedule) and pushes them to the watch over WatchConnectivityapplicationContext(alongside the auth tokens), cached inUserDefaults. The watch Start Surf screen lists those sessions so a recording carriesathlete_id+session_idfrom the first sample — and it still works with the watch fully offline mid-surf. - Automatic sync (the surfer is the signal): clips align to the watch with no gear and no coach action. When the filmed athlete's watch is live,
who-is-surfingreturns itswearableSessionIdand the clip is locked to that exact watch session at capture time (deterministic). Otherwise: in-app and camera-roll clips carry a shared-clockcapture_start_utc/PHAsset.creationDatefor an exact offset; any other camera is aligned by motion cross-correlation of watch vs. pose-derived speed (wearableAutoSync.js), with per-clip results + confidence inwearable_clip_sync. An on-camera raised-armsync_markerremains an optional fallback — it is no longer required.
Server-side derivation
summarizeSamples (src/lib/wearable.js) runs the activity classifier (classifyActivity in src/lib/wearableFusion.js) over the raw samples to produce ride/paddle/pop-up/turn/rest segments with per-event confidence, plus the metric columns surfaced in the UI. Related endpoints:
| Route | Purpose |
|---|---|
POST /api/wearable/sessions/[id]/rederive | Re-run the classifier over retained raw samples to backfill an old session |
DELETE /api/wearable/sessions/[id] | Hard-delete a session + its sample sidecar (coach/admin only; labels + clip-sync cascade) |
GET /api/wearable/my-schedule | The wearer's day-of training sessions, pushed to the watch's Start picker |
GET/POST/DELETE /api/wearable/labels | Coach ground-truth event labels (wearable_event_labels) |
GET /api/wearable/accuracy | Precision/recall/timing-error of detectors vs labels |
Per-athlete calibration lives in athletes.wearable_calibration (jsonb) and is read by the classifier. See the migration supabase/migrations/20260627190000_wearable_metrics.sql.
Auth and push
The iOS app logs in via Supabase and transfers tokens to the watch, which stores them in the Keychain and uses the shared AuthService to refresh. The watch registers for APNs and records its token in push_tokens, so web-originated notifications (lessons, achievements, messages) arrive on the wrist.