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:
copilot-swe-agent[bot]
2026-03-20 09:08:08 +00:00
parent 0e6a4c5084
commit 3b5ca04ebc
18 changed files with 392 additions and 184 deletions
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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
View File
@@ -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);
}
+3 -1
View File
@@ -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",