---
type: reference
title: "CAMDOM — UI State Machine"
description: "Complete documentation of the state-driven UI architecture: the 7 app states, SharedValue bridge pattern, ball scaling system, text rendering pipeline, sound orchestration, and lateral menu visibility logic."
tags: [camdom, state-machine, reanimated, skia, shared-values, ui, ble]
timestamp: "2026-07-20"
---

# CAMDOM UI State Machine

The CAMDOM UI is entirely state-driven. A single `AppConnectionState` SharedValue propagates through the BLE manager, drives all animations via Reanimated worklets, and bridges to React state for imperative side effects (BLE operations, sound playback, menu toggling). There are 7 discrete states, each with a distinct visual, auditory, and haptic signature.

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

---

## 1. The 7 States and Their UI Manifestations

### Type Definition (from BleManager.ts)

```ts
export type AppConnectionState =
  | "disconnected"
  | "scanning"
  | "connected"
  | "connecting"
  | "migrating"
  | "synced"
  | "alarm";
```

### State Details

#### disconnected

**Visual:**
- Red ball visible (`showRedBall = true`), positioned at `redBallInitialY` (`height - height / 3 + 20`)
- Blue ball at `blueBallInitialY` (`height / 3 + 20`), with `ballRadius = defaultBallRadius` (86 on `sm` breakpoint, 96 otherwise)
- Swipe-down arrow icon visible (`iconVisibility = true`), animated with a vertical bob (2px oscillation, 1000ms period)
- Border ring visible (`borderVisibility = true`), color `#889AA2` (disconnected state gray)
- `bottomAndTopIconVisibility = false` -- no directional arrows around the ball
- Ball text: localized "swipe to connect" string, split by `|` delimiter
- Background: `colors.background` (dark theme)

**Sound:** None (unless transitioning from alarm, in which case alarm sound is stopped)

**Haptic:** None

**Menu:** Lateral menu visible (`toggleIsShowingLateralMenu(true)`)

**Button:** None rendered

**Ball pulse:** No pulsing (`pulsingRadius = r.value`, no multiplier)

---

#### scanning

**Visual:**
- Red ball hidden (`showRedBall = false`)
- Blue ball at `redBallInitialY` (bottom position), `ballRadius = defaultBallRadius`
- Border visible (`borderVisibility = true`), color `#BCD4DF` (active state blue-gray)
- Outer stroke ring visible with opacity animation: `interpolate(pulseFactorOutter, [1, 1.2], [1, 0.2])` -- fades from full opacity to 20% as the ring pulses outward
- Swipe-down icon hidden (`iconVisibility = false`)
- `bottomAndTopIconVisibility = false`
- Ball text: localized "scanning" string
- Blue ball springs to `redBallInitialY` with `damping: 100`

**Sound:** `"open"` sound played via `BleManager.playSound("open")`

**Haptic:** None directly in state handler (gesture haptic already fired)

**Menu:** Lateral menu visible

**Button:** None rendered

**Ball pulse:** No pulsing (`pulsingRadius = r.value`)

---

#### connecting

**Visual:**
- Red ball hidden (`showRedBall = false`)
- Blue ball at current position (mid-transition from scanning), `ballRadius = defaultBallRadius`
- Border visible, color `#BCD4DF`
- Outer stroke ring visible with same opacity animation as scanning
- Swipe-down icon hidden
- `bottomAndTopIconVisibility = false`
- Ball text: localized "connecting" string

**Sound:** None (no sound change from scanning)

**Haptic:** None

**Menu:** Lateral menu visible

**Button:** None rendered

**Ball pulse:** No pulsing

---

#### connected

**Visual:**
- Red ball hidden (`showRedBall = false`)
- Blue ball at `y = height / 2` (center of screen), `ballRadius = defaultBallRadius`
- Border visible, color `#BCD4DF`
- Swipe-down icon hidden
- `bottomAndTopIconVisibility = false`
- Ball text: localized "syncing" string
- This is a **transient state** -- the BLE manager transitions to `synced` almost immediately after receiving the session packet

**Sound:** None

**Haptic:** None

**Menu:** Lateral menu **hidden** (`toggleIsShowingLateralMenu(false)`)

**Button:** None rendered

**Ball pulse:** `pulsingRadius = r.value * pulseFactor * 2` -- double-radius pulsing begins

---

#### synced

