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.
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
| 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
.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:
-
State evaluation: Reads
appState.valueand setsshouldBlocktotrueif the app is in any active state (connected, alarm, synced, scanning, connecting). Onlydisconnectedandmigratingallow the gesture to proceed. The blocking decision is captured once at the start of the gesture -- it does not re-evaluate mid-gesture. -
Haptic feedback: Fires
ImpactFeedbackStyle.SoftviarunOnJSbridge. 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
shouldBlockistrue, all movement is suppressed -- the gesture handler returns immediately without modifyingy
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 (triggersuseAnimatedReaction->setShouldStartScan(true))appState.value = "scanning"-- enters scanning stateretries.value = 0-- resets retry counterhasConnectionError.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 stateretries.value = 7-- resets retries to max (effectively disabling retry since the check isretries.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) ANDisDisconnectButtonPressedis still true:- Sets
serverAlreadyShouldDisconnect = true - Broadcasts
d:a|ato all clients (authorization to disconnect) - Clears the interval
- Sets
- 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
- If
Client-side (this device is a client):
- Sends
d:r|spacket to server (disconnect request, start phase) - The server responds with
d:a|awhen 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
serverDisconnectionTimeoutinterval - 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|rto asked clients (rejection -- they pressed alone) - Triggers RSSI proximity monitoring setup
- Waits 3500ms, then sends
Client-side:
- Sends
d:r|fto server (disconnect request, finish phase) - If
isAmIClientAllowedToDisconnectEdgeCaseis true, sendsa:r|ffinstead (alarm-related edge case)
Consensus Mechanism (Disconnect)
The disconnect protocol requires all devices to agree before disconnection occurs:
- Press start: Client sends
d:r|sto server. Server adds client toaskedDisconnectionDevices. - Polling: Server checks every 500ms if ALL connected clients have requested disconnection.
- Press finish: Client sends
d:r|f. Server removes client fromaskedDisconnectionDevices. - Decision: If server's own button was pressed AND all clients requested disconnection, server broadcasts
d:a|a(authorize disconnect) to all. - Edge case: If a single client presses disconnect without the server pressing, the server sends
d:a|r(reject) and may trigger alarm iffireAlarmOnDisconnectis 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.lengthANDisAlarmButtonPressed:- Broadcasts
a:f|0|a(alarm off, authorize, to all) - Sets
appState.value = "disconnected" - Stops alarm sound
- Fires
Haptics.notificationAsync(NotificationFeedbackType.Success) - Clears interval
- Broadcasts
- If
Client-side:
- If
isAmIClientAllowedToDisconnectEdgeCase: sendsa:r|ffand schedules disconnect after 1500ms - Always sends
a:r|sto server (alarm stop request, start phase)
onPressOut -> BleManager.onStopAlarmPressFinish():
Haptics.impactAsync(ImpactFeedbackStyle.Heavy);
BleManager.onStopAlarmPressFinish();
Server-side:
- Sets
isAlarmButtonPressed = false - Clears the
stopAlarmTimeoutinterval - Resets
askedToStopAlarmClients
Client-side:
- Sends
discq:{sessionId}to server - If edge case: sends
a:r|ff - Sends
a:r|fto server (alarm stop request, finish phase)
Consensus Mechanism (Alarm Stop)
Same pattern as disconnect:
- Press start: Client sends
a:r|sto server. Server adds client toaskedToStopAlarmClients. - Polling: Server checks every 500ms if ALL connected clients have requested alarm stop.
- Press finish: Client sends
a:r|f. Server removes client fromaskedToStopAlarmClients. - 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:
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
| 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
- Initial state:
retries = 7(exceeds the<= 5threshold, so no retries happen) - Swipe-to-connect:
retriesreset to0(allows retries) - Connection error:
hasConnectionError.valueset totrueby BLE manager retryConnect()called: From theuseEffectwatchingshouldStartScan-- when scan stops andhasConnectionErroris true- First retry:
retriesincremented to 1, Snackbar shown, ball springs to red position, scan restarts - Subsequent retries:
retriesincrements each time (2, 3, 4, 5) - Max retries exceeded: When
retriesreaches 6, the conditionretries.value <= 5fails, retries stop - Successful connection: On reaching
connectedorsyncedstate,retries.value = 0is set (buthasConnectionErroris alsofalse, preventing further retries) - User cancels (gesture release below threshold):
retriesreset 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
| 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:
.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,
shouldBlockisfalseand the gesture proceeds even if the state changes mid-gesture - If the user starts swiping while connected,
shouldBlockistrueand 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:
- No bridge crossing: The pan gesture callbacks run on the UI thread. Reading a
SharedValuestays on the UI thread. Reading React state would requirerunOnJS, introducing latency and potential frame drops. - Synchronous evaluation: The
onChangeandonEndcallbacks checkshouldBlock.valuesynchronously on the UI thread. - 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:
shouldBlockprevents gesture interaction during active statesretriescontrols automatic reconnection after failures- Both reset on successful connection (
retries = 0,shouldBlockbecomes irrelevant since state changes tosynced) - 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)
{
bottom: height / 7,
right: width / 2 - (80 * 1.3) / 2,
}
- Vertically:
height / 7from screen bottom (roughly 14% up from bottom) - Horizontally: Centered (
width / 2 - 52for a 104px wide button) - Size: 104 x 104px (80 * 1.3)
- Shape: Circular (
borderRadius: 100, which isgestureRadius)
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;