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
+31 -29
View File
@@ -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<UploadItem[]>([]);
// 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 (
<View style={styles.center}>
<Text style={styles.emptyText}>Please sign in to upload documents.</Text>
<Text style={styles.emptyText}>{t("upload.sign_in_required")}</Text>
</View>
);
}
@@ -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")}
>
<Ionicons name="camera-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>Camera</Text>
<Text style={styles.actionLabel}>{t("upload.camera")}</Text>
</Pressable>
<Pressable
style={[styles.actionButton, styles.photoLibraryButton]}
onPress={handlePhotoLibrary}
accessibilityRole="button"
accessibilityLabel="Select photo from library"
accessibilityLabel={t("upload.photo_label")}
>
<Ionicons name="images-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>Photos</Text>
<Text style={styles.actionLabel}>{t("upload.photos")}</Text>
</Pressable>
<Pressable
style={[styles.actionButton, styles.fileButton]}
onPress={handleFilePicker}
accessibilityRole="button"
accessibilityLabel="Pick file from device"
accessibilityLabel={t("upload.file_label")}
>
<Ionicons name="document-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>Files</Text>
<Text style={styles.actionLabel}>{t("upload.files")}</Text>
</Pressable>
</View>
@@ -381,12 +384,8 @@ export default function UploadScreen() {
{uploads.length === 0 ? (
<View style={styles.emptyState}>
<Ionicons name="cloud-upload-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
<Text style={styles.emptyText}>
Tap Camera, Photos, or Files to upload a document.
</Text>
<Text style={styles.emptyHint}>
You can also share files from other apps directly to DocuElevate.
</Text>
<Text style={styles.emptyText}>{t("upload.empty_title")}</Text>
<Text style={styles.emptyHint}>{t("upload.empty_hint")}</Text>
</View>
) : (
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<UploadItem["status"], { name: keyof typeof Ionicons.glyphMap; color: string }> = {
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<string, string> = {
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}
>
<Ionicons name={uploadIconProps[item.status].name} size={22} color={uploadIconProps[item.status].color} style={rowStyles.icon} />
@@ -446,7 +448,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
<ActivityIndicator size="small" color="#1e40af" />
)}
{item.status === "done" && !item.serverStatus && (
<Text style={rowStyles.statusQueued}>Queued for processing…</Text>
<Text style={rowStyles.statusQueued}>{t("upload.status_queued")}</Text>
)}
{item.status === "done" && item.serverStatus && (
<Text
@@ -465,7 +467,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
<View>
<Text style={rowStyles.statusError}>{item.error}</Text>
{canRetry && (
<Text style={rowStyles.retryHint}>Tap to retry</Text>
<Text style={rowStyles.retryHint}>{t("upload.tap_retry")}</Text>
)}
</View>
)}