**Visual:**
- Red ball hidden (`showRedBall = false`)
- Blue ball at `y = height / 2` (center)
- Ball radius: animated between 300 and 360:
  ```ts
  ballRadius.value = 300;
  ballRadius.value = withRepeat(
    withTiming(360, {
      easing: Easing.inOut(Easing.linear),
      duration: 1900,
    }),
    -1,   // infinite
    true, // reverse
  );
  ```
- Border visible, color `#BCD4DF`
- Swipe-down icon hidden
- `bottomAndTopIconVisibility = true` -- directional arrows appear above and below the ball
- Ball text: localized "synced/pair" string
- Ball fill: switches to `redDot.png` texture (red pulsing ball) -- `imageSelected = true` when `red?.value` is true OR `appState.value === "alarm"`

**Sound:** `"pair"` sound played via `BleManager.playSound("pair")`

**Haptic:** None in state handler

**Menu:** Lateral menu **hidden**

**Button:** Disconnect button rendered (104px circle, centered, `bottom: height / 7`)

**Ball pulse:** `pulsingRadius = r.value * pulseFactor * 2` -- inner pulse oscillates 1x to 1.1x at 2500ms period. Outer pulse oscillates 1x to 1.3x at 1160ms period.

**Scale transform:** `derivedTransform` maps `ballRadius` (300-360) through `interpolate(ballRadius, [78, 360], [1, 3])`:
- At radius 300: scale = `1 + (300-78)/(360-78) * 2 = 1 + 0.686 * 2 = 2.37`
- At radius 360: scale = 3.0

The ball visually fills most of the screen at this state.

---

#### alarm

**Visual:**
- Red ball hidden (`showRedBall = false`)
- Blue ball at `y = height / 2` (center)
- Ball radius: aggressive pulse between 310 and 360:
  ```ts
  ballRadius.value = 310;
  ballRadius.value = withRepeat(
    withSequence(
      withTiming(360, { duration: 300 }, () => {
        runOnJS(Haptics.notificationAsync)(NotificationFeedbackType.Error);
      }),
      withTiming(310, { duration: 500 }, () => {
        runOnJS(Haptics.notificationAsync)(NotificationFeedbackType.Error);
      }),
    ),
    -1,    // infinite
    false, // don't reverse (sequential, not oscillating)
  );
  ```
- Background: `colors.alarmBackground` (darker than normal)
- Ball text: `[t("ui_state_alarm")]` -- single-element array (no line splitting)
- Ball text color: `#D9D9D9` (light gray) instead of `colors.background`
- Font size: 14px (vs 11px in other states) -- larger for urgency
- `bottomAndTopIconVisibility = true` -- but rendered with `iconsBlueOpacity` (alarm-specific blue arrows: `Frame16.png` and `Frame14.png` instead of normal arrows)
- Directional arrows: `iconsOpacity = 0` for normal arrows, `iconsBlueOpacity = 1` for alarm arrows

**Sound:** `"alarm"` sound played via `BleManager.playSound("alarm")` -- set to **looping** (`setIsLoopingAsync(true)`)

**Haptic:** `NotificationFeedbackType.Error` fired at each animation callback:
- When ball reaches 360 (expand peak): error haptic
- When ball reaches 310 (contract trough): error haptic
- Creates rhythmic vibration pattern: expand (300ms) -> vibrate -> contract (500ms) -> vibrate -> repeat

**Menu:** Lateral menu **hidden**

**Button:** Stop-alarm button rendered (104px circle, centered, `bottom: height / 7`, `borderColor: "#D9D9D9"`)

**Ball pulse:** `pulsingRadius = r.value * pulseFactor * 5` -- quintuple radius multiplication. The base radius of 310-360 multiplied by the pulse factor (1 to 1.1) and then by 5 creates a massive pulsing effect that fills the entire screen.

**Scale transform:** At radius 310: scale = `1 + (310-78)/(360-78) * 2 = 2.43`. At radius 360: scale = 3.0. The ball visually dominates the entire canvas.

---

#### migrating

**Visual:**
- Identical to scanning except:
- `hasConnectionError.value = false` -- explicitly cleared
- Ball text: localized "connecting" string (reuses connecting text, not scanning text)

**Sound:** `"open"` sound played

**Haptic:** None

**Menu:** Lateral menu visible

**Button:** None rendered

**Ball pulse:** No pulsing

