Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions apps/expo/I18N_SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ The PackRat Expo app has been set up with internationalization (i18n) support fo

### 1. Core Infrastructure

- **expo-localization** (v~16.1.7): Detects device locale
- **i18n-js** (v^4.4.3): Manages translations
- **expo-localization** (v~16.1.6): Detects device locale
- **i18next** (v^25.8.18): Core i18n library
- **react-i18next** (v^16.5.6): React bindings (provides `useTranslation` hook)
- **Configuration**: `apps/expo/lib/i18n/index.ts`
- **TypeScript Types**: `apps/expo/lib/i18n/i18next.d.ts` (module augmentation)
- **Translation Hook**: `apps/expo/lib/hooks/useTranslation.ts`

### 2. English Translations
Expand Down Expand Up @@ -62,16 +64,17 @@ Created comprehensive developer resources:
apps/expo/
├── lib/
│ ├── i18n/
│ │ ├── index.ts # i18n configuration
│ │ ├── index.ts # i18next configuration + resources
│ │ ├── i18next.d.ts # TypeScript module augmentation
│ │ ├── types.ts # TranslationKeys convenience type
│ │ ├── locales/
│ │ │ └── en.json # English translations
│ │ ├── README.md # Documentation
│ │ ├── MIGRATION.md # Migration guide
│ │ ├── EXAMPLES.tsx # Code examples
│ │ ├── types.ts # TypeScript types
│ │ └── extract-strings.js # Helper script
│ └── hooks/
│ └── useTranslation.ts # Translation hook
│ └── useTranslation.ts # Re-exports useTranslation from react-i18next
└── package.json # Updated with dependencies
```

Expand Down Expand Up @@ -137,7 +140,11 @@ When ready to add other languages:
4. Import in `lib/i18n/index.ts`:
```typescript
import es from './locales/es.json';
i18n.translations = { en, es };

