WikifitaGitHub live67e8de5
outro · camdom/camdom-ui-state-machine

CAMDOM — UI State Machine

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.

Baixar raw

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)

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:
    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:
    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)

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)

useAnimatedReaction(
  () => showRedBall.value,
  (prep, previous) => {
    if (!previous && prep) {
      runOnJS(setShouldStartScan)(false);
    }
    if (previous && !prep) {
      runOnJS(setShouldStartScan)(true);
    }
  },
  [showRedBall.value],
);
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:

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]);
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

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>:

<Group transform={derivedTransform} origin={derivedScaledOrigin}>
  <Ball cx={ballXV} cy={y} r={ballRadius} ... />
</Group>

derivedScaledOrigin

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

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:

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:

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

StateballText ValueFont SizeColor
disconnected`t("ui_state_swipe").split("")`11px (10 on sm)
scanning`t("ui_state_scanning").split("")`11px (10 on sm)
connecting`t("ui_state_connecting").split("")`11px (10 on sm)
connected`t("ui_state_syncing").split("")`11px (10 on sm)
synced`t("ui_state_synced").split("")`11px (10 on sm)
alarm[t("ui_state_alarm")]14px#D9D9D9
migrating`t("ui_state_connecting").split("")`11px (10 on sm)

Paragraph Builder

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

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():

KeyFilePacket 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

StateSoundTriggerLooping
scanning"open"BleManager.playSound("open") in useEffectNo
migrating"open"BleManager.playSound("open") in useEffectNo
synced"pair"BleManager.playSound("pair") in useEffectNo
alarm"alarm"BleManager.playSound("alarm") in useEffectYes (setIsLoopingAsync(true))
disconnected (from error)"unpair"BleManager.playSound("unpair") in stopScanning callbackNo
disconnect consensus"unpair"BleManager.playSound("unpair") in server disconnect handlerNo

BLE Packet Sound Broadcasting

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

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

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:

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

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:

const { toggleIsShowingLateralMenu } = useLateralMenu();

State-Menu Mapping

StateMenu VisibleCall
disconnectedYestoggleIsShowingLateralMenu(true)
scanningYestoggleIsShowingLateralMenu(true)
migratingYestoggleIsShowingLateralMenu(true)
connectingYestoggleIsShowingLateralMenu(true)
connectedNotoggleIsShowingLateralMenu(false)
syncedNotoggleIsShowingLateralMenu(false)
alarmNotoggleIsShowingLateralMenu(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:

useEffect(() => {
  toggleIsShowingLateralMenu(true);
  // ...
}, []);

The menu is shown by default. It is also re-shown in the Index wrapper component:

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

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):

<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):

<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

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

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

FromToTriggerPackage
disconnectedscanningSwipe threshold metshowRedBall=false -> startAdvertising + startScanning
scanningconnectingBLE device found with higher advertised valueappState.value = "connecting"
scanningdisconnectedScanning timeout (7.5s) or BLE errorappState.value = "disconnected", hasConnectionError = true
connectingsyncedServer responds with session packetappState.value = "synced"
connectingdisconnectedConnection error or timeoutappState.value = "disconnected", hasConnectionError = true
connectedsyncedSession packet received from serverappState.value = "synced"
synceddisconnectedDisconnect consensus reachedappState.value = "disconnected", plays "unpair"
syncedalarmApp backgrounded (server) or disconnect with alarm enabledappState.value = "alarm", plays "alarm"
alarmdisconnectedStop-alarm consensus reachedappState.value = "disconnected", stops alarm sound
syncedscanning/migratingServer migration (higher advertiser found)appState.value = "migrating" or "scanning"

10. SharedValue Registry

All SharedValues in App Component

SharedValueTypeInitialPurpose
ballRadiusnumberdefaultBallRadius (86/96)Blue ball base radius
isPeripheralServerbooleanfalseWhether this device is the BLE server
hasConnectionErrorbooleanfalseConnection error flag for retry logic
appStateAppConnectionState"disconnected"Master state -- drives everything
ballPosition"center""center"Ball horizontal position
alarmPos"center""center"Alarm button position
showRedBallbooleantrueRed ball visibility + scan trigger
borderVisibilitybooleantrueBorder ring visibility
iconVisibilitybooleantrueSwipe-down icon visibility
bottomAndTopIconVisibilitybooleanfalseDirectional arrow visibility
ballTextstring[]["SWIPE", "TO CONNECT"]Ball text lines
ballXnumberwidth / 2Ball X position (static)
ynumberheight / 4Blue ball Y position (gesture-driven)
cynumberheight - height/3 + 80Red ball Y position
shouldBlockbooleanfalseGesture blocking flag
retriesnumber7Retry counter
iconsAlignYBotAnimatednumber0Bottom icon bob animation

All DerivedValues in App Component

DerivedValueDepends OnPurpose
ballXVballPositionBall X from position ("center" -> width/2)
derivedTransformballRadiusScale transform (78->1x, 360->3x)
derivedScaledOriginballXV, yScale origin point
iconsAlignXballXVArrow X position
iconsAlignYBoty, iconsAlignYBotAnimatedBottom arrow Y
iconsAlignYTopy, iconsAlignYBotAnimatedTop arrow Y
iconsOpacitybottomAndTopIconVisibility, appStateNormal arrow opacity
iconsBlueOpacityappStateAlarm arrow opacity
paragraphfontMgr, ballText, appStateSkia text paragraph
textAligny, appState, langText Y position
textAlignXballXVText X position

All DerivedValues in Ball Component

DerivedValueDepends OnPurpose
pulsingRadiusr, pulseFactor, appStateInner circle radius with pulse
pulsingRadiusOutterr, pulseFactorOutter, appStateOuter ring radius with pulse
isVisiblevisibleGroup opacity (0 or 1)
borderColorborder, appStateBorder ring color
innerStrokeRadiuspulsingRadius, appState, rInner ring radius (radius - 20)
outterStrokeRadiuspulsingRadiusOutterOuter ring radius
outterStrokeOpacityappState, pulseFactorOutterOuter ring fade
imageShaderXcxTexture X offset
imageShaderYcy, redTexture Y offset
iconAlignXcxIcon X position
iconAlignYcyIcon Y position
iconOpacityiconIcon visibility
ballOrigincx, cyTexture origin point
pulsingScalepulseFactorInner pulse scale transform
imageSelectedred, appStateTexture selection (red vs black)
iconAnimationYiconAlignY, iconAnimationValueAnimated icon Y