**Trigger:** Server migration occurs when a higher-numbered BLE advertiser is found while this device is already a server. The packet `m:d|{newAdvertisedValue}` is broadcast, and the client disconnects and re-enters scanning/migrating state.

---

## 2. The useAnimatedReaction Bridge

### Pattern

The app uses a two-stage bridge to move data from the Reanimated UI thread to the React JS thread:

**Stage 1: SharedValue -> SharedValue (UI thread)**
```ts
const showRedBall = useSharedValue(true);
const appState = useSharedValue<AppConnectionState>("disconnected");
```
All gesture callbacks and animation logic operate directly on these SharedValues on the UI thread.

**Stage 2: SharedValue -> React State (UI thread -> JS thread)**
```ts
useAnimatedReaction(
  () => showRedBall.value,
  (prep, previous) => {
    if (!previous && prep) {
      runOnJS(setShouldStartScan)(false);
    }
    if (previous && !prep) {
      runOnJS(setShouldStartScan)(true);
    }
  },
  [showRedBall.value],
);
```

```ts
useAnimatedReaction(
  () => appState.value,
  (prep, previous) => {
    runOnJS(setAppCurrentState)(prep);
  },
  [appState.value],
);
```

### Why Two Separate Reactions

**Reaction 1: `showRedBall` -> `shouldStartScan`**

This reaction watches the red ball visibility and maps it to a boolean scan trigger:
- `showRedBall` goes `true -> false` (user swiped): `shouldStartScan = true` -> triggers BLE advertising + scanning
- `showRedBall` goes `false -> true` (scan stopped or user cancelled): `shouldStartScan = false` -> triggers BLE stop + cleanup

The directional logic (`previous` vs `prep`) is necessary because the same SharedValue drives both start and stop, and the reaction needs to distinguish which edge occurred.

**Reaction 2: `appState` -> `appCurrentState`**

This reaction is a pure pass-through: it copies the SharedValue to React state on every change. There is no directional logic because the `useEffect` watching `appCurrentState` handles all 7 states via a `switch` statement.

### Why runOnJS is Required

The `useAnimatedReaction` callback runs on the UI thread (it is a worklet). React state setters (`setShouldStartScan`, `setAppCurrentState`) are JS thread operations. `runOnJS` wraps the setter function and schedules its execution on the JS thread via the Reanimated bridge. Without `runOnJS`, calling a React state setter from a worklet would crash or silently fail.

### The useEffect Side-Effect Layer

The React state values drive imperative side effects that cannot run on the UI thread:

```ts
useEffect(() => {
  if (shouldStartScan) {
    BleManager.startAdvertising().then(async (success) => {
      if (success) await BleManager.startScanning();
    });
  } else {
    BleManager.stopScanning().then(async (isConnected) => {
      BleManager.stopAdvertising(true);
      if (isConnected) {
        await BleManager.disconnectFromServer();
        BleManager.playSound("unpair");
      }
      ballPosition.value = "center";
      ballRadius.value = defaultBallRadius;
      retryConnect();
    });
  }
}, [shouldStartScan]);
```

```ts
useEffect(() => {
  switch (appCurrentState) {
    case "scanning": { ... }
    case "migrating": { ... }
    case "connecting": { ... }
    case "connected": { ... }
    case "synced": { ... }
    case "alarm": { ... }
    case "disconnected": { ... }
  }
}, [appCurrentState]);
```

This is the architectural pattern: **SharedValues drive animations (UI thread) -> reactions bridge to React state (JS thread) -> useEffects execute side effects (JS thread) -> side effects modify SharedValues (JS->UI bridge)**.

---

## 3. The Ball Scaling System

### derivedTransform

```ts
const derivedTransform = useDerivedValue(() => {
  const baseScale = 1;
  const scale = interpolate(ballRadius.value, [78, 360], [baseScale, 3]);
  return [{ scale: scale }];
}, [ballRadius.value]);
```

This maps the `ballRadius` SharedValue to a visual scale transform:
- `ballRadius = 78`: scale = 1.0 (minimum, ball at native size)
- `ballRadius = 360`: scale = 3.0 (maximum, ball tripled in size)
- Linear interpolation between these bounds

The transform is applied to the blue ball's `<Group>`:
```tsx
<Group transform={derivedTransform} origin={derivedScaledOrigin}>
  <Ball cx={ballXV} cy={y} r={ballRadius} ... />
</Group>
```

