---
type: reference
title: "CAMDOM - i18n System, Storage, Permissions, and Menu Architecture"
description: "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"
tags: [camdom, i18n, menu, storage, permissions, localization]
timestamp: "2026-07-20"
---

## 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`

```ts
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:
```ts
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 on `sm` breakpoint: `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`:

```ts
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 flags
- `useMMKVString(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:
```ts
} else {
  cb(true);
}
```

#### Android -- API Level Split

**API < 31 (Android 11 and below):**
- Requests `ACCESS_FINE_LOCATION` via `PermissionsAndroid.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-permissions` `requestMultiple()` for the three granular BLE permissions:
  - `BLUETOOTH_SCAN` -- scan for nearby devices
  - `BLUETOOTH_CONNECT` -- connect to paired devices
  - `BLUETOOTH_ADVERTISE` -- make device discoverable
- All three must be granted for the callback to receive `true`
- API level detected via `react-native-device-info` `getApiLevel()`

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

```ts
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 to `true` (sidebar visible)
- `isMenuOpen`: defaults to `false` (menu closed)
- `isAnyOptionSelected`: defaults to `false`
- `selectedOption`: 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**: `makeAnimation` generator with `stagger` and `timing` -- 30ms duration transitions
- **Backdrop**: `NoisyBlurredBackground` with `balls`, `blur={100}`, `opacity={0.9}` -- the signature Skia noise texture
- **Android Back Button**: `BackHandler` listener closes the menu on hardware back press
- **Visibility**: Opacity animates from 0 to 1; `display` toggles 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**: `display` toggles 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_devices` translation
- Bluetooth notice: `bluetooth_must_be_on` in 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 `adjustsFontSizeToFit` on 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()` then `auth()?.signOut()`)
- **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 `Canvas` with `position: absolute`, `zIndex: -2`, background color from theme
- **AnimatedBall** (internal): Each ball is a `Circle` with:
  - `ImageShader` using either `red-dot.png` or `black-dot.png` texture (loaded via `useImageAsTexture`)
  - `Blur` filter 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

1. **Menu overlay** (`Menu.tsx`): `balls blur={100} opacity={0.9}` -- large, very blurred balls behind menu
2. **Onboarding** (`onboarding.tsx`): `<NoisyBlurredBackground />` -- default settings (no balls prop)
3. **Main index** (`app/index.tsx`): Not directly -- the main screen uses the `Ball` component 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

```tsx
<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 function
- `stagger(offset, ...animations)` -- Runs animations sequentially with an offset delay
- `timing(sharedValue, { to, duration })` -- Linear timing animation
- `wait(ms)` -- Delay within the generator
- `waitUntil(sharedValue, condition)` -- Blocks until a shared value matches
- `useAnimation(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.
