WikifitaGitHub live67e8de5
outro · camdom/camdom-gesture-system

CAMDOM — Gesture System

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.

Baixar raw

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

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

ParameterValueSource
gestureRadius100Hardcoded constant
Touch target size200 x 200 pxgestureRadius * 2
y initial valueheight / 4Computed from screen dimensions
cy (red ball Y) initialheight - height / 3 + 80Derived from screen height
blueBallInitialYheight / 3 + 20Top position for blue ball
redBallInitialYheight - height / 3 + 20Bottom position for red ball

onStart

.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

.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

.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

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

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

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

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

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

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

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 TypeStyleWhen FiredContext
ImpactFeedbackStyle.SoftLight tapPan gesture onStartGesture recognized
ImpactFeedbackStyle.SoftLight tapDisconnect onLongPressLong press threshold crossed
ImpactFeedbackStyle.HeavyStrong tapDisconnect onPressInButton press starts
ImpactFeedbackStyle.HeavyStrong tapDisconnect onPressOutButton press ends
ImpactFeedbackStyle.HeavyStrong tapStop-alarm onPressInAlarm button press starts
ImpactFeedbackStyle.HeavyStrong tapStop-alarm onPressOutAlarm button press ends
NotificationFeedbackType.ErrorError vibrationAlarm ball animation peaksBall reaches max radius (360) and min radius (310) during alarm pulse
NotificationFeedbackType.SuccessSuccess vibrationDisconnect consensus reached (server)All devices agreed to disconnect
NotificationFeedbackType.SuccessSuccess vibrationStop-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:

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

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

ParameterValueNotes
Initial retries value7Set at component mount
Max retries allowed5Check: retries.value <= 5
Delay between retries500msVia sleep(500)
FeedbackSnackbarSnackbar.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

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

StateBlocks?Reason
disconnectedNoDefault state -- gesture allowed
scanningYesConnection attempt in progress
connectingYesBLE connection establishing
connectedYesConnection active, transition to synced imminent
syncedYesFull connection active, disconnect button is the exit path
alarmYesAlarm active, stop-alarm button is the exit path
migratingNoServer migration in progress, allows re-gesture

Evaluation Timing

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

.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

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

OperationThreadMechanism
Gesture callbacksUIReanimated worklet
shouldBlock read/writeUISharedValue
y.value read/writeUISharedValue
appState.value read/writeUISharedValue
showRedBall.value read/writeUISharedValue
Haptic feedback (in gesture)JSrunOnJS(Haptics.impactAsync)
setShouldStartScanJSrunOnJS(setShouldStartScan)
setAppCurrentStateJSrunOnJS(setAppCurrentState)
BleManager.* callsJSDirect calls from useEffect
Snackbar.showJSDirect calls from retryConnect
Spring/timing animationsUIwithSpring, withTiming, withRepeat
clampUIReanimated utility
interpolateUIReanimated utility

8. Animation Parameters

Spring Configurations

ContextConfigEffect
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

ContextEasingDuration
Ball pulse (inner)Easing.inOut(Easing.ease)2500ms
Ball pulse (outer)Easing.in(Easing.ease)1160ms
Synced ball radiusEasing.inOut(Easing.linear)1900ms
Alarm expandLinear (default)300ms
Alarm contractLinear (default)500ms
Icon bob animationwithTiming(2/-2, { duration: 1000 })1000ms each direction
Bottom/top icon bobwithTiming(4, { duration: 300 })300ms oscillation

Repeat Configurations

AnimationCountReversePurpose
Inner pulse-1 (infinite)trueSmooth breathing effect
Outer pulse-1 (infinite)falseScanning/connecting ring fade
Synced radius-1 (infinite)trueGentle 300-360 oscillation
Alarm radius-1 (infinite)falseAggressive 310-360 pulse
Icon bob (down arrow)-1 (infinite)trueGentle vertical oscillation
Bottom/top icons-1 (infinite)trueSynchronized bob

9. Button Positioning

Disconnect Button (synced state)

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

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:

const lang = i18n.resolvedLanguage;