### derivedScaledOrigin

```ts
const derivedScaledOrigin = useDerivedValue(() => {
  return vec(ballXV.value, y.value);
}, [y.value, ballXV.value]);
```

The scale origin is set to the ball's current center position (`ballXV`, `y`). This ensures the ball scales outward from its center, not from the canvas origin. Without this, the ball would appear to fly off-screen as it scales.

### How the Ball "Fills the Screen"

In the `synced` state, `ballRadius` oscillates between 300 and 360:
- At 300: `interpolate(300, [78, 360], [1, 3])` = `1 + (300-78)/(360-78) * 2` = **2.37x scale**
- At 360: `interpolate(360, [78, 360], [1, 3])` = **3.0x scale**

Combined with the inner `pulsingRadius` multiplier (`r.value * pulseFactor * 2`), the actual rendered radius ranges from `300 * 1.0 * 2 = 600` to `360 * 1.1 * 2 = 792`. The Skia canvas `<Circle>` at this radius, combined with the 22px blur, creates a soft-edged orb that extends beyond the viewport.

In the `alarm` state, the multiplier is `* 5` instead of `* 2`:
- Range: `310 * 1.0 * 5 = 1550` to `360 * 1.1 * 5 = 1980`
- The ball completely fills the screen with a pulsing, blurred, colored field

---

## 4. The Text Rendering System

### ballText SharedValue

```ts
const ballText = useSharedValue(t("ui_state_swipe").split("|"));
```

Initialized with the localized "swipe to connect" string, split by the `|` delimiter. Each element becomes a line in the rendered paragraph.

### Pipe-Delimited Line Format

The `ballText` value is an array of strings. Each string is a line of text. The `|` character in the localized strings is the split delimiter:

```ts
ballText.value = t("ui_state_swipe").split("|");
// Example: "SWIPE|DOWN|TO CONNECT" -> ["SWIPE", "DOWN", "TO CONNECT"]
```

### The "f:" Prefix for Custom Font Sizes

The paragraph builder checks each line for the `f:` prefix:

```ts
for (const line of ballText.value) {
  if (line.toLowerCase().trimStart().startsWith("f:")) {
    const lineData = line.split("<>");
    let fontSize = parseInt(lineData[0].split(":")[1]);
    fontSize = breakpoint === "sm" ? fontSize - 1 : fontSize;

    builder.pushStyle({
      fontSize: fontSize,
      color: Skia.Color(
        appState.value === "alarm" ? "#D9D9D9" : colors.background,
      ),
      fontFamilies: ["BasicSans"],
    });

    const lineText = lineData[1];
    builder.addText(lineText.toUpperCase() + "\n");
  } else {
    builder.pushStyle({
      fontSize: breakpoint === "sm" ? 10 : 11,
      color: Skia.Color(
        appState.value === "alarm" ? "#D9D9D9" : colors.background,
      ),
      fontFamilies: ["BasicSans"],
    });
    builder.addText(line.toUpperCase() + "\n");
  }
}
```

**Format:** `f:{size}<>{text}`

Example: `"f:16<>PAIR"` renders "PAIR" at 16px (or 15px on `sm` breakpoint).

The `<>` delimiter separates the size directive from the text content. This allows localized strings to embed per-line font sizes without code changes.

### State-Specific Text Content

| State | ballText Value | Font Size | Color |
|-------|---------------|-----------|-------|
| disconnected | `t("ui_state_swipe").split("|")` | 11px (10 on sm) | `colors.background` |
| scanning | `t("ui_state_scanning").split("|")` | 11px (10 on sm) | `colors.background` |
| connecting | `t("ui_state_connecting").split("|")` | 11px (10 on sm) | `colors.background` |
| connected | `t("ui_state_syncing").split("|")` | 11px (10 on sm) | `colors.background` |
| synced | `t("ui_state_synced").split("|")` | 11px (10 on sm) | `colors.background` |
| alarm | `[t("ui_state_alarm")]` | **14px** | **`#D9D9D9`** |
| migrating | `t("ui_state_connecting").split("|")` | 11px (10 on sm) | `colors.background` |

### Paragraph Builder

