---
type: reference
title: "CAMDOM — Gesture System"
description: "Complete documentation of the gesture architecture: pan swipe-to-connect, hold-to-disconnect, hold-to-stop-alarm, haptic feedback integration, retry mechanism, and the shouldBlock pattern."
tags: [camdom, gestures, haptics, reanimated, react-native-gesture-handler, touch-id]
timestamp: "2026-07-20"
---

# CAMDOM Gesture System

The gesture system is the primary interaction layer of CAMDOM. It orchestrates BLE connection lifecycle through two distinct gesture paradigms: a **Reanimated-driven pan gesture** for initiating connections, and **TouchableOpacity-based hold gestures** for terminating connections and silencing alarms. Every gesture is fused with haptic feedback and state-gated by the `shouldBlock` SharedValue.

Source: `app/index.tsx`, `modules/ble-manager/src/BleManager.ts`

---

## 1. Pan Gesture (Swipe-to-Connect)

### Configuration

```ts
const gesture = Gesture.Pan()
  .onStart(...)
  .onChange(...)
  .onEnd(...)
```

The gesture is rendered via `<GestureDetector gesture={gesture}>` wrapping an `<Animated.View>` positioned absolutely at the top of the screen. The touch target is a **200x200px invisible circle** (`gestureRadius = 100`, `borderRadius: gestureRadius`), centered horizontally at `left: width / 2 - gestureRadius`. Its vertical position is driven by `y` SharedValue via `translateY`.

### Gesture Dimensions

| Parameter | Value | Source |
|-----------|-------|--------|
| `gestureRadius` | 100 | Hardcoded constant |
| Touch target size | 200 x 200 px | `gestureRadius * 2` |
| `y` initial value | `height / 4` | Computed from screen dimensions |
| `cy` (red ball Y) initial | `height - height / 3 + 80` | Derived from screen height |
| `blueBallInitialY` | `height / 3 + 20` | Top position for blue ball |
| `redBallInitialY` | `height - height / 3 + 20` | Bottom position for red ball |

### onStart

```ts
.onStart((e) => {
  shouldBlock.value =
    appState.value === "connected" ||
    appState.value === "alarm" ||
    appState.value === "synced" ||
    appState.value === "scanning" ||
    appState.value === "connecting";

  runOnJS(Haptics.impactAsync)(ImpactFeedbackStyle.Soft);
})
```

**Two actions on gesture start:**

1. **State evaluation**: Reads `appState.value` and sets `shouldBlock` to `true` if the app is in any active state (connected, alarm, synced, scanning, connecting). Only `disconnected` and `migrating` allow the gesture to proceed. The blocking decision is captured once at the start of the gesture -- it does not re-evaluate mid-gesture.

2. **Haptic feedback**: Fires `ImpactFeedbackStyle.Soft` via `runOnJS` bridge. This provides immediate tactile confirmation that the gesture was recognized, regardless of whether it will be blocked.

### onChange

```ts
.onChange((e) => {
  if (shouldBlock.value) {
    return;
  }
  y.value += e.changeY;
  y.value = clamp(y.value, height / 4, redBallInitialY);
})
```

**Clamping logic:**

- The ball cannot move above `height / 4` (the upper bound -- the "swipe down" starting area)
- The ball cannot move below `redBallInitialY` (the lower bound -- where the red ball sits)
- Movement is incremental via `e.changeY` (delta from previous frame), not absolute
- When `shouldBlock` is `true`, all movement is suppressed -- the gesture handler returns immediately without modifying `y`

The `clamp` function is imported from `react-native-reanimated` and operates on the UI thread without bridge crossing.

### onEnd

```ts
.onEnd((e) => {
  if (shouldBlock.value) return;

  if (e.absoluteY >= 500) {
    y.value = withSpring(redBallInitialY, { damping: 100 }, () => {
      showRedBall.value = false;
      appState.value = "scanning";
      retries.value = 0;
      hasConnectionError.value = false;
    });
  } else {
    showRedBall.value = true;
    y.value = withSpring(
      blueBallInitialY,
      { damping: 100 },
      () => {
        appState.value = "disconnected";
        retries.value = 7;
        hasConnectionError.value = false;
      },
    );
  }
})
```

