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:
@@ -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<string | null>(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 (
|
||||
<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}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
<Text style={styles.retryText}>{t("common.retry")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<Text style={styles.backButtonText}>← Back</Text>
|
||||
<Text style={styles.backButtonText}>{t("common.back")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -152,10 +155,10 @@ export default function FileDetailScreen() {
|
||||
style={styles.backRow}
|
||||
onPress={() => router.back()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Go back"
|
||||
accessibilityLabel={t("file_detail.back")}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* File info card */}
|
||||
@@ -178,20 +181,20 @@ export default function FileDetailScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.metaGrid}>
|
||||
<MetaRow label="File Size" value={formatBytes(file.file_size)} />
|
||||
<MetaRow label="MIME Type" value={file.mime_type ?? "–"} />
|
||||
<MetaRow label="Uploaded" value={formatDateTime(file.created_at)} />
|
||||
<MetaRow label="File Hash" value={file.filehash ? `${file.filehash.slice(0, 24)}…` : "–"} />
|
||||
<MetaRow label="Last Step" value={status.last_step ?? "–"} />
|
||||
<MetaRow label="Total Steps" value={String(status.total_steps)} />
|
||||
<MetaRow label={t("file_detail.file_size")} value={formatBytes(file.file_size)} />
|
||||
<MetaRow label={t("file_detail.mime_type")} value={file.mime_type ?? "–"} />
|
||||
<MetaRow label={t("file_detail.uploaded")} value={formatDateTime(file.created_at)} />
|
||||
<MetaRow label={t("file_detail.file_hash")} value={file.filehash ? `${file.filehash.slice(0, 24)}…` : "–"} />
|
||||
<MetaRow label={t("file_detail.last_step")} value={status.last_step ?? "–"} />
|
||||
<MetaRow label={t("file_detail.total_steps")} value={String(status.total_steps)} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Processing logs */}
|
||||
<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 ? (
|
||||
<Text style={styles.emptyLog}>No processing logs yet.</Text>
|
||||
<Text style={styles.emptyLog}>{t("file_detail.no_logs")}</Text>
|
||||
) : (
|
||||
detail.logs.map((log, idx) => {
|
||||
const icon = logStepIcon(log.status);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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() {
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<Pressable style={styles.retryButton} onPress={handleRefresh}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
<Text style={styles.retryText}>{t("common.retry")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -163,21 +166,21 @@ export default function FilesScreen() {
|
||||
<Ionicons name="search-outline" size={18} color="#9ca3af" style={styles.searchIcon} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search documents…"
|
||||
placeholder={t("files.search_placeholder")}
|
||||
placeholderTextColor="#9ca3af"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
accessibilityLabel="Search documents"
|
||||
accessibilityLabel={t("common.search")}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable
|
||||
onPress={handleClearSearch}
|
||||
style={styles.clearButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Clear search"
|
||||
accessibilityLabel={t("common.clear_search")}
|
||||
>
|
||||
<Ionicons name="close-circle" size={18} color="#9ca3af" />
|
||||
</Pressable>
|
||||
@@ -199,12 +202,10 @@ export default function FilesScreen() {
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="folder-open-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
|
||||
<Text style={styles.emptyText}>
|
||||
{searchQuery ? "No documents match your search." : "No documents yet."}
|
||||
{searchQuery ? t("files.search_empty") : t("files.empty_title")}
|
||||
</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
{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")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
<Text style={styles.logoText}>DocuElevate</Text>
|
||||
</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
|
||||
style={styles.input}
|
||||
placeholder="https://your-docuelevate-server.com"
|
||||
placeholder={t("login.server_url_placeholder")}
|
||||
placeholderTextColor="#9ca3af"
|
||||
value={serverUrl}
|
||||
onChangeText={setServerUrl}
|
||||
@@ -118,7 +121,7 @@ export default function LoginScreen() {
|
||||
keyboardType="url"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={handleSignIn}
|
||||
accessibilityLabel="Server URL"
|
||||
accessibilityLabel={t("login.server_url")}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
@@ -126,18 +129,18 @@ export default function LoginScreen() {
|
||||
onPress={handleSignIn}
|
||||
disabled={loading || qrLoading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Sign in with SSO"
|
||||
accessibilityLabel={t("login.sign_in_sso")}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign in with SSO</Text>
|
||||
<Text style={styles.buttonText}>{t("login.sign_in_sso")}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.dividerRow}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Text style={styles.dividerText}>or</Text>
|
||||
<Text style={styles.dividerText}>{t("login.or")}</Text>
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
@@ -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 ? (
|
||||
<ActivityIndicator color="#1e40af" />
|
||||
) : (
|
||||
<Text style={styles.qrButtonText}>📱 Scan QR Code to Login</Text>
|
||||
<Text style={styles.qrButtonText}>{t("login.scan_qr")}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.hint}>
|
||||
Sign in via SSO or scan a QR code from the web app.
|
||||
</Text>
|
||||
<Text style={styles.hint}>{t("login.hint")}</Text>
|
||||
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back to welcome screen"
|
||||
accessibilityLabel={t("login.back")}
|
||||
style={styles.backLink}
|
||||
>
|
||||
<Text style={styles.backLinkText}>← Back</Text>
|
||||
<Text style={styles.backLinkText}>{t("login.back")}</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* 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}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Privacy Policy</Text>
|
||||
<Text style={styles.legalLinkText}>{t("legal.privacy_policy")}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.legalSeparator}>·</Text>
|
||||
<Pressable
|
||||
@@ -190,10 +191,10 @@ export default function LoginScreen() {
|
||||
Linking.openURL(`${base.replace(/\/$/, "")}/terms`);
|
||||
}}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Terms of Service"
|
||||
accessibilityLabel={t("legal.terms")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Terms</Text>
|
||||
<Text style={styles.legalLinkText}>{t("legal.terms")}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.legalSeparator}>·</Text>
|
||||
<Pressable
|
||||
@@ -202,10 +203,10 @@ export default function LoginScreen() {
|
||||
Linking.openURL(`${base.replace(/\/$/, "")}/imprint`);
|
||||
}}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Imprint"
|
||||
accessibilityLabel={t("legal.imprint")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Imprint</Text>
|
||||
<Text style={styles.legalLinkText}>{t("legal.imprint")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import Constants from "expo-constants";
|
||||
import * as Linking from "expo-linking";
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
@@ -15,23 +15,33 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { getLanguage, getSupportedLanguages, setLanguage } from "../i18n";
|
||||
import { useLocale, getSupportedLanguages, t } from "../i18n";
|
||||
import api from "../services/api";
|
||||
|
||||
const DEFAULT_SERVER_URL = "https://app.docuelevate.org";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user, signOut, baseUrl } = useAuth();
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(getLanguage());
|
||||
const { lang, setLang } = useLocale();
|
||||
|
||||
const effectiveBaseUrl = baseUrl || DEFAULT_SERVER_URL;
|
||||
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
|
||||
const languages = getSupportedLanguages();
|
||||
|
||||
async function handleLanguageSelect(code: string) {
|
||||
await setLang(code);
|
||||
// Fire-and-forget: sync the choice to the server so it persists across
|
||||
// platforms (desktop web will reflect this preference too).
|
||||
api.setServerLanguage(code).catch(() => {
|
||||
// Network errors are non-critical – the local change is already applied.
|
||||
});
|
||||
}
|
||||
|
||||
function handleSignOut() {
|
||||
Alert.alert("Sign out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
Alert.alert(t("profile.sign_out_title"), t("profile.sign_out_msg"), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: "Sign out",
|
||||
text: t("profile.sign_out"),
|
||||
style: "destructive",
|
||||
onPress: signOut,
|
||||
},
|
||||
@@ -40,16 +50,16 @@ export default function ProfileScreen() {
|
||||
|
||||
function handleDeleteAccount() {
|
||||
Alert.alert(
|
||||
"Delete Account",
|
||||
"This will permanently delete your account and all associated data. This action cannot be undone.",
|
||||
t("profile.delete_account_title"),
|
||||
t("profile.delete_account_msg"),
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: "Delete Account",
|
||||
text: t("profile.delete_account"),
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
Linking.openURL(`${effectiveBaseUrl}/account/delete`).catch(() => {
|
||||
Alert.alert("Error", "Could not open the account deletion page. Please try again.");
|
||||
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.delete_account") }));
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -59,26 +69,26 @@ export default function ProfileScreen() {
|
||||
|
||||
function openPrivacyPolicy() {
|
||||
Linking.openURL(`${effectiveBaseUrl}/privacy`).catch(() => {
|
||||
Alert.alert("Error", "Could not open the privacy policy. Please try again.");
|
||||
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.privacy_policy") }));
|
||||
});
|
||||
}
|
||||
|
||||
function openTermsOfService() {
|
||||
Linking.openURL(`${effectiveBaseUrl}/terms`).catch(() => {
|
||||
Alert.alert("Error", "Could not open the terms of service. Please try again.");
|
||||
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.terms_of_service") }));
|
||||
});
|
||||
}
|
||||
|
||||
function openImprint() {
|
||||
Linking.openURL(`${effectiveBaseUrl}/imprint`).catch(() => {
|
||||
Alert.alert("Error", "Could not open the imprint page. Please try again.");
|
||||
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.imprint") }));
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.emptyText}>Not signed in</Text>
|
||||
<Text style={styles.emptyText}>{t("profile.not_signed_in")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -102,20 +112,20 @@ export default function ProfileScreen() {
|
||||
)}
|
||||
<Text style={styles.displayName}>{user.display_name ?? user.owner_id}</Text>
|
||||
{user.email && <Text style={styles.email}>{user.email}</Text>}
|
||||
{user.is_admin && <Text style={styles.adminBadge}>Admin</Text>}
|
||||
{user.is_admin && <Text style={styles.adminBadge}>{t("profile.admin")}</Text>}
|
||||
</View>
|
||||
|
||||
{/* Server info */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Connection</Text>
|
||||
<Text style={styles.sectionTitle}>{t("profile.connection")}</Text>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Server</Text>
|
||||
<Text style={styles.rowLabel}>{t("profile.server")}</Text>
|
||||
<Text style={styles.rowValue} numberOfLines={1}>
|
||||
{effectiveBaseUrl}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>User ID</Text>
|
||||
<Text style={styles.rowLabel}>{t("profile.user_id")}</Text>
|
||||
<Text style={styles.rowValue} numberOfLines={1}>
|
||||
{user.owner_id}
|
||||
</Text>
|
||||
@@ -124,31 +134,28 @@ export default function ProfileScreen() {
|
||||
|
||||
{/* Settings */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Settings</Text>
|
||||
<Text style={styles.settingLabel}>Language</Text>
|
||||
<Text style={styles.sectionTitle}>{t("profile.settings")}</Text>
|
||||
<Text style={styles.settingLabel}>{t("profile.language")}</Text>
|
||||
<View style={styles.languageGrid}>
|
||||
{languages.map((lang) => (
|
||||
{languages.map((l) => (
|
||||
<Pressable
|
||||
key={lang.code}
|
||||
key={l.code}
|
||||
style={[
|
||||
styles.languageChip,
|
||||
selectedLanguage === lang.code && styles.languageChipActive,
|
||||
lang === l.code && styles.languageChipActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
setLanguage(lang.code);
|
||||
setSelectedLanguage(lang.code);
|
||||
}}
|
||||
onPress={() => handleLanguageSelect(l.code)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Set language to ${lang.label}`}
|
||||
accessibilityState={{ selected: selectedLanguage === lang.code }}
|
||||
accessibilityLabel={`Set language to ${l.label}`}
|
||||
accessibilityState={{ selected: lang === l.code }}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.languageChipText,
|
||||
selectedLanguage === lang.code && styles.languageChipTextActive,
|
||||
lang === l.code && styles.languageChipTextActive,
|
||||
]}
|
||||
>
|
||||
{lang.label}
|
||||
{l.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
@@ -157,32 +164,32 @@ export default function ProfileScreen() {
|
||||
|
||||
{/* Legal & Privacy */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Legal</Text>
|
||||
<Text style={styles.sectionTitle}>{t("profile.legal")}</Text>
|
||||
<Pressable
|
||||
style={styles.linkRow}
|
||||
onPress={openPrivacyPolicy}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Privacy Policy"
|
||||
accessibilityLabel={t("profile.privacy_policy")}
|
||||
>
|
||||
<Text style={styles.linkText}>Privacy Policy</Text>
|
||||
<Text style={styles.linkText}>{t("profile.privacy_policy")}</Text>
|
||||
<Text style={styles.linkChevron}>›</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.linkRow}
|
||||
onPress={openTermsOfService}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Terms of Service"
|
||||
accessibilityLabel={t("profile.terms_of_service")}
|
||||
>
|
||||
<Text style={styles.linkText}>Terms of Service</Text>
|
||||
<Text style={styles.linkText}>{t("profile.terms_of_service")}</Text>
|
||||
<Text style={styles.linkChevron}>›</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.linkRow, styles.linkRowLast]}
|
||||
onPress={openImprint}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Imprint"
|
||||
accessibilityLabel={t("profile.imprint")}
|
||||
>
|
||||
<Text style={styles.linkText}>Imprint</Text>
|
||||
<Text style={styles.linkText}>{t("profile.imprint")}</Text>
|
||||
<Text style={styles.linkChevron}>›</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -193,9 +200,9 @@ export default function ProfileScreen() {
|
||||
style={styles.signOutButton}
|
||||
onPress={handleSignOut}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Sign out"
|
||||
accessibilityLabel={t("profile.sign_out")}
|
||||
>
|
||||
<Text style={styles.signOutText}>Sign out</Text>
|
||||
<Text style={styles.signOutText}>{t("profile.sign_out")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -205,9 +212,9 @@ export default function ProfileScreen() {
|
||||
style={styles.deleteAccountButton}
|
||||
onPress={handleDeleteAccount}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Delete account"
|
||||
accessibilityLabel={t("profile.delete_account")}
|
||||
>
|
||||
<Text style={styles.deleteAccountText}>Delete Account</Text>
|
||||
<Text style={styles.deleteAccountText}>{t("profile.delete_account")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<ScrollView
|
||||
@@ -55,16 +59,13 @@ export default function WelcomeScreen() {
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.appName}>DocuElevate</Text>
|
||||
<Text style={styles.tagline}>Intelligent Document Processing</Text>
|
||||
<Text style={styles.heroDescription}>
|
||||
Ingest documents, run OCR, extract metadata with AI, and route files
|
||||
to your cloud storage — all in one seamless pipeline.
|
||||
</Text>
|
||||
<Text style={styles.tagline}>{t("welcome.tagline")}</Text>
|
||||
<Text style={styles.heroDescription}>{t("welcome.description")}</Text>
|
||||
</View>
|
||||
|
||||
{/* Feature highlights */}
|
||||
<View style={styles.features}>
|
||||
{FEATURES.map((feature) => (
|
||||
{features.map((feature) => (
|
||||
<View key={feature.title} style={styles.featureRow}>
|
||||
<Text style={styles.featureIcon} aria-hidden={true}>{feature.icon}</Text>
|
||||
<View style={styles.featureText}>
|
||||
@@ -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")}
|
||||
>
|
||||
<Text style={styles.buttonText}>Get Started</Text>
|
||||
<Text style={styles.buttonText}>{t("welcome.get_started")}</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.hint}>
|
||||
Connect to your self-hosted or cloud DocuElevate server.
|
||||
</Text>
|
||||
<Text style={styles.hint}>{t("welcome.hint")}</Text>
|
||||
|
||||
{/* Legal links – accessible pre-login for GDPR / Apple compliance */}
|
||||
<View style={styles.legalLinks}>
|
||||
<Pressable
|
||||
onPress={() => Linking.openURL("https://app.docuelevate.org/privacy")}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Privacy Policy"
|
||||
accessibilityLabel={t("legal.privacy_policy")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Privacy Policy</Text>
|
||||
<Text style={styles.legalLinkText}>{t("legal.privacy_policy")}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.legalSeparator}>·</Text>
|
||||
<Pressable
|
||||
onPress={() => Linking.openURL("https://app.docuelevate.org/terms")}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Terms of Service"
|
||||
accessibilityLabel={t("legal.terms")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Terms</Text>
|
||||
<Text style={styles.legalLinkText}>{t("legal.terms")}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.legalSeparator}>·</Text>
|
||||
<Pressable
|
||||
onPress={() => Linking.openURL("https://app.docuelevate.org/imprint")}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Imprint"
|
||||
accessibilityLabel={t("legal.imprint")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Imprint</Text>
|
||||
<Text style={styles.legalLinkText}>{t("legal.imprint")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
Reference in New Issue
Block a user