```ts
const paragraph = useDerivedValue(() => {
  if (!fontMgr || !ballText) return null;

  const builder = Skia.ParagraphBuilder.Make({
    textAlign: TextAlign.Center,
    textHeightBehavior: TextHeightBehavior.All,
    textStyle: {
      fontSize: appState.value === "alarm" ? 14 : 11,
      fontFamilies: ["BasicSans"],
    },
  }, fontMgr);

  // ... line-by-line processing (see above)

  const para = builder.build();
  para.layout(240);  // fixed 240px width for text layout
  return para;
}, [fontMgr, ballText, ballText?.value, appState]);
```

Key details:
- Font: `BasicSans-SemiBold.otf` loaded via `useFonts`
- Layout width: fixed at 240px
- All text is uppercased (`.toUpperCase()`)
- Text alignment: center
- The paragraph is re-derived whenever `fontMgr`, `ballText.value`, or `appState` changes

### Text Positioning

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

- **Y position**: `y.value - 16` (or `y.value - 24` for German in disconnected state)
- **X position**: `ballXV.value - 120` (centered on the ball, since text layout is 240px wide)
- The text floats **above** the ball center

---

## 5. Sound Orchestration

### Sound Library

Loaded at BLE manager construction via `bindSoundEffects()`:

| Key | File | Packet Code |
|-----|------|-------------|
| `"open"` | `BillyBoy_Camdom_02_Open.mp3` | `"0"` |
| `"pair"` | `BillyBoy_Camdom_02_Pair.mp3` | `"1"` |
| `"unpair"` | `BillyBoy_Camdom_02_Unpair.mp3` | `"2"` |
| `"alarm"` | `BillyBoy_Camdom_Alarm_2.mp3` | `"3"` |
| `"ping"` | `ping.mp3` | `"4"` |

### State-Sound Mapping

| State | Sound | Trigger | Looping |
|-------|-------|---------|---------|
| scanning | `"open"` | `BleManager.playSound("open")` in useEffect | No |
| migrating | `"open"` | `BleManager.playSound("open")` in useEffect | No |
| synced | `"pair"` | `BleManager.playSound("pair")` in useEffect | No |
| alarm | `"alarm"` | `BleManager.playSound("alarm")` in useEffect | **Yes** (`setIsLoopingAsync(true)`) |
| disconnected (from error) | `"unpair"` | `BleManager.playSound("unpair")` in stopScanning callback | No |
| disconnect consensus | `"unpair"` | `BleManager.playSound("unpair")` in server disconnect handler | No |

### BLE Packet Sound Broadcasting

When the server plays a non-alarm sound, it broadcasts the sound to all connected clients:

```ts
if (this.isServer && key !== "alarm") {
  this.module.broadcastPacket(`sound:1|${SoundsToPacketMapper(key)}`);
}
```

Packet format: `sound:1|{code}` where `1` means "play" and code maps to the sound key. Clients receiving this packet play the corresponding sound locally, ensuring both devices hear the same audio cue.

The alarm sound is excluded from broadcasting because the alarm state is already synchronized via the `a:f|1|u` packet.

### Audio Mode Configuration

```ts
await Audio.setAudioModeAsync({
  staysActiveInBackground: true,
  playsInSilentModeIOS: true,
  interruptionModeIOS: InterruptionModeIOS.DoNotMix,
  interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
  shouldDuckAndroid: false,
  playThroughEarpieceAndroid: false,
});
```

- **Stays active in background**: Alarm continues when app is backgrounded
- **Plays in silent mode**: Critical for a safety device -- alarm must sound even on silent
- **DoNotMix**: Alarm takes over the audio session, ducking no other audio
- **Earpiece**: Sound plays through the speaker, not the earpiece

### Volume Management

Before playing any sound, the BLE manager checks and forces volume to maximum:

```ts
if ((await VolumeManager.getVolume()).volume < 1) {
  await VolumeManager.showNativeVolumeUI({ enabled: true });
  await VolumeManager.setVolume(1);
}
```

This ensures the alarm is always audible, even if the user has volume turned down.

### Sound Playback by Platform

```ts
if (Platform.OS === "ios") this.loadedSounds.get(key)?.sound?.replayAsync();
else this.loadedSounds.get(key)?.sound?.playFromPositionAsync(0);
```

iOS uses `replayAsync()` (resets to beginning), Android uses `playFromPositionAsync(0)`.

---

## 6. Lateral Menu Visibility

### Control Mechanism