**Two branches based on `e.absoluteY >= 500`:**

**Threshold met (absoluteY >= 500) -- Connection initiated:**
- Blue ball springs to `redBallInitialY` (bottom position, overlapping the red ball)
- Spring animation: `damping: 100` (critically damped -- fast settle, no overshoot)
- On animation completion callback:
  - `showRedBall.value = false` -- hides the red ball (triggers `useAnimatedReaction` -> `setShouldStartScan(true)`)
  - `appState.value = "scanning"` -- enters scanning state
  - `retries.value = 0` -- resets retry counter
  - `hasConnectionError.value = false` -- clears any previous error state

**Threshold not met -- Gesture cancelled:**
- `showRedBall.value = true` -- keeps red ball visible
- Blue ball springs back to `blueBallInitialY` (top position)
- On completion:
  - `appState.value = "disconnected"` -- ensures clean state
  - `retries.value = 7` -- resets retries to max (effectively disabling retry since the check is `retries.value <= 5`)
  - `hasConnectionError.value = false`

### The 500px Threshold

The value `500` in `e.absoluteY >= 500` is an absolute Y coordinate in screen pixels. Since `e.absoluteY` represents the finger's vertical position on screen, and the gesture starts near the top (`height / 4`), a value of 500 means the user has dragged their finger roughly to the middle-lower portion of the screen. This is a deliberate UX choice: the user must make a decisive downward swipe, not a casual flick. On shorter screens (e.g., iPhone SE at 568px height), this means swiping nearly to the bottom. On taller screens (iPhone 15 Pro Max at 932px), it is roughly 54% down the screen.

---

## 2. Hold-to-Disconnect

### Component

```tsx
{appCurrentState === "synced" && (
  <TouchableOpacity
    style={{
      zIndex: 1000,
      position: "absolute",
      borderWidth: 1,
      borderColor: colors.background,
      height: 80 * 1.3,    // 104px
      width: 80 * 1.3,     // 104px
      borderRadius: gestureRadius,  // 100 (circular)
      justifyContent: "center",
      alignItems: "center",
      bottom: height / 7,
      right: width / 2 - (80 * 1.3) / 2,
    }}
    delayLongPress={517}
    onPressIn={...}
    onLongPress={...}
    onPressOut={...}
  >
```

The button only renders when `appCurrentState === "synced"`. It is a 104x104px circle centered horizontally, positioned at `bottom: height / 7` from the screen bottom.

### The 517ms Threshold

`delayLongPress={517}` sets the threshold for the `onLongPress` callback. This is a deliberate prime-adjacent number chosen to avoid common double-tap intervals (typically 300ms) and short press durations (typically 200-300ms). The value 517ms sits in a "sweet spot":

- **Above 300ms**: Avoids accidental long-press activation during normal tap interactions
- **Below 600ms**: Still feels responsive -- the user does not need to hold uncomfortably long
- **Non-round number**: 517 (not 500 or 520) reduces the probability of coinciding with system-level timing thresholds or accessibility auto-repeat intervals
- The value is exactly 517 milliseconds, creating a distinctive interaction signature

### Event Flow

**onPressIn -> `BleManager.onDisconnectPressStart()`:**
```ts
Haptics.impactAsync(ImpactFeedbackStyle.Heavy);
BleManager.onDisconnectPressStart();
```

Immediate haptic impact (`Heavy`) confirms the press was registered. Then `onDisconnectPressStart` initiates the consensus protocol:

