diff --git a/app/api/mobile.py b/app/api/mobile.py
index c9163f00..5305c5d2 100644
--- a/app/api/mobile.py
+++ b/app/api/mobile.py
@@ -120,6 +120,7 @@ class WhoAmIResponse(BaseModel):
email: str | None
avatar_url: str | None
is_admin: bool
+ preferred_language: str | None
# ---------------------------------------------------------------------------
@@ -357,4 +358,5 @@ async def whoami(
"email": email,
"avatar_url": avatar_url,
"is_admin": is_admin,
+ "preferred_language": profile.preferred_language if profile else None,
}
diff --git a/docs/MobileApp.md b/docs/MobileApp.md
index 1a577fd4..07ff077b 100644
--- a/docs/MobileApp.md
+++ b/docs/MobileApp.md
@@ -285,10 +285,19 @@ The mobile app supports five languages with automatic device-locale detection:
### How it works
-1. On app launch, `expo-localization` detects the device's preferred language
-2. If the device language matches a supported locale, that language is used automatically
-3. If no match is found, English is used as the fallback
-4. Users can manually switch languages from the **Profile** tab → **Settings** → **Language**
+Language priority (highest to lowest):
+
+1. **Server preference** — `preferred_language` returned by `GET /api/mobile/whoami` on login or app resume. Allows a language set on the desktop web interface to propagate to mobile automatically.
+2. **AsyncStorage** — the last language explicitly selected on the device, used as an offline fallback when the server is unreachable.
+3. **Device locale** — detected via `expo-localization` on first launch.
+4. **English** — final fallback when none of the above match a supported locale.
+
+When a user selects a language on mobile the choice is:
+- Applied immediately to all screens (via `LocaleContext`)
+- Persisted locally to AsyncStorage
+- Synced to the server via `POST /api/i18n/language` (fire-and-forget), so the next desktop login reflects the same preference.
+
+> **Note**: If the server's preferred language is not supported by the mobile app (e.g. a locale added to the web frontend but not yet translated for mobile), the mobile app falls back to the next priority in the list above.
### Adding a new language
@@ -316,7 +325,8 @@ The backend exposes a dedicated `/api/mobile/` namespace:
| `POST` | `/api/mobile/register-device` | Bearer | Register Expo push token |
| `GET` | `/api/mobile/devices` | Bearer | List registered devices |
| `DELETE` | `/api/mobile/devices/{id}` | Bearer | Deactivate a device |
-| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile |
+| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile (includes `preferred_language`) |
+| `POST` | `/api/i18n/language` | Bearer | Sync language preference to server |
All other API endpoints (file upload, file listing, etc.) work with Bearer token authentication.
@@ -360,7 +370,7 @@ Re-registering the same token is safe (idempotent).
### GET /api/mobile/whoami
-Returns the current user's profile.
+Returns the current user's profile, including the server-stored language preference.
**Response (200):**
```json
@@ -369,10 +379,15 @@ Returns the current user's profile.
"display_name": "John Doe",
"email": "john@example.com",
"avatar_url": "https://www.gravatar.com/avatar/...",
- "is_admin": false
+ "is_admin": false,
+ "preferred_language": "de"
}
```
+`preferred_language` is `null` when no preference has been saved. The mobile
+app applies this value on login / app resume, falling back to AsyncStorage and
+then the device locale when it is `null` or unsupported.
+
## Configuration
No server-side configuration is required to enable the mobile app. The Expo push notification routing does not need FCM or APNs credentials on the server.
diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx
index eb2c93dd..7068f4d2 100644
--- a/mobile/app/(tabs)/_layout.tsx
+++ b/mobile/app/(tabs)/_layout.tsx
@@ -11,10 +11,15 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { usePushNotifications } from "../../src/hooks/usePushNotifications";
import { useAuth } from "../../src/context/AuthContext";
+import { useLocale, t } from "../../src/i18n";
export default function TabLayout() {
const { isAuthenticated } = useAuth();
usePushNotifications(isAuthenticated);
+ // Subscribe to language changes so tab labels re-render when the language
+ // is switched. The `lang` variable is intentionally unused – its only
+ // purpose is to make this component a consumer of LocaleContext.
+ useLocale();
return (
(
),
@@ -48,23 +53,23 @@ export default function TabLayout() {
(
),
- headerTitle: "My Documents",
+ headerTitle: t("files.title"),
}}
/>
(
),
- headerTitle: "Profile",
+ headerTitle: t("tabs.profile"),
}}
/>
{/* File detail screen – hidden from tab bar, accessed via navigation */}
@@ -72,8 +77,8 @@ export default function TabLayout() {
name="file-detail"
options={{
href: null,
- title: "File Details",
- headerTitle: "File Details",
+ title: t("file_detail.title"),
+ headerTitle: t("file_detail.title"),
}}
/>
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index d2396c07..0feda68f 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -23,6 +23,7 @@ import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { AuthProvider, useAuth } from "../src/context/AuthContext";
import { ShareProvider, useShare } from "../src/context/ShareContext";
+import { LocaleProvider, useLocale, isLanguageSupported } from "../src/i18n";
import { mimeTypeFromFilename } from "../src/utils/mimeTypes";
// ---------------------------------------------------------------------------
@@ -100,11 +101,22 @@ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string; mim
// ---------------------------------------------------------------------------
function AuthGuard() {
- const { isLoading, isAuthenticated } = useAuth();
+ const { isLoading, isAuthenticated, user } = useAuth();
const { addPendingFile } = useShare();
+ const { setLang } = useLocale();
const segments = useSegments();
const router = useRouter();
+ // Apply the server-side language preference whenever the user profile is
+ // loaded (on login or app resume). This syncs the language set on the
+ // desktop/web client to the mobile app. If the server language is not
+ // supported by the mobile app, we leave the current language unchanged.
+ useEffect(() => {
+ if (user?.preferred_language && isLanguageSupported(user.preferred_language)) {
+ void setLang(user.preferred_language);
+ }
+ }, [user?.preferred_language, setLang]);
+
// Listen for files shared from other apps (iOS Share Sheet / Android Intent).
// Both cold-start (app was not running) and warm-start (app in background)
// cases are handled.
@@ -162,11 +174,13 @@ function AuthGuard() {
export default function RootLayout() {
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/mobile/src/i18n/de.json b/mobile/src/i18n/de.json
index 5a107e4c..79589bf5 100644
--- a/mobile/src/i18n/de.json
+++ b/mobile/src/i18n/de.json
@@ -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",
diff --git a/mobile/src/i18n/en.json b/mobile/src/i18n/en.json
index 342f26bd..137503c3 100644
--- a/mobile/src/i18n/en.json
+++ b/mobile/src/i18n/en.json
@@ -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",
diff --git a/mobile/src/i18n/es.json b/mobile/src/i18n/es.json
index 57216302..eb434f34 100644
--- a/mobile/src/i18n/es.json
+++ b/mobile/src/i18n/es.json
@@ -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",
diff --git a/mobile/src/i18n/fr.json b/mobile/src/i18n/fr.json
index 2dce9a8f..3760d4d5 100644
--- a/mobile/src/i18n/fr.json
+++ b/mobile/src/i18n/fr.json
@@ -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é",
diff --git a/mobile/src/i18n/index.ts b/mobile/src/i18n/index.ts
index 6260ebb5..84bc1504 100644
--- a/mobile/src/i18n/index.ts
+++ b/mobile/src/i18n/index.ts
@@ -6,9 +6,23 @@
* locales.
*
* Supported languages: English, German, Spanish, French, Italian.
+ *
+ * ## React integration
+ *
+ * Wrap the app root in `` 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 = { 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;
+}
+
+const LocaleContext = React.createContext({
+ 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 => {
+ 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 {t("common.loading")};
+ * }
+ * ```
+ */
+export function useLocale(): LocaleContextValue {
+ return React.useContext(LocaleContext);
+}
diff --git a/mobile/src/i18n/it.json b/mobile/src/i18n/it.json
index a3211a5c..71fc3699 100644
--- a/mobile/src/i18n/it.json
+++ b/mobile/src/i18n/it.json
@@ -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",
diff --git a/mobile/src/screens/FileDetailScreen.tsx b/mobile/src/screens/FileDetailScreen.tsx
index d0afce5b..8d7cad20 100644
--- a/mobile/src/screens/FileDetailScreen.tsx
+++ b/mobile/src/screens/FileDetailScreen.tsx
@@ -20,6 +20,7 @@ import {
} from "react-native";
import type { FileDetail } from "../services/api";
import api from "../services/api";
+import { useLocale, t } from "../i18n";
// ---------------------------------------------------------------------------
// Helpers
@@ -88,6 +89,8 @@ export default function FileDetailScreen() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(null);
+ // Subscribe to language changes so translated strings re-render.
+ useLocale();
const fileId = parseInt(id ?? "0", 10);
@@ -127,12 +130,12 @@ export default function FileDetailScreen() {
if (error || !detail) {
return (
- {error ?? "File not found"}
+ {error ?? t("file_detail.file_not_found")}
- Retry
+ {t("common.retry")}
router.back()}>
- ← Back
+ {t("common.back")}
);
@@ -152,10 +155,10 @@ export default function FileDetailScreen() {
style={styles.backRow}
onPress={() => router.back()}
accessibilityRole="button"
- accessibilityLabel="Go back"
+ accessibilityLabel={t("file_detail.back")}
>
- Back to Files
+ {t("file_detail.back")}
{/* File info card */}
@@ -178,20 +181,20 @@ export default function FileDetailScreen() {
-
-
-
-
-
-
+
+
+
+
+
+
{/* Processing logs */}
- Processing Log
+ {t("file_detail.processing_log")}
{detail.logs.length === 0 ? (
- No processing logs yet.
+ {t("file_detail.no_logs")}
) : (
detail.logs.map((log, idx) => {
const icon = logStepIcon(log.status);
diff --git a/mobile/src/screens/FilesScreen.tsx b/mobile/src/screens/FilesScreen.tsx
index dc0602d3..d40b1c65 100644
--- a/mobile/src/screens/FilesScreen.tsx
+++ b/mobile/src/screens/FilesScreen.tsx
@@ -17,6 +17,7 @@ import {
} from "react-native";
import type { FileRecord } from "../services/api";
import api from "../services/api";
+import { useLocale, t } from "../i18n";
function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === undefined) return "–";
@@ -58,6 +59,8 @@ export default function FilesScreen() {
const [error, setError] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
const searchTimeoutRef = useRef | null>(null);
+ // Subscribe to language changes so translated strings re-render.
+ useLocale();
const fetchFiles = useCallback(
async (pageNum: number, replace: boolean, search?: string) => {
@@ -150,7 +153,7 @@ export default function FilesScreen() {
{error}
- Retry
+ {t("common.retry")}
);
@@ -163,21 +166,21 @@ export default function FilesScreen() {
{searchQuery.length > 0 && (
@@ -199,12 +202,10 @@ export default function FilesScreen() {
- {searchQuery ? "No documents match your search." : "No documents yet."}
+ {searchQuery ? t("files.search_empty") : t("files.empty_title")}
- {searchQuery
- ? "Try a different search term."
- : "Upload a document from the Upload tab to get started."}
+ {searchQuery ? t("files.search_empty_hint") : t("files.empty_hint")}
}
diff --git a/mobile/src/screens/LoginScreen.tsx b/mobile/src/screens/LoginScreen.tsx
index 31a35784..e92a5596 100644
--- a/mobile/src/screens/LoginScreen.tsx
+++ b/mobile/src/screens/LoginScreen.tsx
@@ -24,6 +24,7 @@ import {
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
+import { useLocale, t } from "../i18n";
export default function LoginScreen() {
const { signIn, signInWithQR } = useAuth();
@@ -31,6 +32,8 @@ export default function LoginScreen() {
const [serverUrl, setServerUrl] = useState("https://app.docuelevate.org");
const [loading, setLoading] = useState(false);
const [qrLoading, setQrLoading] = useState(false);
+ // Subscribe to language changes so translated strings re-render.
+ useLocale();
// Handle incoming deep links for QR login (docuelevate://qr-login?token=...&server=...)
const handleDeepLink = useCallback(
@@ -46,8 +49,8 @@ export default function LoginScreen() {
}
}
} catch (err: unknown) {
- const message = err instanceof Error ? err.message : "QR login failed";
- Alert.alert("QR Login Failed", message);
+ const message = err instanceof Error ? err.message : t("login.qr_login_failed");
+ Alert.alert(t("login.qr_login_failed"), message);
} finally {
setQrLoading(false);
}
@@ -70,11 +73,11 @@ export default function LoginScreen() {
async function handleSignIn() {
const url = serverUrl.trim();
if (!url) {
- Alert.alert("Server URL required", "Please enter the URL of your DocuElevate server.");
+ Alert.alert(t("login.server_url_required"), t("login.server_url_required_msg"));
return;
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
- Alert.alert("Invalid URL", "The server URL must start with http:// or https://");
+ Alert.alert(t("login.invalid_url"), t("login.invalid_url_msg"));
return;
}
@@ -82,8 +85,8 @@ export default function LoginScreen() {
try {
await signIn(url);
} catch (err: unknown) {
- const message = err instanceof Error ? err.message : "Sign-in failed";
- Alert.alert("Sign-in failed", message);
+ const message = err instanceof Error ? err.message : t("login.sign_in_failed");
+ Alert.alert(t("login.sign_in_failed"), message);
} finally {
setLoading(false);
}
@@ -104,12 +107,12 @@ export default function LoginScreen() {
/>
DocuElevate
- Intelligent Document Processing
+ {t("welcome.tagline")}
- Server URL
+ {t("login.server_url")}
{loading ? (
) : (
- Sign in with SSO
+ {t("login.sign_in_sso")}
)}
- or
+ {t("login.or")}
@@ -148,26 +151,24 @@ export default function LoginScreen() {
}}
disabled={loading || qrLoading}
accessibilityRole="button"
- accessibilityLabel="Sign in with QR code"
+ accessibilityLabel={t("login.scan_qr")}
>
{qrLoading ? (
) : (
- 📱 Scan QR Code to Login
+ {t("login.scan_qr")}
)}
-
- Sign in via SSO or scan a QR code from the web app.
-
+ {t("login.hint")}
router.back()}
accessibilityRole="button"
- accessibilityLabel="Back to welcome screen"
+ accessibilityLabel={t("login.back")}
style={styles.backLink}
>
- ← Back
+ {t("login.back")}
{/* Legal links – accessible pre-login for GDPR / Apple compliance */}
@@ -178,10 +179,10 @@ export default function LoginScreen() {
Linking.openURL(`${base.replace(/\/$/, "")}/privacy`);
}}
accessibilityRole="link"
- accessibilityLabel="Privacy Policy"
+ accessibilityLabel={t("legal.privacy_policy")}
style={styles.legalLinkButton}
>
- Privacy Policy
+ {t("legal.privacy_policy")}
·
- Terms
+ {t("legal.terms")}
·
- Imprint
+ {t("legal.imprint")}
diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx
index 6c4bb866..3990e1df 100644
--- a/mobile/src/screens/ProfileScreen.tsx
+++ b/mobile/src/screens/ProfileScreen.tsx
@@ -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 (
- Not signed in
+ {t("profile.not_signed_in")}
);
}
@@ -102,20 +112,20 @@ export default function ProfileScreen() {
)}
{user.display_name ?? user.owner_id}
{user.email && {user.email}}
- {user.is_admin && Admin}
+ {user.is_admin && {t("profile.admin")}}
{/* Server info */}
- Connection
+ {t("profile.connection")}
- Server
+ {t("profile.server")}
{effectiveBaseUrl}
- User ID
+ {t("profile.user_id")}
{user.owner_id}
@@ -124,31 +134,28 @@ export default function ProfileScreen() {
{/* Settings */}
- Settings
- Language
+ {t("profile.settings")}
+ {t("profile.language")}
- {languages.map((lang) => (
+ {languages.map((l) => (
{
- 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 }}
>
- {lang.label}
+ {l.label}
))}
@@ -157,32 +164,32 @@ export default function ProfileScreen() {
{/* Legal & Privacy */}
- Legal
+ {t("profile.legal")}
- Privacy Policy
+ {t("profile.privacy_policy")}
›
- Terms of Service
+ {t("profile.terms_of_service")}
›
- Imprint
+ {t("profile.imprint")}
›
@@ -193,9 +200,9 @@ export default function ProfileScreen() {
style={styles.signOutButton}
onPress={handleSignOut}
accessibilityRole="button"
- accessibilityLabel="Sign out"
+ accessibilityLabel={t("profile.sign_out")}
>
- Sign out
+ {t("profile.sign_out")}
@@ -205,9 +212,9 @@ export default function ProfileScreen() {
style={styles.deleteAccountButton}
onPress={handleDeleteAccount}
accessibilityRole="button"
- accessibilityLabel="Delete account"
+ accessibilityLabel={t("profile.delete_account")}
>
- Delete Account
+ {t("profile.delete_account")}
diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx
index 4a99023f..daa00a01 100644
--- a/mobile/src/screens/UploadScreen.tsx
+++ b/mobile/src/screens/UploadScreen.tsx
@@ -30,6 +30,7 @@ import { useAuth } from "../context/AuthContext";
import { useShare } from "../context/ShareContext";
import { normalizeFileUri } from "../utils/normalizeUri";
import api from "../services/api";
+import { useLocale, t } from "../i18n";
/** Statuses that indicate processing has finished (no further polling needed). */
const TERMINAL_STATUSES = new Set(["completed", "failed", "duplicate"]);
@@ -56,6 +57,8 @@ export default function UploadScreen() {
const { isAuthenticated } = useAuth();
const { pendingFiles, clearPendingFiles } = useShare();
const [uploads, setUploads] = useState([]);
+ // Subscribe to language changes so translated strings re-render.
+ useLocale();
// Keep a ref in sync so the polling interval can read current state without
// capturing a stale closure.
@@ -268,8 +271,8 @@ export default function UploadScreen() {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== "granted") {
Alert.alert(
- "Camera access required",
- "Please grant camera access in Settings to capture documents."
+ t("upload.camera_access_title"),
+ t("upload.camera_access_msg")
);
return;
}
@@ -291,8 +294,8 @@ export default function UploadScreen() {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") {
Alert.alert(
- "Photo library access required",
- "Please grant photo library access in Settings to select images."
+ t("upload.photo_access_title"),
+ t("upload.photo_access_msg")
);
return;
}
@@ -329,14 +332,14 @@ export default function UploadScreen() {
}
}
} catch (err: unknown) {
- Alert.alert("File picker error", err instanceof Error ? err.message : "Could not open file picker");
+ Alert.alert(t("upload.file_picker_error"), err instanceof Error ? err.message : t("upload.file_picker_error_msg"));
}
}
if (!isAuthenticated) {
return (
- Please sign in to upload documents.
+ {t("upload.sign_in_required")}
);
}
@@ -349,30 +352,30 @@ export default function UploadScreen() {
style={[styles.actionButton, styles.cameraButton]}
onPress={handleCamera}
accessibilityRole="button"
- accessibilityLabel="Capture document with camera"
+ accessibilityLabel={t("upload.capture_label")}
>
- Camera
+ {t("upload.camera")}
- Photos
+ {t("upload.photos")}
- Files
+ {t("upload.files")}
@@ -381,12 +384,8 @@ export default function UploadScreen() {
{uploads.length === 0 ? (
-
- Tap Camera, Photos, or Files to upload a document.
-
-
- You can also share files from other apps directly to DocuElevate.
-
+ {t("upload.empty_title")}
+ {t("upload.empty_hint")}
) : (
uploads.map((item) => (
@@ -399,6 +398,9 @@ export default function UploadScreen() {
}
function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) {
+ // Subscribe to language changes so status labels re-render.
+ useLocale();
+
const uploadIconProps: Record = {
pending: { name: "time-outline", color: "#6b7280" },
uploading: { name: "arrow-up-circle-outline", color: "#1e40af" },
@@ -409,11 +411,11 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
/** Human-readable label for the server-side processing status. */
function serverStatusLabel(s: string): string {
const labels: Record = {
- pending: "Queued for processing…",
- processing: "Processing…",
- completed: "Processed",
- failed: "Processing failed",
- duplicate: "Duplicate – already processed",
+ pending: t("upload.status_queued"),
+ processing: t("upload.status_processing"),
+ completed: t("upload.status_completed"),
+ failed: t("upload.status_failed"),
+ duplicate: t("upload.status_duplicate"),
};
return labels[s] ?? s;
}
@@ -422,9 +424,9 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
function handleLongPress() {
if (!canRetry) return;
- Alert.alert("Retry Upload", `Do you want to retry uploading "${item.filename}"?`, [
- { text: "Cancel", style: "cancel" },
- { text: "Retry", onPress: () => onRetry(item) },
+ Alert.alert(t("upload.retry_title"), t("upload.retry_msg", { filename: item.filename }), [
+ { text: t("common.cancel"), style: "cancel" },
+ { text: t("common.retry"), onPress: () => onRetry(item) },
]);
}
@@ -434,7 +436,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
onPress={canRetry ? () => onRetry(item) : undefined}
style={rowStyles.row}
accessibilityRole={canRetry ? "button" : "none"}
- accessibilityLabel={canRetry ? `Retry uploading ${item.filename}` : undefined}
+ accessibilityLabel={canRetry ? `${t("common.retry")} ${item.filename}` : undefined}
accessibilityHint={canRetry ? "Tap or long-press to retry this upload" : undefined}
>
@@ -446,7 +448,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
)}
{item.status === "done" && !item.serverStatus && (
- Queued for processing…
+ {t("upload.status_queued")}
)}
{item.status === "done" && item.serverStatus && (
{item.error}
{canRetry && (
- Tap to retry
+ {t("upload.tap_retry")}
)}
)}
diff --git a/mobile/src/screens/WelcomeScreen.tsx b/mobile/src/screens/WelcomeScreen.tsx
index b56a91c0..8f3b8127 100644
--- a/mobile/src/screens/WelcomeScreen.tsx
+++ b/mobile/src/screens/WelcomeScreen.tsx
@@ -17,27 +17,31 @@ import {
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
-
-const FEATURES: { icon: string; title: string; description: string }[] = [
- {
- icon: "🔍",
- title: "OCR & Text Extraction",
- description: "Convert scanned PDFs and images into fully searchable text automatically.",
- },
- {
- icon: "🤖",
- title: "AI Metadata Extraction",
- description: "AI classifies documents and pulls out key fields like dates, amounts, and subjects.",
- },
- {
- icon: "☁️",
- title: "Multi-Cloud Storage",
- description: "Route processed files to Dropbox, Google Drive, OneDrive, S3, Nextcloud, and more.",
- },
-];
+import { useLocale, t } from "../i18n";
export default function WelcomeScreen() {
const router = useRouter();
+ // Subscribe to language changes so translated strings re-render.
+ useLocale();
+
+ const features = [
+ {
+ icon: "🔍",
+ title: t("welcome.feature_ocr_title"),
+ description: t("welcome.feature_ocr_desc"),
+ },
+ {
+ icon: "🤖",
+ title: t("welcome.feature_ai_title"),
+ description: t("welcome.feature_ai_desc"),
+ },
+ {
+ icon: "☁️",
+ title: t("welcome.feature_cloud_title"),
+ description: t("welcome.feature_cloud_desc"),
+ },
+ ];
+
return (
DocuElevate
- Intelligent Document Processing
-
- Ingest documents, run OCR, extract metadata with AI, and route files
- to your cloud storage — all in one seamless pipeline.
-
+ {t("welcome.tagline")}
+ {t("welcome.description")}
{/* Feature highlights */}
- {FEATURES.map((feature) => (
+ {features.map((feature) => (
{feature.icon}
@@ -80,42 +81,40 @@ export default function WelcomeScreen() {
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
onPress={() => router.push("/(auth)/login")}
accessibilityRole="button"
- accessibilityLabel="Get started — connect to your DocuElevate server"
+ accessibilityLabel={t("welcome.get_started")}
>
- Get Started
+ {t("welcome.get_started")}
-
- Connect to your self-hosted or cloud DocuElevate server.
-
+ {t("welcome.hint")}
{/* Legal links – accessible pre-login for GDPR / Apple compliance */}
Linking.openURL("https://app.docuelevate.org/privacy")}
accessibilityRole="link"
- accessibilityLabel="Privacy Policy"
+ accessibilityLabel={t("legal.privacy_policy")}
style={styles.legalLinkButton}
>
- Privacy Policy
+ {t("legal.privacy_policy")}
·
Linking.openURL("https://app.docuelevate.org/terms")}
accessibilityRole="link"
- accessibilityLabel="Terms of Service"
+ accessibilityLabel={t("legal.terms")}
style={styles.legalLinkButton}
>
- Terms
+ {t("legal.terms")}
·
Linking.openURL("https://app.docuelevate.org/imprint")}
accessibilityRole="link"
- accessibilityLabel="Imprint"
+ accessibilityLabel={t("legal.imprint")}
style={styles.legalLinkButton}
>
- Imprint
+ {t("legal.imprint")}
diff --git a/mobile/src/services/api.ts b/mobile/src/services/api.ts
index 1e0b41ea..27861f85 100644
--- a/mobile/src/services/api.ts
+++ b/mobile/src/services/api.ts
@@ -26,6 +26,7 @@ export interface WhoAmIResponse {
email: string | null;
avatar_url: string | null;
is_admin: boolean;
+ preferred_language: string | null;
}
export interface GenerateTokenResponse {
@@ -209,6 +210,11 @@ class DocuElevateAPI {
return this.request("GET", "/api/mobile/whoami");
}
+ /** Sync the user's preferred UI language to the server. */
+ async setServerLanguage(lang: string): Promise {
+ await this.request("POST", "/api/i18n/language", { body: { language: lang } });
+ }
+
// -------------------------------------------------------------------------
// Push notifications
// -------------------------------------------------------------------------
diff --git a/tests/test_api_mobile.py b/tests/test_api_mobile.py
index 6069694e..5fcd7229 100644
--- a/tests/test_api_mobile.py
+++ b/tests/test_api_mobile.py
@@ -467,6 +467,42 @@ class TestWhoAmI:
assert data["email"] == _OWNER
assert data["avatar_url"] is not None # Gravatar URL
assert data["is_admin"] is False
+ assert data["preferred_language"] is None # not set yet
+ finally:
+ _cleanup(app)
+
+ def test_whoami_returns_preferred_language(self, mob_engine, mob_session):
+ """preferred_language from UserProfile is included in the whoami response."""
+ from app.main import app
+ from app.models import UserProfile
+
+ profile = UserProfile(
+ user_id=_OWNER,
+ display_name="Bob Test",
+ preferred_language="de",
+ )
+ mob_session.add(profile)
+ mob_session.commit()
+
+ client = _make_client(mob_engine)
+ try:
+ resp = client.get("/api/mobile/whoami")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["preferred_language"] == "de"
+ finally:
+ _cleanup(app)
+
+ def test_whoami_no_profile_preferred_language_is_null(self, mob_engine):
+ """preferred_language is null when no UserProfile exists."""
+ from app.main import app
+
+ client = _make_client(mob_engine)
+ try:
+ resp = client.get("/api/mobile/whoami")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["preferred_language"] is None
finally:
_cleanup(app)