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.
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 atredBallInitialY(height - height / 3 + 20) - Blue ball at
blueBallInitialY(height / 3 + 20), withballRadius = defaultBallRadius(86 onsmbreakpoint, 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
redBallInitialYwithdamping: 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
syncedalmost 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.pngtexture (red pulsing ball) --imageSelected = truewhenred?.valueis true ORappState.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 ofcolors.background - Font size: 14px (vs 11px in other states) -- larger for urgency
bottomAndTopIconVisibility = true-- but rendered withiconsBlueOpacity(alarm-specific blue arrows:Frame16.pngandFrame14.pnginstead of normal arrows)- Directional arrows:
iconsOpacity = 0for normal arrows,iconsBlueOpacity = 1for 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:
showRedBallgoestrue -> false(user swiped):shouldStartScan = true-> triggers BLE advertising + scanningshowRedBallgoesfalse -> 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 = 1550to360 * 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
| State | ballText Value | Font Size | Color |
|---|---|---|---|
| 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.otfloaded viauseFonts - Layout width: fixed at 240px
- All text is uppercased (
.toUpperCase()) - Text alignment: center
- The paragraph is re-derived whenever
fontMgr,ballText.value, orappStatechanges
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(ory.value - 24for 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:
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
| 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:
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>withderivedTransformandderivedScaledOrigin - Always visible, texture switches based on state
Ball Internal Rendering Layers
Each Ball renders 4 layers in order:
-
Main circle with image shader (dot texture + blur)
pulsingRadiusdrives the circle sizeImageShaderappliesredDot.pngorblackDot.pngtextureBlur blur={22}creates soft edgespulsingScaletransform for inner pulse
-
Inner stroke ring
innerStrokeRadius(radius - 20)- 0.6px stroke width
- Color:
#BCD4DF(active) or#889AA2(disconnected) ortransparent(no border)
-
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
scanningorconnectingstates
-
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.pngiconImageDown:arrow_down.png- Opacity:
iconsOpacity= 1 whenbottomAndTopIconVisibilityis true AND state is not alarm
Alarm arrows (alarm state):
iconBlueImageUp:alarm_arrows/Frame16.pngiconBlueImageDown: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
| 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 |