**Server-side (this device is the server):**
- Sets `isDisconnectButtonPressed = true`
- Clears any existing disconnection/alarm timeouts
- Starts a **500ms polling interval** (`setInterval`) that checks:
  - If `askedDisconnectionDevices.length === connectedClients.length` (all clients have requested disconnection) AND `isDisconnectButtonPressed` is still true:
    - Sets `serverAlreadyShouldDisconnect = true`
    - Broadcasts `d:a|a` to all clients (authorization to disconnect)
    - Clears the interval
  - If `connectedClients.length === 0` (all clients already left):
    - Plays "unpair" sound
    - Fires `Haptics.notificationAsync(NotificationFeedbackType.Success)`
    - Sets `appState.value = "disconnected"`
    - Stops advertising, clears all state

**Client-side (this device is a client):**
- Sends `d:r|s` packet to server (disconnect request, start phase)
- The server responds with `d:a|a` when consensus is reached

### onLongPress -> `BleManager.onDisconnectLongPress()`:

```ts
Haptics.impactAsync(ImpactFeedbackStyle.Soft);
BleManager.onDisconnectLongPress();
```

The long press (after 517ms) provides an **accelerated disconnection path**. The haptic changes from `Heavy` to `Soft` to signal the state transition. `onDisconnectLongPress` follows the same consensus protocol as `onDisconnectPressStart` but with the `isDisconnectButtonPressed` flag already set to `true`, meaning the polling interval immediately begins checking for consensus.

### onPressOut -> `BleManager.onDisconnectPressFinish()`:

```ts
Haptics.impactAsync(ImpactFeedbackStyle.Heavy);
BleManager.onDisconnectPressFinish();
```

**Server-side:**
- Sets `isDisconnectButtonPressed = false` -- stops the polling loop
- Clears the `serverDisconnectionTimeout` interval
- If clients have already disconnected (`connectedClients.length === 0`):
  - Full cleanup: play "unpair", haptic success, set state to "disconnected", stop advertising
- If clients are still connected but some requested disconnection:
  - Waits 3500ms, then sends `d:a|r` to asked clients (rejection -- they pressed alone)
  - Triggers RSSI proximity monitoring setup

**Client-side:**
- Sends `d:r|f` to server (disconnect request, finish phase)
- If `isAmIClientAllowedToDisconnectEdgeCase` is true, sends `a:r|ff` instead (alarm-related edge case)

### Consensus Mechanism (Disconnect)

The disconnect protocol requires **all devices to agree** before disconnection occurs:

1. **Press start**: Client sends `d:r|s` to server. Server adds client to `askedDisconnectionDevices`.
2. **Polling**: Server checks every 500ms if ALL connected clients have requested disconnection.
3. **Press finish**: Client sends `d:r|f`. Server removes client from `askedDisconnectionDevices`.
4. **Decision**: If server's own button was pressed AND all clients requested disconnection, server broadcasts `d:a|a` (authorize disconnect) to all.
5. **Edge case**: If a single client presses disconnect without the server pressing, the server sends `d:a|r` (reject) and may trigger alarm if `fireAlarmOnDisconnect` is enabled.

This ensures that **neither party can unilaterally disconnect** when the alarm-on-disconnect feature is enabled.

---

## 3. Hold-to-Stop-Alarm

### Component

```tsx
{appCurrentState === "alarm" && (
  <TouchableOpacity
    style={{
      zIndex: 1000,
      position: "absolute",
      borderWidth: 1,
      borderColor: "#D9D9D9",
      height: 80 * 1.3,
      width: 80 * 1.3,
      borderRadius: gestureRadius,
      justifyContent: "center",
      alignItems: "center",
      bottom: height / 7,
      right: width / 2 - (80 * 1.3) / 2,
    }}
    onPressIn={...}
    onPressOut={...}
  >
```

Only renders when `appCurrentState === "alarm"`. Visually identical to the disconnect button but with `borderColor: "#D9D9D9"` (light gray instead of theme background) and text color `#D9D9D9`.

**Note**: Unlike the disconnect button, this button has **no `delayLongPress` or `onLongPress`**. It only uses `onPressIn` and `onPressOut`.

### Event Flow

**onPressIn -> `BleManager.onStopAlarmPressStart()`:**
```ts
Haptics.impactAsync(ImpactFeedbackStyle.Heavy);
BleManager.onStopAlarmPressStart();
```