The lateral menu visibility is controlled by `toggleIsShowingLateralMenu` from the `useLateralMenu` hook:

```ts
const { toggleIsShowingLateralMenu } = useLateralMenu();
```

### State-Menu Mapping

| State | Menu Visible | Call |
|-------|-------------|------|
| disconnected | Yes | `toggleIsShowingLateralMenu(true)` |
| scanning | Yes | `toggleIsShowingLateralMenu(true)` |
| migrating | Yes | `toggleIsShowingLateralMenu(true)` |
| connecting | Yes | `toggleIsShowingLateralMenu(true)` |
| connected | **No** | `toggleIsShowingLateralMenu(false)` |
| synced | **No** | `toggleIsShowingLateralMenu(false)` |
| alarm | **No** | `toggleIsShowingLateralMenu(false)` |

### Logic

The menu is hidden during active protective states (connected, synced, alarm) to prevent accidental navigation away from the protection screen. It is visible during connection-establishment states (disconnected, scanning, migrating, connecting) where the user might need to access settings or configuration.

### Initial State

On component mount:
```ts
useEffect(() => {
  toggleIsShowingLateralMenu(true);
  // ...
}, []);
```

The menu is shown by default. It is also re-shown in the `Index` wrapper component:
```ts
export default function Index() {
  const { toggleIsShowingLateralMenu } = useLateralMenu();
  useEffect(() => {
    toggleIsShowingLateralMenu(true);
  }, []);
  // ...
}
```

This double-initialization ensures the menu is visible even if the `App` component mounts before the lateral menu component.

---

## 7. The Ball Component Architecture

### Ball Props

```ts
const Ball: React.FC<{
  cx: SharedValue<number>;        // Center X
  cy: SharedValue<number>;        // Center Y
  r: SharedValue<number>;         // Base radius
  text?: SharedValue<string[]>;   // Text lines (unused in current rendering)
  red?: SharedValue<boolean>;     // Red dot texture flag
  border: SharedValue<boolean>;   // Border visibility
  appState: SharedValue<AppConnectionState>;
  visible?: SharedValue<boolean>; // Group opacity flag
  icon?: SharedValue<boolean>;    // Swipe-down icon visibility
}>
```

### Two Ball Instances

The Canvas renders two Ball instances:

**Red Ball (background):**
```tsx
<Ball
  cx={ballX}           // SharedValue(width / 2)
  cy={cy}              // SharedValue(height - height / 3 + 80)
  r={useSharedValue(defaultBallRadius)}
  red={useSharedValue(true)}
  border={useSharedValue(false)}
  visible={showRedBall}
  appState={appState}
/>
```
- Static position at the bottom of the screen
- No border, always red texture
- Visibility controlled by `showRedBall`

**Blue Ball (interactive):**
```tsx
<Group transform={derivedTransform} origin={derivedScaledOrigin}>
  <Ball
    cx={ballXV}        // DerivedValue from ballPosition
    cy={y}             // SharedValue (gesture-driven)
    r={ballRadius}     // SharedValue (state-driven)
    red={useSharedValue(false)}
    icon={iconVisibility}
    border={borderVisibility}
    visible={useSharedValue(true)}
    appState={appState}
  />
</Group>
```
- Position driven by gesture (`y`) and state (`ballXV`)
- Wrapped in a `<Group>` with `derivedTransform` and `derivedScaledOrigin`
- Always visible, texture switches based on state

### Ball Internal Rendering Layers

Each Ball renders 4 layers in order:

1. **Main circle** with image shader (dot texture + blur)
   - `pulsingRadius` drives the circle size
   - `ImageShader` applies `redDot.png` or `blackDot.png` texture
   - `Blur blur={22}` creates soft edges
   - `pulsingScale` transform for inner pulse

2. **Inner stroke ring**
   - `innerStrokeRadius` (radius - 20)
   - 0.6px stroke width
   - Color: `#BCD4DF` (active) or `#889AA2` (disconnected) or `transparent` (no border)

3. **Outer stroke ring** (scanning/connecting only)
   - `outterStrokeRadius` (outer pulse radius - 20)
   - 0.75px stroke width
   - Opacity: `interpolate(pulseFactorOutter, [1, 1.2], [1, 0.2])` -- fades as ring expands
   - Only visible during `scanning` or `connecting` states

