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
+2
View File
@@ -120,6 +120,7 @@ class WhoAmIResponse(BaseModel):
email: str | None email: str | None
avatar_url: str | None avatar_url: str | None
is_admin: bool is_admin: bool
preferred_language: str | None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -357,4 +358,5 @@ async def whoami(
"email": email, "email": email,
"avatar_url": avatar_url, "avatar_url": avatar_url,
"is_admin": is_admin, "is_admin": is_admin,
"preferred_language": profile.preferred_language if profile else None,
} }
+22 -7
View File
@@ -285,10 +285,19 @@ The mobile app supports five languages with automatic device-locale detection:
### How it works ### How it works
1. On app launch, `expo-localization` detects the device's preferred language Language priority (highest to lowest):
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 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.
4. Users can manually switch languages from the **Profile** tab → **Settings****Language** 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 ### 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 | | `POST` | `/api/mobile/register-device` | Bearer | Register Expo push token |
| `GET` | `/api/mobile/devices` | Bearer | List registered devices | | `GET` | `/api/mobile/devices` | Bearer | List registered devices |
| `DELETE` | `/api/mobile/devices/{id}` | Bearer | Deactivate a device | | `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. 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 ### GET /api/mobile/whoami
Returns the current user's profile. Returns the current user's profile, including the server-stored language preference.
**Response (200):** **Response (200):**
```json ```json
@@ -369,10 +379,15 @@ Returns the current user's profile.
"display_name": "John Doe", "display_name": "John Doe",
"email": "john@example.com", "email": "john@example.com",
"avatar_url": "https://www.gravatar.com/avatar/...", "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 ## 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. 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.
+15 -10
View File
@@ -11,10 +11,15 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react"; import React from "react";
import { usePushNotifications } from "../../src/hooks/usePushNotifications"; import { usePushNotifications } from "../../src/hooks/usePushNotifications";
import { useAuth } from "../../src/context/AuthContext"; import { useAuth } from "../../src/context/AuthContext";
import { useLocale, t } from "../../src/i18n";
export default function TabLayout() { export default function TabLayout() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
usePushNotifications(isAuthenticated); 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 ( return (
<Tabs <Tabs
@@ -37,8 +42,8 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: "Upload", title: t("tabs.upload"),
tabBarLabel: "Upload", tabBarLabel: t("tabs.upload"),
tabBarIcon: ({ color, size }) => ( tabBarIcon: ({ color, size }) => (
<Ionicons name="cloud-upload-outline" size={size} color={color} /> <Ionicons name="cloud-upload-outline" size={size} color={color} />
), ),
@@ -48,23 +53,23 @@ export default function TabLayout() {
<Tabs.Screen <Tabs.Screen
name="files" name="files"
options={{ options={{
title: "Files", title: t("tabs.files"),
tabBarLabel: "Files", tabBarLabel: t("tabs.files"),
tabBarIcon: ({ color, size }) => ( tabBarIcon: ({ color, size }) => (
<Ionicons name="document-text-outline" size={size} color={color} /> <Ionicons name="document-text-outline" size={size} color={color} />
), ),
headerTitle: "My Documents", headerTitle: t("files.title"),
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="profile" name="profile"
options={{ options={{
title: "Profile", title: t("tabs.profile"),
tabBarLabel: "Profile", tabBarLabel: t("tabs.profile"),
tabBarIcon: ({ color, size }) => ( tabBarIcon: ({ color, size }) => (
<Ionicons name="person-circle-outline" size={size} color={color} /> <Ionicons name="person-circle-outline" size={size} color={color} />
), ),
headerTitle: "Profile", headerTitle: t("tabs.profile"),
}} }}
/> />
{/* File detail screen hidden from tab bar, accessed via navigation */} {/* File detail screen hidden from tab bar, accessed via navigation */}
@@ -72,8 +77,8 @@ export default function TabLayout() {
name="file-detail" name="file-detail"
options={{ options={{
href: null, href: null,
title: "File Details", title: t("file_detail.title"),
headerTitle: "File Details", headerTitle: t("file_detail.title"),
}} }}
/> />
</Tabs> </Tabs>
+20 -6
View File
@@ -23,6 +23,7 @@ import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { AuthProvider, useAuth } from "../src/context/AuthContext"; import { AuthProvider, useAuth } from "../src/context/AuthContext";
import { ShareProvider, useShare } from "../src/context/ShareContext"; import { ShareProvider, useShare } from "../src/context/ShareContext";
import { LocaleProvider, useLocale, isLanguageSupported } from "../src/i18n";
import { mimeTypeFromFilename } from "../src/utils/mimeTypes"; import { mimeTypeFromFilename } from "../src/utils/mimeTypes";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -100,11 +101,22 @@ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string; mim
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function AuthGuard() { function AuthGuard() {
const { isLoading, isAuthenticated } = useAuth(); const { isLoading, isAuthenticated, user } = useAuth();
const { addPendingFile } = useShare(); const { addPendingFile } = useShare();
const { setLang } = useLocale();
const segments = useSegments(); const segments = useSegments();
const router = useRouter(); 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). // 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) // Both cold-start (app was not running) and warm-start (app in background)
// cases are handled. // cases are handled.
@@ -162,11 +174,13 @@ function AuthGuard() {
export default function RootLayout() { export default function RootLayout() {
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<ShareProvider> <LocaleProvider>
<AuthProvider> <ShareProvider>
<AuthGuard /> <AuthProvider>
</AuthProvider> <AuthGuard />
</ShareProvider> </AuthProvider>
</ShareProvider>
</LocaleProvider>
</SafeAreaProvider> </SafeAreaProvider>
); );
} }
+3 -1
View File
@@ -99,7 +99,9 @@
"delete_account_title": "Konto löschen", "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.", "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.", "could_not_open": "Konnte {page} nicht öffnen. Bitte versuchen Sie es erneut.",
"admin": "Admin" "admin": "Admin",
"settings": "Einstellungen",
"language": "Sprache"
}, },
"legal": { "legal": {
"privacy_policy": "Datenschutz", "privacy_policy": "Datenschutz",
+3 -1
View File
@@ -99,7 +99,9 @@
"delete_account_title": "Delete Account", "delete_account_title": "Delete Account",
"delete_account_msg": "This will permanently delete your account and all associated data. This action cannot be undone.", "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.", "could_not_open": "Could not open the {page}. Please try again.",
"admin": "Admin" "admin": "Admin",
"settings": "Settings",
"language": "Language"
}, },
"legal": { "legal": {
"privacy_policy": "Privacy Policy", "privacy_policy": "Privacy Policy",
+3 -1
View File
@@ -99,7 +99,9 @@
"delete_account_title": "Eliminar Cuenta", "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.", "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.", "could_not_open": "No se pudo abrir {page}. Inténtalo de nuevo.",
"admin": "Admin" "admin": "Admin",
"settings": "Configuración",
"language": "Idioma"
}, },
"legal": { "legal": {
"privacy_policy": "Privacidad", "privacy_policy": "Privacidad",
+3 -1
View File
@@ -99,7 +99,9 @@
"delete_account_title": "Supprimer le Compte", "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.", "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.", "could_not_open": "Impossible d'ouvrir {page}. Veuillez réessayer.",
"admin": "Admin" "admin": "Admin",
"settings": "Paramètres",
"language": "Langue"
}, },
"legal": { "legal": {
"privacy_policy": "Confidentialité", "privacy_policy": "Confidentialité",
+109 -2
View File
@@ -6,9 +6,23 @@
* locales. * locales.
* *
* Supported languages: English, German, Spanish, French, Italian. * 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 { getLocales } from "expo-localization";
import React from "react";
import de from "./de.json"; import de from "./de.json";
import en from "./en.json"; import en from "./en.json";
@@ -28,6 +42,8 @@ const translations: Record<string, TranslationMap> = { en, de, es, fr, it };
// Locale detection // Locale detection
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const LANG_STORAGE_KEY = "@docuelevate:language";
/** Resolve the best-matching language code from the device locale list. */ /** Resolve the best-matching language code from the device locale list. */
function detectLanguage(): string { function detectLanguage(): string {
try { try {
@@ -46,7 +62,7 @@ function detectLanguage(): string {
let currentLanguage: string = detectLanguage(); let currentLanguage: string = detectLanguage();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public API // Plain-function public API (framework-agnostic)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
@@ -82,13 +98,22 @@ export function getLanguage(): string {
return currentLanguage; 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 { export function setLanguage(lang: string): void {
if (translations[lang]) { if (translations[lang]) {
currentLanguage = 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. */ /** Return the list of supported language codes. */
export function getSupportedLanguages(): { code: string; label: string }[] { export function getSupportedLanguages(): { code: string; label: string }[] {
return [ return [
@@ -99,3 +124,85 @@ export function getSupportedLanguages(): { code: string; label: string }[] {
{ code: "it", label: "Italiano" }, { 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_title": "Elimina Account",
"delete_account_msg": "Questo eliminerà permanentemente il tuo account e tutti i dati associati. Questa azione non può essere annullata.", "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.", "could_not_open": "Impossibile aprire {page}. Riprova.",
"admin": "Admin" "admin": "Admin",
"settings": "Impostazioni",
"language": "Lingua"
}, },
"legal": { "legal": {
"privacy_policy": "Privacy", "privacy_policy": "Privacy",
+16 -13
View File
@@ -20,6 +20,7 @@ import {
} from "react-native"; } from "react-native";
import type { FileDetail } from "../services/api"; import type { FileDetail } from "../services/api";
import api from "../services/api"; import api from "../services/api";
import { useLocale, t } from "../i18n";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -88,6 +89,8 @@ export default function FileDetailScreen() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Subscribe to language changes so translated strings re-render.
useLocale();
const fileId = parseInt(id ?? "0", 10); const fileId = parseInt(id ?? "0", 10);
@@ -127,12 +130,12 @@ export default function FileDetailScreen() {
if (error || !detail) { if (error || !detail) {
return ( return (
<View style={styles.center}> <View style={styles.center}>
<Text style={styles.errorText}>{error ?? "File not found"}</Text> <Text style={styles.errorText}>{error ?? t("file_detail.file_not_found")}</Text>
<Pressable style={styles.retryButton} onPress={handleRefresh}> <Pressable style={styles.retryButton} onPress={handleRefresh}>
<Text style={styles.retryText}>Retry</Text> <Text style={styles.retryText}>{t("common.retry")}</Text>
</Pressable> </Pressable>
<Pressable style={styles.backButton} onPress={() => router.back()}> <Pressable style={styles.backButton} onPress={() => router.back()}>
<Text style={styles.backButtonText}> Back</Text> <Text style={styles.backButtonText}>{t("common.back")}</Text>
</Pressable> </Pressable>
</View> </View>
); );
@@ -152,10 +155,10 @@ export default function FileDetailScreen() {
style={styles.backRow} style={styles.backRow}
onPress={() => router.back()} onPress={() => router.back()}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Go back" accessibilityLabel={t("file_detail.back")}
> >
<Ionicons name="arrow-back" size={20} color="#1e40af" /> <Ionicons name="arrow-back" size={20} color="#1e40af" />
<Text style={styles.backLabel}>Back to Files</Text> <Text style={styles.backLabel}>{t("file_detail.back")}</Text>
</Pressable> </Pressable>
{/* File info card */} {/* File info card */}
@@ -178,20 +181,20 @@ export default function FileDetailScreen() {
</View> </View>
<View style={styles.metaGrid}> <View style={styles.metaGrid}>
<MetaRow label="File Size" value={formatBytes(file.file_size)} /> <MetaRow label={t("file_detail.file_size")} value={formatBytes(file.file_size)} />
<MetaRow label="MIME Type" value={file.mime_type ?? ""} /> <MetaRow label={t("file_detail.mime_type")} value={file.mime_type ?? ""} />
<MetaRow label="Uploaded" value={formatDateTime(file.created_at)} /> <MetaRow label={t("file_detail.uploaded")} value={formatDateTime(file.created_at)} />
<MetaRow label="File Hash" value={file.filehash ? `${file.filehash.slice(0, 24)}` : ""} /> <MetaRow label={t("file_detail.file_hash")} value={file.filehash ? `${file.filehash.slice(0, 24)}` : ""} />
<MetaRow label="Last Step" value={status.last_step ?? ""} /> <MetaRow label={t("file_detail.last_step")} value={status.last_step ?? ""} />
<MetaRow label="Total Steps" value={String(status.total_steps)} /> <MetaRow label={t("file_detail.total_steps")} value={String(status.total_steps)} />
</View> </View>
</View> </View>
{/* Processing logs */} {/* Processing logs */}
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.sectionTitle}>Processing Log</Text> <Text style={styles.sectionTitle}>{t("file_detail.processing_log")}</Text>
{detail.logs.length === 0 ? ( {detail.logs.length === 0 ? (
<Text style={styles.emptyLog}>No processing logs yet.</Text> <Text style={styles.emptyLog}>{t("file_detail.no_logs")}</Text>
) : ( ) : (
detail.logs.map((log, idx) => { detail.logs.map((log, idx) => {
const icon = logStepIcon(log.status); const icon = logStepIcon(log.status);
+9 -8
View File
@@ -17,6 +17,7 @@ import {
} from "react-native"; } from "react-native";
import type { FileRecord } from "../services/api"; import type { FileRecord } from "../services/api";
import api from "../services/api"; import api from "../services/api";
import { useLocale, t } from "../i18n";
function formatBytes(bytes: number | null): string { function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === undefined) return ""; if (bytes === null || bytes === undefined) return "";
@@ -58,6 +59,8 @@ export default function FilesScreen() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Subscribe to language changes so translated strings re-render.
useLocale();
const fetchFiles = useCallback( const fetchFiles = useCallback(
async (pageNum: number, replace: boolean, search?: string) => { async (pageNum: number, replace: boolean, search?: string) => {
@@ -150,7 +153,7 @@ export default function FilesScreen() {
<View style={styles.center}> <View style={styles.center}>
<Text style={styles.errorText}>{error}</Text> <Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={handleRefresh}> <Pressable style={styles.retryButton} onPress={handleRefresh}>
<Text style={styles.retryText}>Retry</Text> <Text style={styles.retryText}>{t("common.retry")}</Text>
</Pressable> </Pressable>
</View> </View>
); );
@@ -163,21 +166,21 @@ export default function FilesScreen() {
<Ionicons name="search-outline" size={18} color="#9ca3af" style={styles.searchIcon} /> <Ionicons name="search-outline" size={18} color="#9ca3af" style={styles.searchIcon} />
<TextInput <TextInput
style={styles.searchInput} style={styles.searchInput}
placeholder="Search documents…" placeholder={t("files.search_placeholder")}
placeholderTextColor="#9ca3af" placeholderTextColor="#9ca3af"
value={searchQuery} value={searchQuery}
onChangeText={handleSearch} onChangeText={handleSearch}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
returnKeyType="search" returnKeyType="search"
accessibilityLabel="Search documents" accessibilityLabel={t("common.search")}
/> />
{searchQuery.length > 0 && ( {searchQuery.length > 0 && (
<Pressable <Pressable
onPress={handleClearSearch} onPress={handleClearSearch}
style={styles.clearButton} style={styles.clearButton}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Clear search" accessibilityLabel={t("common.clear_search")}
> >
<Ionicons name="close-circle" size={18} color="#9ca3af" /> <Ionicons name="close-circle" size={18} color="#9ca3af" />
</Pressable> </Pressable>
@@ -199,12 +202,10 @@ export default function FilesScreen() {
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Ionicons name="folder-open-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} /> <Ionicons name="folder-open-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
<Text style={styles.emptyText}> <Text style={styles.emptyText}>
{searchQuery ? "No documents match your search." : "No documents yet."} {searchQuery ? t("files.search_empty") : t("files.empty_title")}
</Text> </Text>
<Text style={styles.emptyHint}> <Text style={styles.emptyHint}>
{searchQuery {searchQuery ? t("files.search_empty_hint") : t("files.empty_hint")}
? "Try a different search term."
: "Upload a document from the Upload tab to get started."}
</Text> </Text>
</View> </View>
} }
+27 -26
View File
@@ -24,6 +24,7 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useLocale, t } from "../i18n";
export default function LoginScreen() { export default function LoginScreen() {
const { signIn, signInWithQR } = useAuth(); const { signIn, signInWithQR } = useAuth();
@@ -31,6 +32,8 @@ export default function LoginScreen() {
const [serverUrl, setServerUrl] = useState("https://app.docuelevate.org"); const [serverUrl, setServerUrl] = useState("https://app.docuelevate.org");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [qrLoading, setQrLoading] = 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=...) // Handle incoming deep links for QR login (docuelevate://qr-login?token=...&server=...)
const handleDeepLink = useCallback( const handleDeepLink = useCallback(
@@ -46,8 +49,8 @@ export default function LoginScreen() {
} }
} }
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : "QR login failed"; const message = err instanceof Error ? err.message : t("login.qr_login_failed");
Alert.alert("QR Login Failed", message); Alert.alert(t("login.qr_login_failed"), message);
} finally { } finally {
setQrLoading(false); setQrLoading(false);
} }
@@ -70,11 +73,11 @@ export default function LoginScreen() {
async function handleSignIn() { async function handleSignIn() {
const url = serverUrl.trim(); const url = serverUrl.trim();
if (!url) { 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; return;
} }
if (!url.startsWith("http://") && !url.startsWith("https://")) { 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; return;
} }
@@ -82,8 +85,8 @@ export default function LoginScreen() {
try { try {
await signIn(url); await signIn(url);
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : "Sign-in failed"; const message = err instanceof Error ? err.message : t("login.sign_in_failed");
Alert.alert("Sign-in failed", message); Alert.alert(t("login.sign_in_failed"), message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -104,12 +107,12 @@ export default function LoginScreen() {
/> />
<Text style={styles.logoText}>DocuElevate</Text> <Text style={styles.logoText}>DocuElevate</Text>
</View> </View>
<Text style={styles.tagline}>Intelligent Document Processing</Text> <Text style={styles.tagline}>{t("welcome.tagline")}</Text>
<Text style={styles.label}>Server URL</Text> <Text style={styles.label}>{t("login.server_url")}</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="https://your-docuelevate-server.com" placeholder={t("login.server_url_placeholder")}
placeholderTextColor="#9ca3af" placeholderTextColor="#9ca3af"
value={serverUrl} value={serverUrl}
onChangeText={setServerUrl} onChangeText={setServerUrl}
@@ -118,7 +121,7 @@ export default function LoginScreen() {
keyboardType="url" keyboardType="url"
returnKeyType="go" returnKeyType="go"
onSubmitEditing={handleSignIn} onSubmitEditing={handleSignIn}
accessibilityLabel="Server URL" accessibilityLabel={t("login.server_url")}
/> />
<Pressable <Pressable
@@ -126,18 +129,18 @@ export default function LoginScreen() {
onPress={handleSignIn} onPress={handleSignIn}
disabled={loading || qrLoading} disabled={loading || qrLoading}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Sign in with SSO" accessibilityLabel={t("login.sign_in_sso")}
> >
{loading ? ( {loading ? (
<ActivityIndicator color="#fff" /> <ActivityIndicator color="#fff" />
) : ( ) : (
<Text style={styles.buttonText}>Sign in with SSO</Text> <Text style={styles.buttonText}>{t("login.sign_in_sso")}</Text>
)} )}
</Pressable> </Pressable>
<View style={styles.dividerRow}> <View style={styles.dividerRow}>
<View style={styles.dividerLine} /> <View style={styles.dividerLine} />
<Text style={styles.dividerText}>or</Text> <Text style={styles.dividerText}>{t("login.or")}</Text>
<View style={styles.dividerLine} /> <View style={styles.dividerLine} />
</View> </View>
@@ -148,26 +151,24 @@ export default function LoginScreen() {
}} }}
disabled={loading || qrLoading} disabled={loading || qrLoading}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Sign in with QR code" accessibilityLabel={t("login.scan_qr")}
> >
{qrLoading ? ( {qrLoading ? (
<ActivityIndicator color="#1e40af" /> <ActivityIndicator color="#1e40af" />
) : ( ) : (
<Text style={styles.qrButtonText}>📱 Scan QR Code to Login</Text> <Text style={styles.qrButtonText}>{t("login.scan_qr")}</Text>
)} )}
</Pressable> </Pressable>
<Text style={styles.hint}> <Text style={styles.hint}>{t("login.hint")}</Text>
Sign in via SSO or scan a QR code from the web app.
</Text>
<Pressable <Pressable
onPress={() => router.back()} onPress={() => router.back()}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Back to welcome screen" accessibilityLabel={t("login.back")}
style={styles.backLink} style={styles.backLink}
> >
<Text style={styles.backLinkText}> Back</Text> <Text style={styles.backLinkText}>{t("login.back")}</Text>
</Pressable> </Pressable>
{/* Legal links accessible pre-login for GDPR / Apple compliance */} {/* Legal links accessible pre-login for GDPR / Apple compliance */}
@@ -178,10 +179,10 @@ export default function LoginScreen() {
Linking.openURL(`${base.replace(/\/$/, "")}/privacy`); Linking.openURL(`${base.replace(/\/$/, "")}/privacy`);
}} }}
accessibilityRole="link" accessibilityRole="link"
accessibilityLabel="Privacy Policy" accessibilityLabel={t("legal.privacy_policy")}
style={styles.legalLinkButton} style={styles.legalLinkButton}
> >
<Text style={styles.legalLinkText}>Privacy Policy</Text> <Text style={styles.legalLinkText}>{t("legal.privacy_policy")}</Text>
</Pressable> </Pressable>
<Text style={styles.legalSeparator}>·</Text> <Text style={styles.legalSeparator}>·</Text>
<Pressable <Pressable
@@ -190,10 +191,10 @@ export default function LoginScreen() {
Linking.openURL(`${base.replace(/\/$/, "")}/terms`); Linking.openURL(`${base.replace(/\/$/, "")}/terms`);
}} }}
accessibilityRole="link" accessibilityRole="link"
accessibilityLabel="Terms of Service" accessibilityLabel={t("legal.terms")}
style={styles.legalLinkButton} style={styles.legalLinkButton}
> >
<Text style={styles.legalLinkText}>Terms</Text> <Text style={styles.legalLinkText}>{t("legal.terms")}</Text>
</Pressable> </Pressable>
<Text style={styles.legalSeparator}>·</Text> <Text style={styles.legalSeparator}>·</Text>
<Pressable <Pressable
@@ -202,10 +203,10 @@ export default function LoginScreen() {
Linking.openURL(`${base.replace(/\/$/, "")}/imprint`); Linking.openURL(`${base.replace(/\/$/, "")}/imprint`);
}} }}
accessibilityRole="link" accessibilityRole="link"
accessibilityLabel="Imprint" accessibilityLabel={t("legal.imprint")}
style={styles.legalLinkButton} style={styles.legalLinkButton}
> >
<Text style={styles.legalLinkText}>Imprint</Text> <Text style={styles.legalLinkText}>{t("legal.imprint")}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
+50 -43
View File
@@ -4,7 +4,7 @@
import Constants from "expo-constants"; import Constants from "expo-constants";
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import React, { useState } from "react"; import React from "react";
import { import {
Alert, Alert,
Image, Image,
@@ -15,23 +15,33 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { useAuth } from "../context/AuthContext"; 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"; const DEFAULT_SERVER_URL = "https://app.docuelevate.org";
export default function ProfileScreen() { export default function ProfileScreen() {
const { user, signOut, baseUrl } = useAuth(); const { user, signOut, baseUrl } = useAuth();
const [selectedLanguage, setSelectedLanguage] = useState(getLanguage()); const { lang, setLang } = useLocale();
const effectiveBaseUrl = baseUrl || DEFAULT_SERVER_URL; const effectiveBaseUrl = baseUrl || DEFAULT_SERVER_URL;
const appVersion = Constants.expoConfig?.version ?? "1.0.0"; const appVersion = Constants.expoConfig?.version ?? "1.0.0";
const languages = getSupportedLanguages(); 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() { function handleSignOut() {
Alert.alert("Sign out", "Are you sure you want to sign out?", [ Alert.alert(t("profile.sign_out_title"), t("profile.sign_out_msg"), [
{ text: "Cancel", style: "cancel" }, { text: t("common.cancel"), style: "cancel" },
{ {
text: "Sign out", text: t("profile.sign_out"),
style: "destructive", style: "destructive",
onPress: signOut, onPress: signOut,
}, },
@@ -40,16 +50,16 @@ export default function ProfileScreen() {
function handleDeleteAccount() { function handleDeleteAccount() {
Alert.alert( Alert.alert(
"Delete Account", t("profile.delete_account_title"),
"This will permanently delete your account and all associated data. This action cannot be undone.", 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", style: "destructive",
onPress: () => { onPress: () => {
Linking.openURL(`${effectiveBaseUrl}/account/delete`).catch(() => { 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() { function openPrivacyPolicy() {
Linking.openURL(`${effectiveBaseUrl}/privacy`).catch(() => { 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() { function openTermsOfService() {
Linking.openURL(`${effectiveBaseUrl}/terms`).catch(() => { 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() { function openImprint() {
Linking.openURL(`${effectiveBaseUrl}/imprint`).catch(() => { 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) { if (!user) {
return ( return (
<View style={styles.center}> <View style={styles.center}>
<Text style={styles.emptyText}>Not signed in</Text> <Text style={styles.emptyText}>{t("profile.not_signed_in")}</Text>
</View> </View>
); );
} }
@@ -102,20 +112,20 @@ export default function ProfileScreen() {
)} )}
<Text style={styles.displayName}>{user.display_name ?? user.owner_id}</Text> <Text style={styles.displayName}>{user.display_name ?? user.owner_id}</Text>
{user.email && <Text style={styles.email}>{user.email}</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> </View>
{/* Server info */} {/* Server info */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Connection</Text> <Text style={styles.sectionTitle}>{t("profile.connection")}</Text>
<View style={styles.row}> <View style={styles.row}>
<Text style={styles.rowLabel}>Server</Text> <Text style={styles.rowLabel}>{t("profile.server")}</Text>
<Text style={styles.rowValue} numberOfLines={1}> <Text style={styles.rowValue} numberOfLines={1}>
{effectiveBaseUrl} {effectiveBaseUrl}
</Text> </Text>
</View> </View>
<View style={styles.row}> <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}> <Text style={styles.rowValue} numberOfLines={1}>
{user.owner_id} {user.owner_id}
</Text> </Text>
@@ -124,31 +134,28 @@ export default function ProfileScreen() {
{/* Settings */} {/* Settings */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Settings</Text> <Text style={styles.sectionTitle}>{t("profile.settings")}</Text>
<Text style={styles.settingLabel}>Language</Text> <Text style={styles.settingLabel}>{t("profile.language")}</Text>
<View style={styles.languageGrid}> <View style={styles.languageGrid}>
{languages.map((lang) => ( {languages.map((l) => (
<Pressable <Pressable
key={lang.code} key={l.code}
style={[ style={[
styles.languageChip, styles.languageChip,
selectedLanguage === lang.code && styles.languageChipActive, lang === l.code && styles.languageChipActive,
]} ]}
onPress={() => { onPress={() => handleLanguageSelect(l.code)}
setLanguage(lang.code);
setSelectedLanguage(lang.code);
}}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Set language to ${lang.label}`} accessibilityLabel={`Set language to ${l.label}`}
accessibilityState={{ selected: selectedLanguage === lang.code }} accessibilityState={{ selected: lang === l.code }}
> >
<Text <Text
style={[ style={[
styles.languageChipText, styles.languageChipText,
selectedLanguage === lang.code && styles.languageChipTextActive, lang === l.code && styles.languageChipTextActive,
]} ]}
> >
{lang.label} {l.label}
</Text> </Text>
</Pressable> </Pressable>
))} ))}
@@ -157,32 +164,32 @@ export default function ProfileScreen() {
{/* Legal & Privacy */} {/* Legal & Privacy */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Legal</Text> <Text style={styles.sectionTitle}>{t("profile.legal")}</Text>
<Pressable <Pressable
style={styles.linkRow} style={styles.linkRow}
onPress={openPrivacyPolicy} onPress={openPrivacyPolicy}
accessibilityRole="link" 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> <Text style={styles.linkChevron}></Text>
</Pressable> </Pressable>
<Pressable <Pressable
style={styles.linkRow} style={styles.linkRow}
onPress={openTermsOfService} onPress={openTermsOfService}
accessibilityRole="link" 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> <Text style={styles.linkChevron}></Text>
</Pressable> </Pressable>
<Pressable <Pressable
style={[styles.linkRow, styles.linkRowLast]} style={[styles.linkRow, styles.linkRowLast]}
onPress={openImprint} onPress={openImprint}
accessibilityRole="link" 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> <Text style={styles.linkChevron}></Text>
</Pressable> </Pressable>
</View> </View>
@@ -193,9 +200,9 @@ export default function ProfileScreen() {
style={styles.signOutButton} style={styles.signOutButton}
onPress={handleSignOut} onPress={handleSignOut}
accessibilityRole="button" 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> </Pressable>
</View> </View>
@@ -205,9 +212,9 @@ export default function ProfileScreen() {
style={styles.deleteAccountButton} style={styles.deleteAccountButton}
onPress={handleDeleteAccount} onPress={handleDeleteAccount}
accessibilityRole="button" 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> </Pressable>
</View> </View>
+31 -29
View File
@@ -30,6 +30,7 @@ import { useAuth } from "../context/AuthContext";
import { useShare } from "../context/ShareContext"; import { useShare } from "../context/ShareContext";
import { normalizeFileUri } from "../utils/normalizeUri"; import { normalizeFileUri } from "../utils/normalizeUri";
import api from "../services/api"; import api from "../services/api";
import { useLocale, t } from "../i18n";
/** Statuses that indicate processing has finished (no further polling needed). */ /** Statuses that indicate processing has finished (no further polling needed). */
const TERMINAL_STATUSES = new Set(["completed", "failed", "duplicate"]); const TERMINAL_STATUSES = new Set(["completed", "failed", "duplicate"]);
@@ -56,6 +57,8 @@ export default function UploadScreen() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { pendingFiles, clearPendingFiles } = useShare(); const { pendingFiles, clearPendingFiles } = useShare();
const [uploads, setUploads] = useState<UploadItem[]>([]); 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 // Keep a ref in sync so the polling interval can read current state without
// capturing a stale closure. // capturing a stale closure.
@@ -268,8 +271,8 @@ export default function UploadScreen() {
const { status } = await ImagePicker.requestCameraPermissionsAsync(); const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== "granted") { if (status !== "granted") {
Alert.alert( Alert.alert(
"Camera access required", t("upload.camera_access_title"),
"Please grant camera access in Settings to capture documents." t("upload.camera_access_msg")
); );
return; return;
} }
@@ -291,8 +294,8 @@ export default function UploadScreen() {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") { if (status !== "granted") {
Alert.alert( Alert.alert(
"Photo library access required", t("upload.photo_access_title"),
"Please grant photo library access in Settings to select images." t("upload.photo_access_msg")
); );
return; return;
} }
@@ -329,14 +332,14 @@ export default function UploadScreen() {
} }
} }
} catch (err: unknown) { } 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) { if (!isAuthenticated) {
return ( return (
<View style={styles.center}> <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> </View>
); );
} }
@@ -349,30 +352,30 @@ export default function UploadScreen() {
style={[styles.actionButton, styles.cameraButton]} style={[styles.actionButton, styles.cameraButton]}
onPress={handleCamera} onPress={handleCamera}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Capture document with camera" accessibilityLabel={t("upload.capture_label")}
> >
<Ionicons name="camera-outline" size={28} color="#fff" style={styles.actionIcon} /> <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>
<Pressable <Pressable
style={[styles.actionButton, styles.photoLibraryButton]} style={[styles.actionButton, styles.photoLibraryButton]}
onPress={handlePhotoLibrary} onPress={handlePhotoLibrary}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Select photo from library" accessibilityLabel={t("upload.photo_label")}
> >
<Ionicons name="images-outline" size={28} color="#fff" style={styles.actionIcon} /> <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>
<Pressable <Pressable
style={[styles.actionButton, styles.fileButton]} style={[styles.actionButton, styles.fileButton]}
onPress={handleFilePicker} onPress={handleFilePicker}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Pick file from device" accessibilityLabel={t("upload.file_label")}
> >
<Ionicons name="document-outline" size={28} color="#fff" style={styles.actionIcon} /> <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> </Pressable>
</View> </View>
@@ -381,12 +384,8 @@ export default function UploadScreen() {
{uploads.length === 0 ? ( {uploads.length === 0 ? (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Ionicons name="cloud-upload-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} /> <Ionicons name="cloud-upload-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
<Text style={styles.emptyText}> <Text style={styles.emptyText}>{t("upload.empty_title")}</Text>
Tap Camera, Photos, or Files to upload a document. <Text style={styles.emptyHint}>{t("upload.empty_hint")}</Text>
</Text>
<Text style={styles.emptyHint}>
You can also share files from other apps directly to DocuElevate.
</Text>
</View> </View>
) : ( ) : (
uploads.map((item) => ( uploads.map((item) => (
@@ -399,6 +398,9 @@ export default function UploadScreen() {
} }
function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) { 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 }> = { const uploadIconProps: Record<UploadItem["status"], { name: keyof typeof Ionicons.glyphMap; color: string }> = {
pending: { name: "time-outline", color: "#6b7280" }, pending: { name: "time-outline", color: "#6b7280" },
uploading: { name: "arrow-up-circle-outline", color: "#1e40af" }, 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. */ /** Human-readable label for the server-side processing status. */
function serverStatusLabel(s: string): string { function serverStatusLabel(s: string): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
pending: "Queued for processing…", pending: t("upload.status_queued"),
processing: "Processing", processing: t("upload.status_processing"),
completed: "Processed", completed: t("upload.status_completed"),
failed: "Processing failed", failed: t("upload.status_failed"),
duplicate: "Duplicate already processed", duplicate: t("upload.status_duplicate"),
}; };
return labels[s] ?? s; return labels[s] ?? s;
} }
@@ -422,9 +424,9 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
function handleLongPress() { function handleLongPress() {
if (!canRetry) return; if (!canRetry) return;
Alert.alert("Retry Upload", `Do you want to retry uploading "${item.filename}"?`, [ Alert.alert(t("upload.retry_title"), t("upload.retry_msg", { filename: item.filename }), [
{ text: "Cancel", style: "cancel" }, { text: t("common.cancel"), style: "cancel" },
{ text: "Retry", onPress: () => onRetry(item) }, { 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} onPress={canRetry ? () => onRetry(item) : undefined}
style={rowStyles.row} style={rowStyles.row}
accessibilityRole={canRetry ? "button" : "none"} 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} 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} /> <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" /> <ActivityIndicator size="small" color="#1e40af" />
)} )}
{item.status === "done" && !item.serverStatus && ( {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 && ( {item.status === "done" && item.serverStatus && (
<Text <Text
@@ -465,7 +467,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
<View> <View>
<Text style={rowStyles.statusError}>{item.error}</Text> <Text style={rowStyles.statusError}>{item.error}</Text>
{canRetry && ( {canRetry && (
<Text style={rowStyles.retryHint}>Tap to retry</Text> <Text style={rowStyles.retryHint}>{t("upload.tap_retry")}</Text>
)} )}
</View> </View>
)} )}
+34 -35
View File
@@ -17,27 +17,31 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { useLocale, t } from "../i18n";
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.",
},
];
export default function WelcomeScreen() { export default function WelcomeScreen() {
const router = useRouter(); 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 ( return (
<SafeAreaView style={styles.safe}> <SafeAreaView style={styles.safe}>
<ScrollView <ScrollView
@@ -55,16 +59,13 @@ export default function WelcomeScreen() {
/> />
</View> </View>
<Text style={styles.appName}>DocuElevate</Text> <Text style={styles.appName}>DocuElevate</Text>
<Text style={styles.tagline}>Intelligent Document Processing</Text> <Text style={styles.tagline}>{t("welcome.tagline")}</Text>
<Text style={styles.heroDescription}> <Text style={styles.heroDescription}>{t("welcome.description")}</Text>
Ingest documents, run OCR, extract metadata with AI, and route files
to your cloud storage all in one seamless pipeline.
</Text>
</View> </View>
{/* Feature highlights */} {/* Feature highlights */}
<View style={styles.features}> <View style={styles.features}>
{FEATURES.map((feature) => ( {features.map((feature) => (
<View key={feature.title} style={styles.featureRow}> <View key={feature.title} style={styles.featureRow}>
<Text style={styles.featureIcon} aria-hidden={true}>{feature.icon}</Text> <Text style={styles.featureIcon} aria-hidden={true}>{feature.icon}</Text>
<View style={styles.featureText}> <View style={styles.featureText}>
@@ -80,42 +81,40 @@ export default function WelcomeScreen() {
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]} style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
onPress={() => router.push("/(auth)/login")} onPress={() => router.push("/(auth)/login")}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Get started — connect to your DocuElevate server" accessibilityLabel={t("welcome.get_started")}
> >
<Text style={styles.buttonText}>Get Started</Text> <Text style={styles.buttonText}>{t("welcome.get_started")}</Text>
</Pressable> </Pressable>
<Text style={styles.hint}> <Text style={styles.hint}>{t("welcome.hint")}</Text>
Connect to your self-hosted or cloud DocuElevate server.
</Text>
{/* Legal links accessible pre-login for GDPR / Apple compliance */} {/* Legal links accessible pre-login for GDPR / Apple compliance */}
<View style={styles.legalLinks}> <View style={styles.legalLinks}>
<Pressable <Pressable
onPress={() => Linking.openURL("https://app.docuelevate.org/privacy")} onPress={() => Linking.openURL("https://app.docuelevate.org/privacy")}
accessibilityRole="link" accessibilityRole="link"
accessibilityLabel="Privacy Policy" accessibilityLabel={t("legal.privacy_policy")}
style={styles.legalLinkButton} style={styles.legalLinkButton}
> >
<Text style={styles.legalLinkText}>Privacy Policy</Text> <Text style={styles.legalLinkText}>{t("legal.privacy_policy")}</Text>
</Pressable> </Pressable>
<Text style={styles.legalSeparator}>·</Text> <Text style={styles.legalSeparator}>·</Text>
<Pressable <Pressable
onPress={() => Linking.openURL("https://app.docuelevate.org/terms")} onPress={() => Linking.openURL("https://app.docuelevate.org/terms")}
accessibilityRole="link" accessibilityRole="link"
accessibilityLabel="Terms of Service" accessibilityLabel={t("legal.terms")}
style={styles.legalLinkButton} style={styles.legalLinkButton}
> >
<Text style={styles.legalLinkText}>Terms</Text> <Text style={styles.legalLinkText}>{t("legal.terms")}</Text>
</Pressable> </Pressable>
<Text style={styles.legalSeparator}>·</Text> <Text style={styles.legalSeparator}>·</Text>
<Pressable <Pressable
onPress={() => Linking.openURL("https://app.docuelevate.org/imprint")} onPress={() => Linking.openURL("https://app.docuelevate.org/imprint")}
accessibilityRole="link" accessibilityRole="link"
accessibilityLabel="Imprint" accessibilityLabel={t("legal.imprint")}
style={styles.legalLinkButton} style={styles.legalLinkButton}
> >
<Text style={styles.legalLinkText}>Imprint</Text> <Text style={styles.legalLinkText}>{t("legal.imprint")}</Text>
</Pressable> </Pressable>
</View> </View>
</ScrollView> </ScrollView>
+6
View File
@@ -26,6 +26,7 @@ export interface WhoAmIResponse {
email: string | null; email: string | null;
avatar_url: string | null; avatar_url: string | null;
is_admin: boolean; is_admin: boolean;
preferred_language: string | null;
} }
export interface GenerateTokenResponse { export interface GenerateTokenResponse {
@@ -209,6 +210,11 @@ class DocuElevateAPI {
return this.request<WhoAmIResponse>("GET", "/api/mobile/whoami"); return this.request<WhoAmIResponse>("GET", "/api/mobile/whoami");
} }
/** Sync the user's preferred UI language to the server. */
async setServerLanguage(lang: string): Promise<void> {
await this.request("POST", "/api/i18n/language", { body: { language: lang } });
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Push notifications // Push notifications
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
+36
View File
@@ -467,6 +467,42 @@ class TestWhoAmI:
assert data["email"] == _OWNER assert data["email"] == _OWNER
assert data["avatar_url"] is not None # Gravatar URL assert data["avatar_url"] is not None # Gravatar URL
assert data["is_admin"] is False 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: finally:
_cleanup(app) _cleanup(app)