**Server-side:**
- Sets `isAlarmButtonPressed = true`
- Clears existing alarm timeout
- Starts a **500ms polling interval** that checks:
  - If `askedToStopAlarmClients.length === connectedClients.length` AND `isAlarmButtonPressed`:
    - Broadcasts `a:f|0|a` (alarm off, authorize, to all)
    - Sets `appState.value = "disconnected"`
    - Stops alarm sound
    - Fires `Haptics.notificationAsync(NotificationFeedbackType.Success)`
    - Clears interval

**Client-side:**
- If `isAmIClientAllowedToDisconnectEdgeCase`: sends `a:r|ff` and schedules disconnect after 1500ms
- Always sends `a:r|s` to server (alarm stop request, start phase)

**onPressOut -> `BleManager.onStopAlarmPressFinish()`:**
```ts
Haptics.impactAsync(ImpactFeedbackStyle.Heavy);
BleManager.onStopAlarmPressFinish();
```

**Server-side:**
- Sets `isAlarmButtonPressed = false`
- Clears the `stopAlarmTimeout` interval
- Resets `askedToStopAlarmClients`

**Client-side:**
- Sends `discq:{sessionId}` to server
- If edge case: sends `a:r|ff`
- Sends `a:r|f` to server (alarm stop request, finish phase)

### Consensus Mechanism (Alarm Stop)

Same pattern as disconnect:

1. **Press start**: Client sends `a:r|s` to server. Server adds client to `askedToStopAlarmClients`.
2. **Polling**: Server checks every 500ms if ALL connected clients have requested alarm stop.
3. **Press finish**: Client sends `a:r|f`. Server removes client from `askedToStopAlarmClients`.
4. **Decision**: If server's own button was pressed AND all clients requested stop, server broadcasts `a:f|0|a` (alarm off) to all.

---

## 4. Haptic Feedback Integration

### Feedback Types Used

| Feedback Type | Style | When Fired | Context |
|---|---|---|---|
| `ImpactFeedbackStyle.Soft` | Light tap | Pan gesture `onStart` | Gesture recognized |
| `ImpactFeedbackStyle.Soft` | Light tap | Disconnect `onLongPress` | Long press threshold crossed |
| `ImpactFeedbackStyle.Heavy` | Strong tap | Disconnect `onPressIn` | Button press starts |
| `ImpactFeedbackStyle.Heavy` | Strong tap | Disconnect `onPressOut` | Button press ends |
| `ImpactFeedbackStyle.Heavy` | Strong tap | Stop-alarm `onPressIn` | Alarm button press starts |
| `ImpactFeedbackStyle.Heavy` | Strong tap | Stop-alarm `onPressOut` | Alarm button press ends |
| `NotificationFeedbackType.Error` | Error vibration | Alarm ball animation peaks | Ball reaches max radius (360) and min radius (310) during alarm pulse |
| `NotificationFeedbackType.Success` | Success vibration | Disconnect consensus reached (server) | All devices agreed to disconnect |
| `NotificationFeedbackType.Success` | Success vibration | Stop-alarm consensus reached (server) | All devices agreed to stop alarm |

### When Each Fires

**Soft impact** -- Used for gesture recognition feedback. Fires when the user initiates a pan gesture (start) or crosses the long-press threshold. Provides subtle confirmation without interrupting flow.

**Heavy impact** -- Used for button press/release feedback. Fires on both press-in and press-out for disconnect and alarm buttons. The bidirectional feedback (down + up) creates a "physical button" feel. The heavy style communicates consequence.

**Error notification** -- Used exclusively during alarm state. Fires at each peak and trough of the ball's pulse animation (every 300ms expanding, every 500ms contracting). Creates a persistent rhythmic vibration pattern that complements the audible alarm. The vibration is tied to the animation callback, not a timer, ensuring synchronization.

**Success notification** -- Fires when consensus is reached on the server side (all devices agreed to disconnect or stop alarm). Communicates that the protective action was completed successfully.

### Bridge Pattern