export const resources = {
en: { translation: en },
es: { translation: es },
} as const;
```

The app automatically uses the device language if available, falling back to English.
Expand All @@ -163,7 +170,9 @@ The infrastructure is complete. Developers can now gradually migrate remaining c
## Resources

- [Expo Localization Docs](https://docs.expo.dev/versions/latest/sdk/localization/)
- [i18n-js Documentation](https://github.com/fnando/i18n)
- [i18next Documentation](https://www.i18next.com/)
- [react-i18next Documentation](https://react.i18next.com/)
- [i18next TypeScript Guide](https://www.i18next.com/overview/typescript)
- Local docs: `apps/expo/lib/i18n/README.md`
- Migration guide: `apps/expo/lib/i18n/MIGRATION.md`
- Code examples: `apps/expo/lib/i18n/EXAMPLES.tsx`
Expand Down
5 changes: 3 additions & 2 deletions apps/expo/app/(app)/shopping-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Icon } from '@roninoss/icons';
import { cn } from 'expo-app/lib/cn';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import type { TranslationKeys } from 'expo-app/lib/i18n/types';
import { useState } from 'react';
import { Pressable, ScrollView, View } from 'react-native';

Expand Down Expand Up @@ -90,7 +91,7 @@ function PriorityBadge({ priority }: { priority: string }) {
}
};

const getPriorityKey = () => {
const getPriorityKey = (): TranslationKeys => {
switch (priority) {
case 'High':
return 'shopping.high';
Expand All @@ -99,7 +100,7 @@ function PriorityBadge({ priority }: { priority: string }) {
case 'Low':
return 'shopping.low';
default:
return priority;
return 'shopping.low';
}
};

Expand Down
3 changes: 2 additions & 1 deletion apps/expo/app/auth/(create-account)/credentials.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Icon } from '@roninoss/icons';
import { useForm } from '@tanstack/react-form';
import { useAuthActions } from 'expo-app/features/auth/hooks/useAuthActions';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import type { TranslationKeys } from 'expo-app/lib/i18n/types';
import { router, useLocalSearchParams } from 'expo-router';
import * as React from 'react';
import { Alert, Image, Platform, View } from 'react-native';
Expand Down Expand Up @@ -77,7 +78,7 @@ const getPasswordStrength = (password: string) => {
strength++;
}

let labelKey = 'auth.veryWeak';
let labelKey: TranslationKeys = 'auth.veryWeak';
let color = 'bg-red-500';

if (strength === 1) {
Expand Down
3 changes: 2 additions & 1 deletion apps/expo/app/auth/(login)/reset-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Icon } from '@roninoss/icons';
import { useForm } from '@tanstack/react-form';
import { useAuthActions } from 'expo-app/features/auth/hooks/useAuthActions';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import type { TranslationKeys } from 'expo-app/lib/i18n/types';
import { router, Stack, useLocalSearchParams } from 'expo-router';
import * as React from 'react';
import { Alert, Image, Platform, View } from 'react-native';
Expand Down Expand Up @@ -76,7 +77,7 @@ const getPasswordStrength = (password: string) => {
strength++;
}

let labelKey = 'auth.veryWeak';
let labelKey: TranslationKeys = 'auth.veryWeak';
let color = 'bg-red-500';

if (strength === 1) {
Expand Down
4 changes: 3 additions & 1 deletion apps/expo/features/ai/lib/reportReasons.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { TranslationKeys } from 'expo-app/lib/i18n/types';

export const reportReasons = [
'inappropriate_content',
'harmful_advice',
Expand All @@ -11,7 +13,7 @@ export type ReportReason = (typeof reportReasons)[number];

// Translation keys for report reasons
// These map to ai.reportReasons.* in the i18n translations
export const reportReasonTranslationKeys: Record<ReportReason, string> = {
export const reportReasonTranslationKeys: Record<ReportReason, TranslationKeys> = {
inappropriate_content: 'ai.reportReasons.inappropriateContent',
harmful_advice: 'ai.reportReasons.harmfulAdvice',
inaccurate_information: 'ai.reportReasons.inaccurateInformation',
Expand Down
2 changes: 1 addition & 1 deletion apps/expo/features/ai/screens/ReportedContentScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export default function ReportedContentScreen() {
{t(
reportReasonTranslationKeys[
item.reason as keyof typeof reportReasonTranslationKeys
] || item.reason,
],
)}
</Text>
</View>
Expand Down
2 changes: 1 addition & 1 deletion apps/expo/features/trips/screens/TripDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { assertDefined } from 'expo-app/utils/typeAssertions';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useRef } from 'react';
import { ScrollView, View } from 'react-native';
import { ScrollView, Share, View } from 'react-native';
import MapView, { Marker, PROVIDER_GOOGLE } from 'react-native-maps';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useDetailedPacks } from '../../packs/hooks/useDetailedPacks';
Expand Down
55 changes: 25 additions & 30 deletions apps/expo/lib/hooks/useTranslation.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,29 @@
import * as Localization from 'expo-localization';
import type { TranslateOptions } from 'i18n-js';
import { useEffect, useState } from 'react';
import i18n from '../i18n';

/**
* Custom hook for accessing translations
* Provides the translate function and current locale
* Re-renders component when locale changes
* Thin wrapper around react-i18next's `useTranslation` hook.
*
* Because the i18next instance is initialised with `initReactI18next` in
* `lib/i18n/index.ts`, and the `CustomTypeOptions` augmentation in
* `lib/i18n/i18next.d.ts` wires `en.json` into i18next's type system,
* the `t` function returned by this hook is already fully type-safe:
* TypeScript will error on any unknown or misspelled translation key.
*
* A thin wrapper is kept here (rather than re-exporting react-i18next
* directly) to preserve a single import path for consumers and to allow
* app-specific behaviour (e.g. locale-change side-effects) to be added
* without touching callsites.
*
* Usage:
* ```tsx
* import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
*
* function MyComponent() {
* const { t } = useTranslation();
* return <Text>{t('common.welcome')}</Text>;
* }
* ```
*/
export function useTranslation() {
const [locale, setLocale] = useState(i18n.locale);

useEffect(() => {
// Listen for locale changes (if implemented)
const currentLocale = Localization.getLocales()[0]?.languageCode ?? 'en';
if (currentLocale !== locale) {
i18n.locale = currentLocale;
setLocale(currentLocale);
}
}, [locale]);
import { useTranslation as useI18nextTranslation } from 'react-i18next';

/**
* Translate function
* @param key - Translation key in dot notation (e.g., 'common.welcome')
* @param options - Optional interpolation values
* @returns Translated string
*/
const t = (key: string, options?: TranslateOptions) => {
return i18n.t(key, options);
};

return { t, locale };
export function useTranslation() {
return useI18nextTranslation();
}
24 changes: 17 additions & 7 deletions apps/expo/lib/i18n/EXAMPLES.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function BasicExample() {
/**
* Example 2: Translation with interpolation (variables)
*/
export function InterpolationExample({ userName }: { userName: string }) {
export function InterpolationExample() {
const { t } = useTranslation();

return (
Expand Down Expand Up @@ -55,24 +55,33 @@ export function PropsExample() {
* Example 4: Using translations outside components
* (e.g., in utility functions, validation, etc.)
*/
export function validateForm(formData: any) {
export function validateForm(formData: Record<string, unknown>) {
if (!formData.email) {
// Using t() directly without the hook
// Use the configured t() from expo-app/lib/i18n when outside a React component
return { error: t('errors.somethingWentWrong') };
}
return { success: true };
}

/**
* Example 5: Default fallback values
* Example 5: Type safety for translation keys
*
* With the `CustomTypeOptions` module augmentation in `i18next.d.ts`, the
* `t()` function only accepts keys that exist in `en.json`. TypeScript will
* report a compile-time error for any unknown or misspelled key:
* "Argument of type '"someNonExistentKey"' is not assignable to parameter
* of type 'ParseKeys<Ns>'"
*
* This ensures missing translations are caught before they reach production.
* @see https://www.i18next.com/overview/typescript
*/
export function FallbackExample() {
const { t } = useTranslation();

return (
<View>
{/* If translation key doesn't exist, shows the key itself */}
<Text>{t('someNonExistentKey')}</Text>
{/* TypeScript will error if the key does not exist in en.json */}
{/* t('someNonExistentKey') ← compile-time error */}

{/* Best practice: Always add keys to en.json first */}
<Text>{t('common.welcome')}</Text>
Expand Down Expand Up @@ -123,7 +132,8 @@ export function ListExample() {
* 2. Use in component: const { t } = useTranslation();
* 3. Translate text: t('section.key')
* 4. With variables: t('key', { variable: value })
* 5. Outside components: import { t } from 'expo-app/lib/i18n';
* 5. Outside components: import { t } from 'expo-app/lib/i18n'; t('key')
*
* Translation file location: apps/expo/lib/i18n/locales/en.json
* TypeScript types: apps/expo/lib/i18n/i18next.d.ts (module augmentation)
*/
50 changes: 40 additions & 10 deletions apps/expo/lib/i18n/README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
# Internationalization (i18n) Setup

This app uses `expo-localization` and `i18n-js` to provide localization support following Expo's best practices: https://docs.expo.dev/versions/latest/sdk/localization/
This app uses `expo-localization`, `i18next`, and `react-i18next` to provide localization support with full compile-time TypeScript type safety.

TypeScript types are set up following the official i18next documentation:
https://www.i18next.com/overview/typescript

## Structure

```
apps/expo/lib/
├── i18n/
│ ├── index.ts # i18n configuration
│ ├── index.ts # i18next configuration + `resources as const`
│ ├── i18next.d.ts # TypeScript module augmentation (CustomTypeOptions)
│ ├── types.ts # Re-exports TranslationKeys convenience type
│ └── locales/
│ └── en.json # English translations
└── hooks/
└── useTranslation.ts # Translation hook
└── useTranslation.ts # Re-exports useTranslation from react-i18next
```

## How Type Safety Works

The `i18next.d.ts` file augments the `i18next` module with `CustomTypeOptions`:

```ts
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: typeof defaultNS;
resources: (typeof resources)['en'];
}
}
```

This wires the exact shape of `en.json` into i18next's type system. The
`t()` function — from `useTranslation()` or `i18next.t()` directly — will
**only accept keys that exist in `en.json`** and will report a compile-time
error for any unknown or misspelled key. No manual maintenance of a key list
is required.

## Usage

### Basic Translation
Expand Down Expand Up @@ -48,14 +72,16 @@ const { t } = useTranslation();

### Direct Translation (without hook)

For use outside of React components:
For use outside of React components (utility functions, validation helpers, etc.):

```tsx
import { t } from 'expo-app/lib/i18n';