4. **Swipe-down icon** (disconnected only)
   - `iconImage` (down_arrow_swipe texture)
   - Position: centered below ball (`cx - 5`, `cy + 30`)
   - Opacity: `icon?.value ? 1 : 0`
   - Animated Y position: `iconAlignY + iconAnimationValue` (2px bob, 1000ms oscillation)

---

## 8. Directional Arrow Icons

### Two Sets of Arrows

**Normal arrows** (connected/synced):
- `iconImageUp`: `arrow_up.png`
- `iconImageDown`: `arrow_down.png`
- Opacity: `iconsOpacity` = 1 when `bottomAndTopIconVisibility` is true AND state is not alarm

**Alarm arrows** (alarm state):
- `iconBlueImageUp`: `alarm_arrows/Frame16.png`
- `iconBlueImageDown`: `alarm_arrows/Frame14.png`
- Opacity: `iconsBlueOpacity` = 1 only during alarm state

### Arrow Positioning

```ts
const iconsAlignX = useDerivedValue(() => ballXV.value - 6, [ballXV.value]);

const iconsAlignYBot = useDerivedValue(
  () => y.value + 55 + iconsAlignYBotAnimated.value,
  [y.value, iconsAlignYBotAnimated, iconsAlignYBotAnimated.value],
);
const iconsAlignYTop = useDerivedValue(
  () => y.value - 75 + iconsAlignYBotAnimated.value * -1,
  [y.value, iconsAlignYBotAnimated, iconsAlignYBotAnimated.value],
);
```

- X: centered on ball (`ballXV - 6`, accounting for 10px icon width)
- Bottom arrow: `y + 55` + bob animation (below ball)
- Top arrow: `y - 75` + inverse bob animation (above ball)
- The bob animations are synchronized in opposite directions: bottom goes up while top goes down, creating a breathing effect

### Arrow Bob Animation

```ts
const iconsAlignYBotAnimated = useSharedValue(0);
useEffect(() => {
  iconsAlignYBotAnimated.value = withRepeat(
    withTiming(4, { duration: 300 }),
    -1,
    true,
  );
}, []);
```

4px oscillation at 300ms period, infinite, reversing. Applied positively to bottom arrow, negatively (inverted) to top arrow.

---

## 9. State Transition Diagram

```
                    +--- user swipes (threshold met) ---+
                    |                                    |
                    v                                    |
              +-----------+                             |
              | scanning  | ---- BLE found server ----> |
              +-----------+                             |
                    |                                   |
                    v                                   |
              +-----------+                             |
              | connecting| ---- handshake complete --> |
              +-----------+                             |
                    |                                   |
                    v                                   |
              +-----------+                             |
              | connected | ---- session received ----> |
              +-----------+                             |
                    |                                   |
                    v                                   |
              +-----------+                             |
              |  synced   | <---------------------------+
              +-----------+
                    |
        +-----------+-----------+
        |                       |
   user presses            app backgrounded
   disconnect              (server only)
        |                       |
        v                       v
  +-----------+           +-----------+
  |disconnect |           |  alarm    |
  | consensus |           +-----------+
  +-----------+                |
        |                      |
        v                      v
  +-----------+           +-----------+
  |disconnected|          | alarm stop|
  +-----------+           | consensus |
        ^                 +-----------+
        |                      |
        +----------------------+

  +-----------+  higher advertiser found
  | migrating | <---- (server migration)
  +-----------+
        |
        v
  +-----------+
  | scanning  |  (re-enters scan cycle)
  +-----------+
```

### Key Transitions

| From | To | Trigger | Package |
|------|----|---------|---------|
| disconnected | scanning | Swipe threshold met | `showRedBall=false` -> `startAdvertising` + `startScanning` |
| scanning | connecting | BLE device found with higher advertised value | `appState.value = "connecting"` |
| scanning | disconnected | Scanning timeout (7.5s) or BLE error | `appState.value = "disconnected"`, `hasConnectionError = true` |
| connecting | synced | Server responds with session packet | `appState.value = "synced"` |
| connecting | disconnected | Connection error or timeout | `appState.value = "disconnected"`, `hasConnectionError = true` |
| connected | synced | Session packet received from server | `appState.value = "synced"` |
| synced | disconnected | Disconnect consensus reached | `appState.value = "disconnected"`, plays "unpair" |
| synced | alarm | App backgrounded (server) or disconnect with alarm enabled | `appState.value = "alarm"`, plays "alarm" |
| alarm | disconnected | Stop-alarm consensus reached | `appState.value = "disconnected"`, stops alarm sound |
| synced | scanning/migrating | Server migration (higher advertiser found) | `appState.value = "migrating"` or `"scanning"` |