All haptic calls cross the Reanimated worklet boundary via `runOnJS`:
```ts
runOnJS(Haptics.impactAsync)(ImpactFeedbackStyle.Soft)
```
This is necessary because `Haptics` from `expo-haptics` is a JS module, not a worklet. The `runOnJS` wrapper schedules the call on the JS thread.

Exception: The `onPressIn`/`onPressOut` callbacks on `TouchableOpacity` are already on the JS thread, so they call `Haptics.impactAsync` directly without `runOnJS`.

---

## 5. Retry Mechanism

### Implementation

```ts
const retries = useSharedValue(7);

const retryConnect = async () => {
  await sleep(500);
  if (retries.value <= 5 && hasConnectionError.value) {
    retries.value += 1;

    Snackbar.show({
      text: t("retry"),
      duration: Snackbar.LENGTH_SHORT,
    });
    y.value = withSpring(redBallInitialY, { damping: 100 }, () => {
      showRedBall.value = false;
      appState.value = "scanning";
      hasConnectionError.value = false;
    });
  }
};
```

### Behavior

| Parameter | Value | Notes |
|-----------|-------|-------|
| Initial `retries` value | 7 | Set at component mount |
| Max retries allowed | 5 | Check: `retries.value <= 5` |
| Delay between retries | 500ms | Via `sleep(500)` |
| Feedback | Snackbar | `Snackbar.LENGTH_SHORT` duration |

### Retry Lifecycle

1. **Initial state**: `retries = 7` (exceeds the `<= 5` threshold, so no retries happen)
2. **Swipe-to-connect**: `retries` reset to `0` (allows retries)
3. **Connection error**: `hasConnectionError.value` set to `true` by BLE manager
4. **`retryConnect()` called**: From the `useEffect` watching `shouldStartScan` -- when scan stops and `hasConnectionError` is true
5. **First retry**: `retries` incremented to 1, Snackbar shown, ball springs to red position, scan restarts
6. **Subsequent retries**: `retries` increments each time (2, 3, 4, 5)
7. **Max retries exceeded**: When `retries` reaches 6, the condition `retries.value <= 5` fails, retries stop
8. **Successful connection**: On reaching `connected` or `synced` state, `retries.value = 0` is set (but `hasConnectionError` is also `false`, preventing further retries)
9. **User cancels (gesture release below threshold)**: `retries` reset to 7, effectively disabling retries

### Retry Reset Points

- **Swipe initiated**: `retries.value = 0`
- **Connected state reached**: `retries.value = 0`
- **Synced state reached**: `retries.value = 0`
- **User cancels gesture**: `retries.value = 7`
- **Initial mount**: `retries.value = 7`

---

## 6. The shouldBlock Pattern

### Definition

```ts
const shouldBlock = useSharedValue(false);
```

A `SharedValue<boolean>` that acts as a **UI-thread gate** for the pan gesture. It prevents gesture processing during active BLE states without crossing the JS bridge.

### States That Block

| State | Blocks? | Reason |
|-------|---------|--------|
| `disconnected` | No | Default state -- gesture allowed |
| `scanning` | Yes | Connection attempt in progress |
| `connecting` | Yes | BLE connection establishing |
| `connected` | Yes | Connection active, transition to synced imminent |
| `synced` | Yes | Full connection active, disconnect button is the exit path |
| `alarm` | Yes | Alarm active, stop-alarm button is the exit path |
| `migrating` | No | Server migration in progress, allows re-gesture |

### Evaluation Timing

`shouldBlock` is evaluated **only once per gesture** -- in the `onStart` callback:

```ts
.onStart((e) => {
  shouldBlock.value =
    appState.value === "connected" ||
    appState.value === "alarm" ||
    appState.value === "synced" ||
    appState.value === "scanning" ||
    appState.value === "connecting";
})
```

This means:
- If the user starts swiping while disconnected, `shouldBlock` is `false` and the gesture proceeds even if the state changes mid-gesture
- If the user starts swiping while connected, `shouldBlock` is `true` and the gesture is completely ignored for its entire duration
- The decision is **sticky** for the lifetime of that gesture instance

