CAMDOM - i18n System, Storage, Permissions, and Menu Architecture
Internationalization system with pipe-delimited ball text and f: prefix, MMKV storage keys, BLE permission flow, lateral menu hook, and the complete menu/page routing system
i18n System
Architecture
CAMDOM uses i18next via react-i18next with expo-localization for device locale detection. The setup is in utils/i18n.ts.
expo-localization (getLocales) -> i18n.init({ lng }) -> react-i18next -> useTranslations hook
Supported Languages
| Code | Language | Notes |
|---|---|---|
en | English | Fallback language |
en_DE | English in Germany | Uses German translations |
de | German | Primary non-English locale |
de_DE | German in Germany | Same as de |
The resources object maps both en_DE/de_DE to the same de translations. The fallback chain is: detected locale -> "en".
Custom Hook: useTranslations
export const useTranslations = () => {
const { t } = useTranslation();
const wrappedT = (key: keyof Translations) => t(key);
return wrappedT;
};
This wraps t() to enforce type safety -- only valid translation keys are accepted (derived from the English translation object via typeof en.translation). The return type Translations is exported for type reference elsewhere.
Pipe-Delimited Ball Text Format
The main UI text displayed on the Skia ball is split by | (pipe) characters. Each pipe-delimited segment renders as a separate line on the canvas via Skia.ParagraphBuilder.
Example translation values:
ui_state_swipe: "Swipe down | for protection "
ui_state_scanning: "Scanning for devices | f:7<> | f:9<>Please Wait"
ui_state_connecting: "Connecting devices | f:7<> | f:9<>Please Wait"
ui_state_syncing: "Syncing | Devices"
ui_state_synced: "Privacy locked | Unlock your pleasure"
ui_state_alarm: "Unprotected"
In app/index.tsx, the text is split at runtime:
const ballText = useSharedValue(t("ui_state_swipe").split("|"));
The f: Prefix System
Lines within the ball text can carry inline font-size overrides using the f:<size><><text> syntax:
f:7<> | f:9<>Please Wait
This means:
f:7-- set font size to 7 (adjusted to 6 onsmbreakpoint:breakpoint === "sm" ? fontSize - 1 : fontSize)<>-- delimiter between font size and the text content-- the actual text to render at that size
The parsing logic in app/index.tsx:
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, color: Skia.Color(...), fontFamilies: ["BasicSans"] });
builder.addText(lineData[1].toUpperCase() + "\n");
} else {
builder.pushStyle({ fontSize: 11, color: ..., fontFamilies: ["BasicSans"] });
builder.addText(line.toUpperCase() + "\n");
}
Lines without the f: prefix render at the default font size (11px, or 10px on sm breakpoint). All text is uppercased for display. The paragraph is laid out at width 240 and centered.
Translation Key Categories
| Category | Keys | Purpose |
|---|---|---|
| Onboarding | onboarding_step_1 through onboarding_step_6 | Step-by-step user introduction |
| UI States | ui_state_swipe, ui_state_scanning, ui_state_connecting, ui_state_syncing, ui_state_synced, ui_state_alarm | Ball text per connection state |
| UI Buttons | ui_state_button_disconnect, ui_state_button_alarm | Hold-to-action button labels |
| Menu | menu_option_1 through menu_option_4 | Lateral menu navigation items |
| Auth | sign_up_token, create_account, acess_account, enter, username, email, password, etc. | Account flow |
| About | here_for_ease, pleasure_and_joy, here_for_inclusivity, and_respect, etc. | Brand messaging |
| Buy | get_your_billy, boy_now, buy_here, here_to_protect_you, in_the_real_world | Commercial CTA |
| Errors | error, email_taken, email_invalid, wrong_email_or_password, passwords_do_not_match | Validation messages |
| Permissions | ask_permission, ask_permission_text, bluetooth_must_be_on | Permission flow text |
Storage System (MMKV)
StorageKeys
All persistent keys are defined in utils/StorageKeys.ts as SHA-1-like hashes (not human-readable) for obfuscation:
| Key Constant | Hash Value | Type | Purpose |
|---|---|---|---|
isOnboardingComplete | b0662c75893623082afb13018d22a4bd5b731fb0 | Boolean | Whether the user has completed onboarding |
isShowingLateralMenu | 9f662c7589362308221513018d22a4bd53731fe5 | Boolean | Whether the lateral menu (sidebar) is visible |
isMenuOpen | 9f662c758h162308221513018d22a4bd53731fe5 | Boolean | Whether the full-screen menu overlay is open |
isAnyOptionSelected | 9f662c7589362308221513019f22a4bd53731fe5 | Boolean | Whether a menu page is currently selected |
selectedOption | 9f662c7512362308221513019f22a4bd53731fe5 | String | Which page is selected: "howto", "about", "buycondom", "options", or "none" |
rssiSense | 9f662c75123623082271513029f22a4bd53731fe2 | Boolean | Controls RSSI-based proximity sensitivity for BLE distance detection |
fireAlarmOnDisconnect | 9f662c75121627082231513629f22a4bd53731fe2 | Boolean | Whether to trigger the alarm when a device disconnects without consent |
exitedWithConsent | 9f662c7512n6l7081231513629f22a4bd53731fe2 | Boolean | Tracks whether the last disconnection was consensual |
Persistence Layer
Storage uses react-native-mmkv (memory-mapped key-value store) with hooks:
useMMKVBoolean(key)-- for boolean flagsuseMMKVString(key)-- for string values
MMKV is chosen over AsyncStorage for its synchronous read/write performance and encryption support. All menu and BLE state is persisted, meaning the app resumes exactly where the user left it.
State Flow
The fireAlarmOnDisconnect flag is the critical safety setting. When true, any BLE disconnection without the consensual unlock process triggers the alarm (sound + haptics + visual pulse). The exitedWithConsent flag records whether the disconnection went through the proper unlock flow.
Permission System
requestPermissions Flow
Located in utils/requestPermissions.ts. Takes a callback (result: boolean) => void.
iOS
On iOS, permissions are auto-granted (no explicit BLE permission dialog needed). The callback receives true immediately:
} else {
cb(true);
}
Android -- API Level Split
API < 31 (Android 11 and below):
- Requests
ACCESS_FINE_LOCATIONviaPermissionsAndroid.request() - BLE scanning on older Android requires location permission
- Dialog shows title "Location Permission" with message "Bluetooth Low Energy requires Location"
API >= 31 (Android 12+):
- Uses
react-native-permissionsrequestMultiple()for the three granular BLE permissions:BLUETOOTH_SCAN-- scan for nearby devicesBLUETOOTH_CONNECT-- connect to paired devicesBLUETOOTH_ADVERTISE-- make device discoverable
- All three must be granted for the callback to receive
true - API level detected via
react-native-device-infogetApiLevel()
When Permissions Are Requested
Permissions are requested during onboarding (step >= 1). On the first "continue" press (step 0), iOS immediately completes onboarding. On Android (or if step >= 1), requestPermissions is called, and onboarding only completes if the user grants permissions.
useLateralMenu Hook
Purpose
Central state manager for all menu visibility and page selection. All state is persisted via MMKV, ensuring the menu state survives app restarts.
Interface
type ChangeSelectedOption = "howto" | "about" | "buycondom" | "options" | "none";
interface ILateralMenu {
isShowingLateralMenu: boolean; // Is the sidebar trigger visible
toggleIsShowingLateralMenu: (value?: boolean) => void;
isMenuOpen: boolean; // Is the full menu overlay open
toggleMenu: (value?: boolean) => void;
isAnyOptionSelected: boolean; // Is any page selected
toggleIsAnyOptionSelected: (value?: boolean) => void;
selectedOption: ChangeSelectedOption; // Which page
changeSelectedOption: (option: ChangeSelectedOption) => void;
}
State Relationships
toggleIsShowingLateralMenu(true)
-> isShowingLateralMenu = true
-> isMenuOpen = false
-> isAnyOptionSelected = false
-> selectedOption = "none"
toggleMenu(true)
-> isMenuOpen = true
-> (menu overlay slides in from left)
changeSelectedOption("about")
-> isAnyOptionSelected = true
-> selectedOption = "about"
-> (MenuList hides, MenuTitle shows "about", MenuContent renders <About />)
changeSelectedOption("none")
-> isAnyOptionSelected = false
-> selectedOption = "none"
-> (MenuList shows again, MenuContent hides)
Default Values
isShowingLateralMenu: defaults totrue(sidebar visible)isMenuOpen: defaults tofalse(menu closed)isAnyOptionSelected: defaults tofalseselectedOption: defaults to"none"
Menu System Architecture
Component Hierarchy
Index (app/index.tsx)
+-- Menu (full-screen overlay)
| +-- NoisyBlurredBackground (Skia canvas, z-index: -2)
| +-- SafeAreaView
| +-- MenuTitle (shows selected page title with separators)
| +-- MenuList (shows 4 menu buttons with separators)
| +-- MenuContent (renders the selected page)
| +-- HowTo / About / BuyComdom / Options
| +-- BillyBoyLogo (visible on Options and BuyComdom)
+-- LateralMenu (sidebar trigger area)
+-- App (main canvas with BLE balls)
Menu Component (components/Menu/Menu.tsx)
The full-screen overlay. Renders when isMenuOpen is true.
- Animation:
makeAnimationgenerator withstaggerandtiming-- 30ms duration transitions - Backdrop:
NoisyBlurredBackgroundwithballs,blur={100},opacity={0.9}-- the signature Skia noise texture - Android Back Button:
BackHandlerlistener closes the menu on hardware back press - Visibility: Opacity animates from 0 to 1;
displaytoggles between"flex"and"none"
MenuTitle Component (components/Menu/MenuTitle.tsx)
Displays the title of the currently selected page, framed by animated separators.
- Animation: 240ms duration, staggered separator + content transitions
- Separator animation: Width interpolates from 0% to 100% using Skia's
interpolate - About special case: When
selectedOption === "about", the title shows "about " + BillyBoyLogo image inline - Other options: Plain text title
- Timing: Waits 360ms before showing (when opening) to allow MenuList to exit first
MenuList Component (components/Menu/MenuList.tsx)
The main navigation list with 4 buttons, each separated by animated dividers.
- 4 buttons:
howto,about,buycondom,options - Animation: 120ms staggered reveal, 60ms offset between items
- Separators: Width interpolates from 0% to 100% using Skia's
interpolate - Visibility logic: Only shows when
isMenuOpen && !isAnyOptionSelected - Button feedback:
Haptics.impactAsync(ImpactFeedbackStyle.Heavy)on press - About button: Shows "about " + BillyBoyLogo inline
- Footer: BillyBoyLogo + version number (invisible, opacity 0)
MenuContent Component (components/Menu/MenuContent/MenuContent.tsx)
Routes to the correct page based on selectedOption:
| selectedOption | Component |
|---|---|
"howto" | <HowTo /> |
"about" | <About /> |
"buycondom" | <BuyComdom /> |
"options" | <Options /> |
- Animation: 240ms, staggered reveal
- Delay: When a non-options page is selected, waits 440ms before animating in (allows MenuList exit)
- Options/BuyComdom footer: BillyBoyLogo + version text (v1.2.0) at bottom
- Visibility:
displaytoggles between"flex"and"none"based on animation state
Menu Pages
HowTo Page (Pages/HowTo/HowTo.tsx)
Displays usage instructions for CAMDOM.
- Title:
install_on_all_devicestranslation - Bluetooth notice:
bluetooth_must_be_onin a styled container - Content:
<CamdomInstruction />component (step-by-step visual guide)
About Page (Pages/About/About.tsx)
Brand messaging page for BILLY BOY partnership.
- Displays a series of translation pairs (e.g., "Here for ease," "pleasure and joy.")
- "Here for you." at the end (larger spacing)
- CTA button: Links to
https://billy-boy.de - Uses
adjustsFontSizeToFiton the body text
BuyComdom Page (Pages/BuyComdom/BuyComdom.tsx)
Commercial redirect page.
- Text: "Here to protect you" + "in the real world" + "Discover your BILLY BOY condoms now"
- CTA button: Links to
https://www.billy-boy.de/produkte/ - Reuses About page styles
Options Page (Pages/Options/Options.tsx)
Settings page (currently minimal).
- Active item: "Terms of Service" linking to
https://www.pay4brain.com/privacy_camdom - Commented out items (present in code but disabled):
- Sign out button (calls
auth()?.signOut()) - Delete account button (calls
auth()?.currentUser?.delete()thenauth()?.signOut())
- Sign out button (calls
- Animation: 240ms staggered reveal, 360ms initial delay
- Uses only 1 of the 3 menu item animation slots (others are commented out)
NoisyBlurredBackground Component
File: components/NoisyBlurredBackground.tsx
What It Does
Renders the signature CAMDOM background: two animated, pulsing, blurred circles (balls) on a Skia canvas with noise textures. These create the organic, living background effect.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
balls | boolean | undefined | Show/hide the animated balls |
size | number | 120 | Base radius of the balls |
displace | boolean | undefined | Offset balls from center |
darken | boolean | undefined | (declared but not used in rendering) |
opacity | number | 1 | Opacity of the ball group |
blur | number | 40 | Blur radius applied to each ball |
Architecture
- Canvas: Full-screen Skia
Canvaswithposition: absolute,zIndex: -2, background color from theme - AnimatedBall (internal): Each ball is a
Circlewith:ImageShaderusing eitherred-dot.pngorblack-dot.pngtexture (loaded viauseImageAsTexture)Blurfilter applied inside the circle- Pulsing radius animation:
withRepeat(withTiming(size * 1.4, { duration }), -1, true)-- oscillates between base size and 1.4x - Red ball: 6000ms animation cycle; Black ball: 5000ms cycle
- Positioning: Red ball is positioned at
(width, height - height/3), black ball at(width - 80, height/2 - 30)with optional displacement
Usage Locations
- Menu overlay (
Menu.tsx):balls blur={100} opacity={0.9}-- large, very blurred balls behind menu - Onboarding (
onboarding.tsx):<NoisyBlurredBackground />-- default settings (no balls prop) - Main index (
app/index.tsx): Not directly -- the main screen uses theBallcomponent which has similar rendering
Separator Component
File: components/Separator.tsx
A simple horizontal line used throughout the UI.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
marginBottom | number | undefined | Bottom margin |
height | number | 1 | Line thickness |
Rendering
<View style={{
width: "100%",
height: height ?? 1,
backgroundColor: colors.primary,
borderRadius: 2,
marginBottom: marginBottom,
}} />
Full-width line in the theme's primary color, 1px height default, 2px border radius. Used in the onboarding flow and menu titles as a visual divider.
Cross-Cutting: Animation System
All menu components use a shared animation utility from utils/Animations:
makeAnimation(generatorFn, initialState)-- Creates a worklet-based animation driven by a generator functionstagger(offset, ...animations)-- Runs animations sequentially with an offset delaytiming(sharedValue, { to, duration })-- Linear timing animationwait(ms)-- Delay within the generatorwaitUntil(sharedValue, condition)-- Blocks until a shared value matchesuseAnimation(definition)-- Returns shared values for the animation state
The generator pattern allows expressing complex sequential animations (open, wait, stagger items in, close, stagger items out) as readable imperative code that runs on the UI thread via Reanimated worklets.