---

## 10. SharedValue Registry

### All SharedValues in App Component

| SharedValue | Type | Initial | Purpose |
|-------------|------|---------|---------|
| `ballRadius` | `number` | `defaultBallRadius` (86/96) | Blue ball base radius |
| `isPeripheralServer` | `boolean` | `false` | Whether this device is the BLE server |
| `hasConnectionError` | `boolean` | `false` | Connection error flag for retry logic |
| `appState` | `AppConnectionState` | `"disconnected"` | **Master state** -- drives everything |
| `ballPosition` | `"center"` | `"center"` | Ball horizontal position |
| `alarmPos` | `"center"` | `"center"` | Alarm button position |
| `showRedBall` | `boolean` | `true` | Red ball visibility + scan trigger |
| `borderVisibility` | `boolean` | `true` | Border ring visibility |
| `iconVisibility` | `boolean` | `true` | Swipe-down icon visibility |
| `bottomAndTopIconVisibility` | `boolean` | `false` | Directional arrow visibility |
| `ballText` | `string[]` | `["SWIPE", "TO CONNECT"]` | Ball text lines |
| `ballX` | `number` | `width / 2` | Ball X position (static) |
| `y` | `number` | `height / 4` | Blue ball Y position (gesture-driven) |
| `cy` | `number` | `height - height/3 + 80` | Red ball Y position |
| `shouldBlock` | `boolean` | `false` | Gesture blocking flag |
| `retries` | `number` | `7` | Retry counter |
| `iconsAlignYBotAnimated` | `number` | `0` | Bottom icon bob animation |

### All DerivedValues in App Component

| DerivedValue | Depends On | Purpose |
|-------------|------------|---------|
| `ballXV` | `ballPosition` | Ball X from position ("center" -> width/2) |
| `derivedTransform` | `ballRadius` | Scale transform (78->1x, 360->3x) |
| `derivedScaledOrigin` | `ballXV`, `y` | Scale origin point |
| `iconsAlignX` | `ballXV` | Arrow X position |
| `iconsAlignYBot` | `y`, `iconsAlignYBotAnimated` | Bottom arrow Y |
| `iconsAlignYTop` | `y`, `iconsAlignYBotAnimated` | Top arrow Y |
| `iconsOpacity` | `bottomAndTopIconVisibility`, `appState` | Normal arrow opacity |
| `iconsBlueOpacity` | `appState` | Alarm arrow opacity |
| `paragraph` | `fontMgr`, `ballText`, `appState` | Skia text paragraph |
| `textAlign` | `y`, `appState`, `lang` | Text Y position |
| `textAlignX` | `ballXV` | Text X position |

### All DerivedValues in Ball Component

| DerivedValue | Depends On | Purpose |
|-------------|------------|---------|
| `pulsingRadius` | `r`, `pulseFactor`, `appState` | Inner circle radius with pulse |
| `pulsingRadiusOutter` | `r`, `pulseFactorOutter`, `appState` | Outer ring radius with pulse |
| `isVisible` | `visible` | Group opacity (0 or 1) |
| `borderColor` | `border`, `appState` | Border ring color |
| `innerStrokeRadius` | `pulsingRadius`, `appState`, `r` | Inner ring radius (radius - 20) |
| `outterStrokeRadius` | `pulsingRadiusOutter` | Outer ring radius |
| `outterStrokeOpacity` | `appState`, `pulseFactorOutter` | Outer ring fade |
| `imageShaderX` | `cx` | Texture X offset |
| `imageShaderY` | `cy`, `red` | Texture Y offset |
| `iconAlignX` | `cx` | Icon X position |
| `iconAlignY` | `cy` | Icon Y position |
| `iconOpacity` | `icon` | Icon visibility |
| `ballOrigin` | `cx`, `cy` | Texture origin point |
| `pulsingScale` | `pulseFactor` | Inner pulse scale transform |
| `imageSelected` | `red`, `appState` | Texture selection (red vs black) |
| `iconAnimationY` | `iconAlignY`, `iconAnimationValue` | Animated icon Y |