### Why SharedValue (Not React State)

Using a `SharedValue` instead of React `useState` for `shouldBlock` is critical because:

1. **No bridge crossing**: The pan gesture callbacks run on the UI thread. Reading a `SharedValue` stays on the UI thread. Reading React state would require `runOnJS`, introducing latency and potential frame drops.
2. **Synchronous evaluation**: The `onChange` and `onEnd` callbacks check `shouldBlock.value` synchronously on the UI thread.
3. **Frame-accurate**: The blocking decision is made in the same frame as the gesture start, with no asynchronous gap.

### Usage in Gesture Callbacks

```ts
// onChange -- suppresses movement
.onChange((e) => {
  if (shouldBlock.value) return;
  y.value += e.changeY;
  y.value = clamp(y.value, height / 4, redBallInitialY);
})

// onEnd -- suppresses connection logic
.onEnd((e) => {
  if (shouldBlock.value) return;
  // ... threshold check and state transitions
})
```

### Relationship to Retry Counter

The `shouldBlock` pattern and the retry mechanism are orthogonal:
- `shouldBlock` prevents gesture interaction during active states
- `retries` controls automatic reconnection after failures
- Both reset on successful connection (`retries = 0`, `shouldBlock` becomes irrelevant since state changes to `synced`)
- Both reset on user cancellation (`retries = 7`, gesture returns to initial state)

---

## 7. Gesture-Animation Interaction Map

### Complete Data Flow

```
User swipes down
  |
  v
Gesture.Pan().onStart()
  |-- evaluates shouldBlock (UI thread)
  |-- fires ImpactFeedbackStyle.Soft haptic (runOnJS -> JS thread)
  |
  v
Gesture.Pan().onChange()
  |-- if shouldBlock: return (no-op)
  |-- y.value += e.changeY (UI thread)
  |-- y.value = clamp(y, height/4, redBallInitialY) (UI thread)
  |-- Animated.View follows via animatedStyleForGestureHandler
  |
  v
Gesture.Pan().onEnd()
  |-- if shouldBlock: return (no-op)
  |-- if absoluteY >= 500 (threshold met):
  |     |-- y = withSpring(redBallInitialY, {damping: 100})
  |     |     |-- animation completes:
  |     |           |-- showRedBall = false (UI thread)
  |     |           |-- appState = "scanning" (UI thread)
  |     |           |-- retries = 0 (UI thread)
  |     |           |-- hasConnectionError = false (UI thread)
  |     |
  |     |-- useAnimatedReaction(showRedBall) triggers:
  |     |     |-- runOnJS(setShouldStartScan)(true)
  |     |
  |     |-- useEffect(shouldStartScan) triggers:
  |           |-- BleManager.startAdvertising()
  |           |     then BleManager.startScanning()
  |           |
  |           |-- BLE events drive appState changes
  |           |-- useAnimatedReaction(appState) triggers:
  |                 |-- runOnJS(setAppCurrentState)(state)
  |                       |-- useEffect(appCurrentState) drives:
  |                             |-- UI animations
  |                             |-- Sound playback
  |                             |-- Menu visibility
  |
  |-- if absoluteY < 500 (threshold not met):
        |-- showRedBall = true
        |-- y = withSpring(blueBallInitialY, {damping: 100})
        |     |-- animation completes:
        |           |-- appState = "disconnected"
        |           |-- retries = 7
        |           |-- hasConnectionError = false
```

### Thread Boundary Map

| Operation | Thread | Mechanism |
|-----------|--------|-----------|
| Gesture callbacks | UI | Reanimated worklet |
| `shouldBlock` read/write | UI | SharedValue |
| `y.value` read/write | UI | SharedValue |
| `appState.value` read/write | UI | SharedValue |
| `showRedBall.value` read/write | UI | SharedValue |
| Haptic feedback (in gesture) | JS | `runOnJS(Haptics.impactAsync)` |
| `setShouldStartScan` | JS | `runOnJS(setShouldStartScan)` |
| `setAppCurrentState` | JS | `runOnJS(setAppCurrentState)` |
| `BleManager.*` calls | JS | Direct calls from `useEffect` |
| `Snackbar.show` | JS | Direct calls from `retryConnect` |
| Spring/timing animations | UI | `withSpring`, `withTiming`, `withRepeat` |
| `clamp` | UI | Reanimated utility |
| `interpolate` | UI | Reanimated utility |

