romailkhan/relax

★ 1Forks 0TypeScriptGitHub ↗Compare

README

Relax

A minimal iOS app that uses haptic feedback to guide restless users back to a state of calm. Relax monitors your physical state through phone sensors, detects signs of anxiety (irregular breathing, restlessness), and offers low-friction, tactile interventions—without requiring you to read instructions or switch context.

What it does

Relax runs in the foreground and continuously processes accelerometer data to infer:

  • Respiratory rhythm — During stillness, subtle torso motion from breathing is extracted via signal processing. Irregular or rapid breathing is linked to autonomic dysregulation.
  • Stillness vs restlessness — Stress often shows up as fidgeting and shifts. Micro-motions are classified into still, restless, or active motion states.
  • Anxiety score — Respiratory irregularity, elevated breathing rate, and restlessness are combined into a 0–100 score. When the score crosses a threshold, you’re gently prompted to use calming tools.

The app offers three haptic-based tools:

  1. Breathe — A breathing guide that expands and contracts in sync with haptic pulses, guiding you from your current rate toward a calmer pace.
  2. Fidget — Bubble wrap and a circular spinner you can trace with your finger, with haptic feedback on each tick.
  3. Breathe+ — Continuous CoreHaptics breathing at 10 BPM while feeling a deep haptic rumble that swells and fades with each breath cycle.
  4. Scratch – Finger movement calibrator which incentivizes slower scrubbing via a Skia scratch-card drawing game. Scratch to reveal Monet's Water Lilies while feeling a deep haptic rumble.

Features

  • Signal processing pipeline: magnitude, Butterworth high-pass/band-pass, sliding-window features, motion classification, autocorrelation-based respiratory rate estimation
  • Minimal dark UI with no instructional text
  • Haptic feedback for breathing guide and fidget toys
  • Foreground monitoring with optional screen keep-awake during sessions

Installation

Prerequisites

  • Bun (JavaScript runtime and package manager)
  • Expo Go on your iPhone (for development), or an iOS simulator
  • iOS device with motion sensors (for real sensor data)

Setup

  1. Clone the repository and navigate into it:

    cd relax
  2. Install dependencies with Bun:

    bun install
  3. Start the development server:

    bun run start

    Or run directly on iOS:

    bun run ios
  4. Open the app:

    • Expo Go: Scan the QR code in the terminal with your iPhone camera
    • iOS Simulator: Press i in the terminal (or bun run ios)

Permissions

On first launch, the app will request Motion & Fitness access to read accelerometer and gyroscope data for breathing and restlessness detection.

Usage

  1. Monitor — Tap “Start Monitoring” on the dashboard. Place the phone on a desk or in a pocket. The app will show your respiratory rate, motion state, and anxiety score.
  2. Breathe — Open the Breathe tab, tap to begin. Follow the expanding and contracting circle; haptics sync with each breath.
  3. Fidget — Open the Fidget tab. Choose bubbles (tap to pop) or spinner (trace your finger around the circle). Each tick triggers haptic feedback.

Tech stack

  • Expo (SDK 54) with React Native
  • expo-sensors — Accelerometer and gyroscope
  • expo-haptics — Haptic feedback (Breathe/Fidget tabs)
  • expo-router — File-based routing
  • react-native-reanimated — Animations (breathing circle, background glow)
  • react-native-gesture-handler — Touch gestures
  • @shopify/react-native-skia — GPU canvas for scratch card
  • zustand — State management

Development notes

CoreHaptics native module (modules/core-haptics/)

A local Expo module bridging Apple's CoreHaptics framework for true continuous haptic patterns with intensity curves.

Autolinking setup — all of these are required for Expo to discover a local module:

File Purpose
modules/core-haptics/package.json Package identity for autolinking search
modules/core-haptics/expo-module.config.json Must include "podspecPath" — without it, resolve silently excludes the module even though search finds it
modules/core-haptics/CoreHapticsExpoModule.podspec CocoaPods build config; name must NOT collide with system frameworks (CoreHapticsExpoModule, not CoreHaptics)
Root expo-module.config.json Sets "nativeModulesDir": "./modules" so autolinking looks there

Haptic engine learnings:

  • CHHapticAdvancedPatternPlayer with loopEnabled = true silently fails on device — the player starts but produces no haptic output. Use a timer-driven approach instead: play one cycle with the basic CHHapticPatternPlayer, then schedule the next cycle with a Timer.
  • Set engine.isAutoShutdownEnabled = false to prevent the engine from stopping between cycles.
  • Sharpness near 0 → deep warm rumble. Sharpness near 1 → buzzy/crisp.
  • Layering transient events (.hapticTransient) on top of continuous events (.hapticContinuous) creates effective punctuation at breath transitions.

Skia scratch card (components/scratch-card.tsx)

<Group layer> does not work on physical iPhones. The standard Skia compositing approach for scratch cards:

<Group layer>                              // saveLayer
  <Rect color="cover"/>                    // solid overlay
  <Group blendMode={BlendMode.DstOut}>     // erase mode
    <Path .../>                            // scratch strokes
  </Group>
</Group>

This works in the iOS Simulator but renders as a white rectangle on physical devices. BlendMode.Clear has the same issue — it punches to transparency, but opaque={false} on Canvas doesn't produce a transparent backing on device either.

Working approach — ImageShader on strokes:

<Rect color="#16161F"/>              // dark cover (plain rect, no compositing)
<Path style="stroke" ...>
  <ImageShader image={artwork} fit="cover" rect={...}/>
</Path>

Each stroke is textured with the hidden image. No blend modes, no layers, no transparency needed. The artwork is "revealed" because the strokes paint it on top of the dark cover.

Gesture → Skia threading:

Skia objects (Skia.Path.Make(), path.lineTo()) live on the JS thread. RNGH gesture worklets run on the UI thread. Creating or mutating Skia paths inside worklets crashes the app. Solution: worklets pass raw numbers via runOnJS to useCallback handlers that do all Skia work on JS. Use useRef alongside useState to avoid stale closures.

Speed-adaptive stroke width:

  • velocityX/velocityY from Pan gestures are in pts/sec, typically 200–5000+ on iOS
  • Slow drag (< 100 px/s) → fat stroke (60px); fast flick (> 3000 px/s) → thin line (4px)
  • Deceleration bonus: prevSpeed - currentSpeed adds up to 40px when braking
  • Dwell expansion: timer grows stroke 5px/60ms while finger is stationary, up to 120px
  • Cubic curve (t³) keeps the stroke fat for moderate speeds; only fast movement shrinks it

Build tips

bun install
npx expo run:ios                          # simulator
npx expo run:ios --device <UDID>          # physical device

Native module changes (Swift, podspec) require a full rebuild. JS changes hot-reload via Metro.

If Metro serves stale bundles after native rebuilds:

lsof -ti :8081 | xargs kill -9
npx expo start --clear

Contributors

wandiliuromailkhan

Issues