const message = t('errors.somethingWentWrong');
```

This uses the configured i18next instance exported from `lib/i18n/index.ts`, which guarantees the instance is always initialised before `t` is called.

## Translation Keys

All translation keys are organized into logical sections in `lib/i18n/locales/en.json`:
Expand Down Expand Up @@ -83,6 +109,9 @@ All translation keys are organized into logical sections in `lib/i18n/locales/en
1. Add the English text to `lib/i18n/locales/en.json` in the appropriate section
2. Use the translation key in your component with `t('section.key')`

The TypeScript types update automatically — no changes to `types.ts` or any
declaration file are needed.

Example:
```json
// In en.json
Expand All @@ -107,14 +136,14 @@ When ready to add support for other languages:

1. Create a new JSON file in `lib/i18n/locales/` (e.g., `es.json` for Spanish)
2. Copy the structure from `en.json` and translate the values
3. Import the new locale in `lib/i18n/index.ts`:
3. Import the new locale in `lib/i18n/index.ts` and add it to `resources`:
```typescript
import es from './locales/es.json';
i18n.translations = {
en,
es,
};

export const resources = {
en: { translation: en },
es: { translation: es },
} as const;
```

The app will automatically use the device's language if available, falling back to English otherwise.
Expand Down Expand Up @@ -161,3 +190,4 @@ Several files have been updated to demonstrate proper i18n usage:
- `features/ai/components/ErrorState.tsx` - AI error state

Review these files to see patterns for implementing translations in your own components.

21 changes: 21 additions & 0 deletions apps/expo/lib/i18n/i18next.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* i18next TypeScript module augmentation.
*
* By declaring `CustomTypeOptions` here we plug the shape of our translation
* resources directly into i18next's type system. After this, the `t()`
* function — whether called from `i18next.t()`, `useTranslation().t`, or the
* `Trans` component — will only accept keys that exist in `en.json` and will
* report a compile-time error for any unknown or misspelled key.
*
* @see https://www.i18next.com/overview/typescript
*/

import 'i18next';
import type { defaultNS, resources } from './index';

declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: typeof defaultNS;
resources: (typeof resources)['en'];
}
}
Loading
Loading