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
+50 -43
View File
@@ -4,7 +4,7 @@
import Constants from "expo-constants";
import * as Linking from "expo-linking";
import React, { useState } from "react";
import React from "react";
import {
Alert,
Image,
@@ -15,23 +15,33 @@ import {
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import { getLanguage, getSupportedLanguages, setLanguage } from "../i18n";
import { useLocale, getSupportedLanguages, t } from "../i18n";
import api from "../services/api";
const DEFAULT_SERVER_URL = "https://app.docuelevate.org";
export default function ProfileScreen() {
const { user, signOut, baseUrl } = useAuth();
const [selectedLanguage, setSelectedLanguage] = useState(getLanguage());
const { lang, setLang } = useLocale();
const effectiveBaseUrl = baseUrl || DEFAULT_SERVER_URL;
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
const languages = getSupportedLanguages();
async function handleLanguageSelect(code: string) {
await setLang(code);
// Fire-and-forget: sync the choice to the server so it persists across
// platforms (desktop web will reflect this preference too).
api.setServerLanguage(code).catch(() => {
// Network errors are non-critical – the local change is already applied.
});
}
function handleSignOut() {
Alert.alert("Sign out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
Alert.alert(t("profile.sign_out_title"), t("profile.sign_out_msg"), [
{ text: t("common.cancel"), style: "cancel" },
{
text: "Sign out",
text: t("profile.sign_out"),
style: "destructive",
onPress: signOut,
},
@@ -40,16 +50,16 @@ export default function ProfileScreen() {
function handleDeleteAccount() {
Alert.alert(
"Delete Account",
"This will permanently delete your account and all associated data. This action cannot be undone.",
t("profile.delete_account_title"),
t("profile.delete_account_msg"),
[
{ text: "Cancel", style: "cancel" },
{ text: t("common.cancel"), style: "cancel" },
{
text: "Delete Account",
text: t("profile.delete_account"),
style: "destructive",
onPress: () => {
Linking.openURL(`${effectiveBaseUrl}/account/delete`).catch(() => {
Alert.alert("Error", "Could not open the account deletion page. Please try again.");
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.delete_account") }));
});
},
},
@@ -59,26 +69,26 @@ export default function ProfileScreen() {
function openPrivacyPolicy() {
Linking.openURL(`${effectiveBaseUrl}/privacy`).catch(() => {
Alert.alert("Error", "Could not open the privacy policy. Please try again.");
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.privacy_policy") }));
});
}
function openTermsOfService() {
Linking.openURL(`${effectiveBaseUrl}/terms`).catch(() => {
Alert.alert("Error", "Could not open the terms of service. Please try again.");
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.terms_of_service") }));
});
}
function openImprint() {
Linking.openURL(`${effectiveBaseUrl}/imprint`).catch(() => {
Alert.alert("Error", "Could not open the imprint page. Please try again.");
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.imprint") }));
});
}
if (!user) {
return (
<View style={styles.center}>
<Text style={styles.emptyText}>Not signed in</Text>
<Text style={styles.emptyText}>{t("profile.not_signed_in")}</Text>
</View>
);
}
@@ -102,20 +112,20 @@ export default function ProfileScreen() {
)}
<Text style={styles.displayName}>{user.display_name ?? user.owner_id}</Text>
{user.email && <Text style={styles.email}>{user.email}</Text>}
{user.is_admin && <Text style={styles.adminBadge}>Admin</Text>}
{user.is_admin && <Text style={styles.adminBadge}>{t("profile.admin")}</Text>}
</View>
{/* Server info */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Connection</Text>
<Text style={styles.sectionTitle}>{t("profile.connection")}</Text>
<View style={styles.row}>
<Text style={styles.rowLabel}>Server</Text>
<Text style={styles.rowLabel}>{t("profile.server")}</Text>
<Text style={styles.rowValue} numberOfLines={1}>
{effectiveBaseUrl}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.rowLabel}>User ID</Text>
<Text style={styles.rowLabel}>{t("profile.user_id")}</Text>
<Text style={styles.rowValue} numberOfLines={1}>
{user.owner_id}
</Text>
@@ -124,31 +134,28 @@ export default function ProfileScreen() {
{/* Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Settings</Text>
<Text style={styles.settingLabel}>Language</Text>
<Text style={styles.sectionTitle}>{t("profile.settings")}</Text>
<Text style={styles.settingLabel}>{t("profile.language")}</Text>
<View style={styles.languageGrid}>
{languages.map((lang) => (
{languages.map((l) => (
<Pressable
key={lang.code}
key={l.code}
style={[
styles.languageChip,
selectedLanguage === lang.code && styles.languageChipActive,
lang === l.code && styles.languageChipActive,
]}
onPress={() => {
setLanguage(lang.code);
setSelectedLanguage(lang.code);
}}
onPress={() => handleLanguageSelect(l.code)}
accessibilityRole="button"
accessibilityLabel={`Set language to ${lang.label}`}
accessibilityState={{ selected: selectedLanguage === lang.code }}
accessibilityLabel={`Set language to ${l.label}`}
accessibilityState={{ selected: lang === l.code }}
>
<Text
style={[
styles.languageChipText,
selectedLanguage === lang.code && styles.languageChipTextActive,
lang === l.code && styles.languageChipTextActive,
]}
>
{lang.label}
{l.label}
</Text>
</Pressable>
))}
@@ -157,32 +164,32 @@ export default function ProfileScreen() {
{/* Legal & Privacy */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Legal</Text>
<Text style={styles.sectionTitle}>{t("profile.legal")}</Text>
<Pressable
style={styles.linkRow}
onPress={openPrivacyPolicy}
accessibilityRole="link"
accessibilityLabel="Privacy Policy"
accessibilityLabel={t("profile.privacy_policy")}
>
<Text style={styles.linkText}>Privacy Policy</Text>
<Text style={styles.linkText}>{t("profile.privacy_policy")}</Text>
<Text style={styles.linkChevron}>›</Text>
</Pressable>
<Pressable
style={styles.linkRow}
onPress={openTermsOfService}
accessibilityRole="link"
accessibilityLabel="Terms of Service"
accessibilityLabel={t("profile.terms_of_service")}
>
<Text style={styles.linkText}>Terms of Service</Text>
<Text style={styles.linkText}>{t("profile.terms_of_service")}</Text>
<Text style={styles.linkChevron}>›</Text>
</Pressable>
<Pressable
style={[styles.linkRow, styles.linkRowLast]}
onPress={openImprint}
accessibilityRole="link"
accessibilityLabel="Imprint"
accessibilityLabel={t("profile.imprint")}
>
<Text style={styles.linkText}>Imprint</Text>
<Text style={styles.linkText}>{t("profile.imprint")}</Text>
<Text style={styles.linkChevron}>›</Text>
</Pressable>
</View>
@@ -193,9 +200,9 @@ export default function ProfileScreen() {
style={styles.signOutButton}
onPress={handleSignOut}
accessibilityRole="button"
accessibilityLabel="Sign out"
accessibilityLabel={t("profile.sign_out")}
>
<Text style={styles.signOutText}>Sign out</Text>
<Text style={styles.signOutText}>{t("profile.sign_out")}</Text>
</Pressable>
</View>
@@ -205,9 +212,9 @@ export default function ProfileScreen() {
style={styles.deleteAccountButton}
onPress={handleDeleteAccount}
accessibilityRole="button"
accessibilityLabel="Delete account"
accessibilityLabel={t("profile.delete_account")}
>
<Text style={styles.deleteAccountText}>Delete Account</Text>
<Text style={styles.deleteAccountText}>{t("profile.delete_account")}</Text>
</Pressable>
</View>