6.5 KiB
6.5 KiB
SwimBuddy
Swimming workout tracker with Apple Watch and WearOS companion apps. Expo 57 + React Native 0.86 + NativeWind v5 + Drizzle ORM + Swift Watch target + Kotlin WearOS target.
Commands
npx expo start # Dev server
npx expo run:ios # Build & run on iOS
npx expo prebuild -p ios # Regenerate ios/ dir (safe — targets/ persists)
npx expo prebuild -p ios && node scripts/add-sentry-watch.js # Prebuild + add SentrySPM to watch target
npx drizzle-kit generate # Regenerate SQL migrations after schema changes
WearOS:
./scripts/build-wearos.sh debug # Build debug APK (reads version from app.json)
./scripts/build-wearos.sh release # Build release APK (reads version from app.json)
./scripts/generate-wearos-icon.sh assets/icon.png # Generate launcher icons
No npm test or lint scripts exist yet. No babel.config.js — NativeWind v5 uses CSS-first config.
Project Structure
src/
features/<name>/screens/ # Screen components (registered in navigators)
features/<name>/components/ # Feature-specific UI
features/<name>/hooks/ # Feature-specific hooks
components/ # Shared UI primitives (flat)
hooks/ # Shared hooks
navigation/ # React Navigation (RootNav, TabNav, feature stacks)
db/ # Drizzle schema, client, relations, operations
providers/ # DatabaseProvider, SettingsProvider (MMKV)
utils/ # formatTime, calculateSwolf, uuid, etc.
constants/ # strokes, poolLengths, colors
types/ # workout.ts, common.ts
modules/watch-connectivity/ # Expo native module bridging WCSession (Apple Watch)
modules/wear-connectivity/ # Expo native module bridging Wear Data Layer (WearOS)
targets/watch/ # Apple Watch app (Swift/SwiftUI) — survives prebuild
targets/wearos/ # WearOS app (Kotlin/Compose) — survives prebuild
Key Conventions
- Path aliases:
@/,@features/,@components/,@db/, etc. (tsconfig.json) - Feature-based folder structure with type-based shared layer
- NativeWind v5:
classNameprop, no babel config, CSS-first. UseclassNamenotStyleSheet classNametypes:nativewind-env.d.tsat root — don't delete- lightningcss pinned to 1.30.1 in package.json overrides — required by NativeWind v5
Watch Target (Apple Watch)
- Lives in
targets/watch/, NOT inios/— survivesexpo prebuild --clean - Config:
targets/watch/expo-target.config.js - Build: open
ios/SwimBuddy.xcworkspacein Xcode, select Watch scheme - Uses App Groups (
group.nl.guido-it.swimbuddy) for data sharing - WCSession for real-time messaging,
transferUserInfofor reliable offline sync - HealthKit for swim workout tracking (indoor swimming)
- Swift types
StoredWorkout,StoredSet,CompletedLapinWorkoutManager.swift - SentrySPM:
@bacons/apple-targetsdoesn't supportswiftPackagesyet. Runnode scripts/add-sentry-watch.jsafterexpo prebuildto add it automatically. For EAS builds, this runs automatically via theeas-build-post-installhook in package.json.
WearOS Target
- Lives in
targets/wearos/— survivesexpo prebuild --clean - Kotlin + Jetpack Compose with Wear OS Compose libraries
- Build:
./scripts/build-wearos.shor opentargets/wearos/in Android Studio - Uses Wear Data Layer API for phone↔watch communication
- Health Services Client for swim workout tracking
- Namespace:
nl.guidoit.swimbuddy
Database
- expo-sqlite + Drizzle ORM (SQLite dialect, expo driver)
- Schema:
src/db/schema.ts— tables: templates, workouts, workoutSessions, sets, laps (laps references workoutSessions, not workouts) - Relations:
src/db/relations.ts - CRUD:
src/db/operations.ts - Client:
src/db/client.ts— exportsdbandDATABASE_NAME - Migrations in
drizzle/— generated bynpx drizzle-kit generate
WCSession Gotchas (Critical)
These are hard-won bugs that will waste hours if rediscovered:
NSNullcrashessendMessage: JSnullbecomesNSNullin ObjC, causingWCErrorCodePayloadUnsupportedTypes(Code 7010). Always stripnull/undefinedvalues before sending viacleanForWCSession().- Two delegate methods conflict: Having both
session(_:didReceiveMessage:)ANDsession(_:didReceiveMessage:replyHandler:)on the same delegate causes iOS to deliver neither. Watch usesdidReceiveMessage(no replyHandler); phone usesdidReceiveMessage:replyHandler:for bidirectional sync. isReachableis unreliable: Onlytruewhen both apps are foreground. Checksession.activationState == .activatedinstead for connection readiness.- Phone
sendMessagemust usereplyHandler: nil: Otherwise JS thread blocks waiting for a reply that watch never sends. Use fire-and-forget for workout plans; usetransferUserInfofor data sync. - Watch
suiteNamevsUserDefaults.standard: Watch only needs local storage, so useUserDefaults.standard— the App Groups suite name is only needed for phone↔watch shared data. - Phone
pendingEventspolling: Phone pollspollEvents()every 2s because Expo Modules'sendEventAPI doesn't work onAppContext. Watch data arrives viadidReceiveUserInfodelegate → stored inpendingEventsarray.
Watch ↔ Phone Data Flow
- Phone → Watch:
sendMessagewith workout plan (fire-and-forget, promise resolves immediately with["status":"sent"]) - Watch → Phone:
transferUserInfowith completed workout data (reliable, works in background) - Phone receives:
pollEvents()called from JS every 2s,saveWatchCompletedWorkout()writes to SQLite
UI Conventions
- Swipe-to-delete on lists uses
SwipeableRowcomponent (react-native-gesture-handler) - BuilderScreen pool length is a 25m/50m toggle (not read-only from settings)
- Templates and Stats features removed from navigation (DB schema preserved but UI deleted)
- Ionicons used for bottom tab icons (
@expo/vector-icons)
Gotchas
metro.config.jshas.sqlinsourceExts— required for Drizzle migration importsexpo prebuildclears and regeneratesios/by default. Use--no-cleanto preserve- Watch target compiles as separate Xcode target — not a RN module
react-native-cssis a NativeWind v5 peer dependency — don't remove@bacons/apple-targetsplugin must be in app.json plugins array- WearOS uses same
applicationIdas iOS bundle identifier — don't change without updating both
Expo Docs
Expo SDK 57: https://docs.expo.dev/versions/v57.0.0/