---

## 8. Animation Parameters

### Spring Configurations

| Context | Config | Effect |
|---------|--------|--------|
| Swipe-to-connect (success) | `{ damping: 100 }` | Critically damped, fast settle to red position |
| Swipe-to-connect (cancel) | `{ damping: 100 }` | Critically damped, fast return to blue position |
| Retry reconnect | `{ damping: 100 }` | Same spring, ball returns to red position |
| Disconnect return | `{ damping: 100 }` | Ball returns to blue position after disconnect |
| Initial mount (blue) | `withTiming(blueBallInitialY, { duration: 500 })` | 500ms ease-in to blue position |
| Initial mount (red) | `withTiming(redBallInitialY, { duration: 500 })` | 500ms ease-in to red position |

### Easing Functions

| Context | Easing | Duration |
|---------|--------|----------|
| Ball pulse (inner) | `Easing.inOut(Easing.ease)` | 2500ms |
| Ball pulse (outer) | `Easing.in(Easing.ease)` | 1160ms |
| Synced ball radius | `Easing.inOut(Easing.linear)` | 1900ms |
| Alarm expand | Linear (default) | 300ms |
| Alarm contract | Linear (default) | 500ms |
| Icon bob animation | `withTiming(2/-2, { duration: 1000 })` | 1000ms each direction |
| Bottom/top icon bob | `withTiming(4, { duration: 300 })` | 300ms oscillation |

### Repeat Configurations

| Animation | Count | Reverse | Purpose |
|-----------|-------|---------|---------|
| Inner pulse | -1 (infinite) | true | Smooth breathing effect |
| Outer pulse | -1 (infinite) | false | Scanning/connecting ring fade |
| Synced radius | -1 (infinite) | true | Gentle 300-360 oscillation |
| Alarm radius | -1 (infinite) | false | Aggressive 310-360 pulse |
| Icon bob (down arrow) | -1 (infinite) | true | Gentle vertical oscillation |
| Bottom/top icons | -1 (infinite) | true | Synchronized bob |

---

## 9. Button Positioning

### Disconnect Button (synced state)

```ts
{
  bottom: height / 7,
  right: width / 2 - (80 * 1.3) / 2,
}
```

- Vertically: `height / 7` from screen bottom (roughly 14% up from bottom)
- Horizontally: Centered (`width / 2 - 52` for a 104px wide button)
- Size: 104 x 104px (80 * 1.3)
- Shape: Circular (`borderRadius: 100`, which is `gestureRadius`)

### Stop-Alarm Button (alarm state)

Identical positioning and sizing to the disconnect button. Only the visual style differs:
- `borderColor: "#D9D9D9"` (light gray)
- Text color: `"#D9D9D9"`

Both buttons occupy the same screen position, but since they are conditionally rendered (`appCurrentState === "synced"` vs `appCurrentState === "alarm"`), they never coexist.

---

## 10. Localization and Language-Dependent Positioning

The ball text positioning adjusts for German-language line lengths:

```ts
const textAlign = useDerivedValue(
  () =>
    y.value -
    (appState.value === "disconnected"
      ? lang?.startsWith("de")
        ? 24
        : 16
      : 16),
  [y.value, appState.value.value],
);
```

When in `disconnected` state and the language is German (`lang?.startsWith("de")`), the text is offset 24px above the ball center instead of 16px. This accounts for longer German text strings (e.g., "HOCHZOOM UM ZU VERBINDEN" vs "SWIPE TO CONNECT") that require more vertical space.

The `lang` variable is captured at module scope:
```ts
const lang = i18n.resolvedLanguage;
```
