fix(mobile): wire i18n reactivity, translate all screens, sync language with server
- Add LocaleProvider + useLocale() hook with AsyncStorage persistence to mobile i18n - Replace all hardcoded English strings in every screen with t() calls - Add missing profile.settings/language keys to all 5 translation files (en/de/es/fr/it) - Wrap app root in LocaleProvider; apply server preferred_language on login in AuthGuard - Tab labels and header titles now re-render on language switch - ProfileScreen: use useLocale() context, sync language to server via POST /api/i18n/language - Backend: add preferred_language field to GET /api/mobile/whoami response - Mobile API: add preferred_language to WhoAmIResponse type + setServerLanguage() method - Tests: add test_whoami_returns_preferred_language and test_whoami_no_profile_preferred_language_is_null - Docs: update MobileApp.md with language sync priority and whoami response format Language priority: server preference > AsyncStorage > device locale > English fallback Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -99,7 +99,9 @@
|
||||
"delete_account_title": "Konto löschen",
|
||||
"delete_account_msg": "Dadurch werden Ihr Konto und alle zugehörigen Daten dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"could_not_open": "Konnte {page} nicht öffnen. Bitte versuchen Sie es erneut.",
|
||||
"admin": "Admin"
|
||||
"admin": "Admin",
|
||||
"settings": "Einstellungen",
|
||||
"language": "Sprache"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Datenschutz",
|
||||
|
||||
@@ -99,7 +99,9 @@
|
||||
"delete_account_title": "Delete Account",
|
||||
"delete_account_msg": "This will permanently delete your account and all associated data. This action cannot be undone.",
|
||||
"could_not_open": "Could not open the {page}. Please try again.",
|
||||
"admin": "Admin"
|
||||
"admin": "Admin",
|
||||
"settings": "Settings",
|
||||
"language": "Language"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Privacy Policy",
|
||||
|
||||
@@ -99,7 +99,9 @@
|
||||
"delete_account_title": "Eliminar Cuenta",
|
||||
"delete_account_msg": "Esto eliminará permanentemente tu cuenta y todos los datos asociados. Esta acción no se puede deshacer.",
|
||||
"could_not_open": "No se pudo abrir {page}. Inténtalo de nuevo.",
|
||||
"admin": "Admin"
|
||||
"admin": "Admin",
|
||||
"settings": "Configuración",
|
||||
"language": "Idioma"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Privacidad",
|
||||
|
||||
@@ -99,7 +99,9 @@
|
||||
"delete_account_title": "Supprimer le Compte",
|
||||
"delete_account_msg": "Cela supprimera définitivement votre compte et toutes les données associées. Cette action est irréversible.",
|
||||
"could_not_open": "Impossible d'ouvrir {page}. Veuillez réessayer.",
|
||||
"admin": "Admin"
|
||||
"admin": "Admin",
|
||||
"settings": "Paramètres",
|
||||
"language": "Langue"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Confidentialité",
|
||||
|
||||
+109
-2
@@ -6,9 +6,23 @@
|
||||
* locales.
|
||||
*
|
||||
* Supported languages: English, German, Spanish, French, Italian.
|
||||
*
|
||||
* ## React integration
|
||||
*
|
||||
* Wrap the app root in `<LocaleProvider>` and call `useLocale()` in any
|
||||
* component that renders translated strings. `useLocale()` returns the
|
||||
* active language code and a `setLang` setter that:
|
||||
* 1. Updates the in-memory `currentLanguage` variable (so `t()` picks it up)
|
||||
* 2. Triggers a React re-render of every consumer
|
||||
* 3. Persists the choice to AsyncStorage (survives app restarts)
|
||||
*
|
||||
* Language priority on startup:
|
||||
* server preference (from /api/mobile/whoami) > AsyncStorage > device locale > "en"
|
||||
*/
|
||||
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { getLocales } from "expo-localization";
|
||||
import React from "react";
|
||||
|
||||
import de from "./de.json";
|
||||
import en from "./en.json";
|
||||
@@ -28,6 +42,8 @@ const translations: Record<string, TranslationMap> = { en, de, es, fr, it };
|
||||
// Locale detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LANG_STORAGE_KEY = "@docuelevate:language";
|
||||
|
||||
/** Resolve the best-matching language code from the device locale list. */
|
||||
function detectLanguage(): string {
|
||||
try {
|
||||
@@ -46,7 +62,7 @@ function detectLanguage(): string {
|
||||
let currentLanguage: string = detectLanguage();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// Plain-function public API (framework-agnostic)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -82,13 +98,22 @@ export function getLanguage(): string {
|
||||
return currentLanguage;
|
||||
}
|
||||
|
||||
/** Override the language manually (e.g. from user settings). */
|
||||
/**
|
||||
* Update the active language in memory.
|
||||
* Prefer `useLocale().setLang` inside React components – it also persists
|
||||
* the choice and triggers re-renders.
|
||||
*/
|
||||
export function setLanguage(lang: string): void {
|
||||
if (translations[lang]) {
|
||||
currentLanguage = lang;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return true if the given language code is supported by the mobile app. */
|
||||
export function isLanguageSupported(lang: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(translations, lang);
|
||||
}
|
||||
|
||||
/** Return the list of supported language codes. */
|
||||
export function getSupportedLanguages(): { code: string; label: string }[] {
|
||||
return [
|
||||
@@ -99,3 +124,85 @@ export function getSupportedLanguages(): { code: string; label: string }[] {
|
||||
{ code: "it", label: "Italiano" },
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// React integration – context + provider + hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LocaleContextValue {
|
||||
/** The active language code, e.g. "en" or "de". */
|
||||
lang: string;
|
||||
/**
|
||||
* Switch to a new language. Persists the choice to AsyncStorage and
|
||||
* triggers a re-render of every `useLocale()` consumer.
|
||||
*/
|
||||
setLang: (code: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const LocaleContext = React.createContext<LocaleContextValue>({
|
||||
lang: currentLanguage,
|
||||
// Default setter used outside of a provider – updates in-memory only.
|
||||
setLang: async (code: string) => {
|
||||
setLanguage(code);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Wrap the app root in `LocaleProvider` to enable reactive language switching.
|
||||
*
|
||||
* On mount it reads the persisted language from AsyncStorage so the user's
|
||||
* choice survives app restarts. The server-preferred language is applied
|
||||
* externally (see `AuthGuard` in `app/_layout.tsx`) after the profile is
|
||||
* fetched from `/api/mobile/whoami`.
|
||||
*/
|
||||
export function LocaleProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
const [lang, setLangState] = React.useState(currentLanguage);
|
||||
|
||||
// Restore the persisted language preference once on app start.
|
||||
React.useEffect(() => {
|
||||
AsyncStorage.getItem(LANG_STORAGE_KEY)
|
||||
.then((saved) => {
|
||||
if (saved && isLanguageSupported(saved)) {
|
||||
setLanguage(saved);
|
||||
setLangState(saved);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore read errors – fall back to device-detected language.
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setLang = React.useCallback(async (code: string): Promise<void> => {
|
||||
if (!isLanguageSupported(code)) return;
|
||||
setLanguage(code);
|
||||
setLangState(code);
|
||||
try {
|
||||
await AsyncStorage.setItem(LANG_STORAGE_KEY, code);
|
||||
} catch {
|
||||
// Ignore write errors – the in-memory change is still applied.
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // setLangState is a React state setter – its identity is guaranteed stable
|
||||
|
||||
const value = React.useMemo(() => ({ lang, setLang }), [lang, setLang]);
|
||||
|
||||
return React.createElement(LocaleContext.Provider, { value }, children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that subscribes to language changes.
|
||||
*
|
||||
* Any component calling `useLocale()` re-renders automatically when the
|
||||
* language changes. Call `t()` freely inside the component body – the
|
||||
* re-render will pick up the new translations.
|
||||
*
|
||||
* ```tsx
|
||||
* function MyScreen() {
|
||||
* const { lang, setLang } = useLocale(); // subscribes to changes
|
||||
* return <Text>{t("common.loading")}</Text>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useLocale(): LocaleContextValue {
|
||||
return React.useContext(LocaleContext);
|
||||
}
|
||||
|
||||
@@ -99,7 +99,9 @@
|
||||
"delete_account_title": "Elimina Account",
|
||||
"delete_account_msg": "Questo eliminerà permanentemente il tuo account e tutti i dati associati. Questa azione non può essere annullata.",
|
||||
"could_not_open": "Impossibile aprire {page}. Riprova.",
|
||||
"admin": "Admin"
|
||||
"admin": "Admin",
|
||||
"settings": "Impostazioni",
|
||||
"language": "Lingua"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Privacy",
|
||||
|
||||
Reference in New Issue
Block a user