WikifitaGitHub live67e8de5
outro · camdom/camdom-i18n-and-menu

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

Baixar raw

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

CodeLanguageNotes
enEnglishFallback language
en_DEEnglish in GermanyUses German translations
deGermanPrimary non-English locale
de_DEGerman in GermanySame 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 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:

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

CategoryKeysPurpose
Onboardingonboarding_step_1 through onboarding_step_6Step-by-step user introduction
UI Statesui_state_swipe, ui_state_scanning, ui_state_connecting, ui_state_syncing, ui_state_synced, ui_state_alarmBall text per connection state
UI Buttonsui_state_button_disconnect, ui_state_button_alarmHold-to-action button labels
Menumenu_option_1 through menu_option_4Lateral menu navigation items
Authsign_up_token, create_account, acess_account, enter, username, email, password, etc.Account flow
Abouthere_for_ease, pleasure_and_joy, here_for_inclusivity, and_respect, etc.Brand messaging
Buyget_your_billy, boy_now, buy_here, here_to_protect_you, in_the_real_worldCommercial CTA
Errorserror, email_taken, email_invalid, wrong_email_or_password, passwords_do_not_matchValidation messages
Permissionsask_permission, ask_permission_text, bluetooth_must_be_onPermission 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 ConstantHash ValueTypePurpose
isOnboardingCompleteb0662c75893623082afb13018d22a4bd5b731fb0BooleanWhether the user has completed onboarding
isShowingLateralMenu9f662c7589362308221513018d22a4bd53731fe5BooleanWhether the lateral menu (sidebar) is visible
isMenuOpen9f662c758h162308221513018d22a4bd53731fe5BooleanWhether the full-screen menu overlay is open
isAnyOptionSelected9f662c7589362308221513019f22a4bd53731fe5BooleanWhether a menu page is currently selected
selectedOption9f662c7512362308221513019f22a4bd53731fe5StringWhich page is selected: "howto", "about", "buycondom", "options", or "none"
rssiSense9f662c75123623082271513029f22a4bd53731fe2BooleanControls RSSI-based proximity sensitivity for BLE distance detection
fireAlarmOnDisconnect9f662c75121627082231513629f22a4bd53731fe2BooleanWhether to trigger the alarm when a device disconnects without consent
exitedWithConsent9f662c7512n6l7081231513629f22a4bd53731fe2BooleanTracks 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:

} 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

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:

selectedOptionComponent
"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

PropTypeDefaultPurpose
ballsbooleanundefinedShow/hide the animated balls
sizenumber120Base radius of the balls
displacebooleanundefinedOffset balls from center
darkenbooleanundefined(declared but not used in rendering)
opacitynumber1Opacity of the ball group
blurnumber40Blur 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

PropTypeDefaultPurpose
marginBottomnumberundefinedBottom margin
heightnumber1Line 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 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.