Files
gh-christianlouis-docuelevate/mobile/src/screens/FilesScreen.tsx
T
copilot-swe-agent[bot] 3b5ca04ebc 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>
2026-03-20 09:08:08 +00:00

339 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* FilesScreen list of documents processed by DocuElevate with search.
*/
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
StyleSheet,
Text,
TextInput,
View,
} 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 "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return iso;
}
}
function statusIcon(status: string): { name: keyof typeof Ionicons.glyphMap; color: string } {
const map: Record<string, { name: keyof typeof Ionicons.glyphMap; color: string }> = {
completed: { name: "checkmark-circle", color: "#059669" },
processing: { name: "sync-circle", color: "#d97706" },
pending: { name: "time-outline", color: "#6b7280" },
failed: { name: "close-circle", color: "#dc2626" },
duplicate: { name: "copy-outline", color: "#6b7280" },
};
return map[status?.toLowerCase()] ?? { name: "document-outline", color: "#6b7280" };
}
export default function FilesScreen() {
const router = useRouter();
const [files, setFiles] = useState<FileRecord[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
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) => {
try {
const data = await api.listFiles(pageNum, 20, search || undefined);
if (replace) {
setFiles(data);
} else {
setFiles((prev) => [...prev, ...data]);
}
setHasMore(data.length === 20);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load files");
}
},
[]
);
useEffect(() => {
(async () => {
setLoading(true);
await fetchFiles(1, true);
setLoading(false);
})();
}, [fetchFiles]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
setPage(1);
await fetchFiles(1, true, searchQuery);
setRefreshing(false);
}, [fetchFiles, searchQuery]);
const handleLoadMore = useCallback(async () => {
if (!hasMore || loading || refreshing) return;
const next = page + 1;
setPage(next);
await fetchFiles(next, false, searchQuery);
}, [fetchFiles, hasMore, loading, page, refreshing, searchQuery]);
const handleSearch = useCallback(
(text: string) => {
setSearchQuery(text);
// Debounce search requests
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(async () => {
setPage(1);
setLoading(true);
try {
await fetchFiles(1, true, text);
} finally {
setLoading(false);
}
}, 400);
},
[fetchFiles]
);
const handleClearSearch = useCallback(async () => {
setSearchQuery("");
setPage(1);
setLoading(true);
try {
await fetchFiles(1, true);
} finally {
setLoading(false);
}
}, [fetchFiles]);
const handleFilePress = useCallback(
(file: FileRecord) => {
router.push({ pathname: "/(tabs)/file-detail", params: { id: String(file.id) } });
},
[router]
);
if (loading && files.length === 0) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#1e40af" />
</View>
);
}
if (error && files.length === 0) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={handleRefresh}>
<Text style={styles.retryText}>{t("common.retry")}</Text>
</Pressable>
</View>
);
}
return (
<View style={styles.container}>
{/* Search bar */}
<View style={styles.searchContainer}>
<Ionicons name="search-outline" size={18} color="#9ca3af" style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder={t("files.search_placeholder")}
placeholderTextColor="#9ca3af"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
accessibilityLabel={t("common.search")}
/>
{searchQuery.length > 0 && (
<Pressable
onPress={handleClearSearch}
style={styles.clearButton}
accessibilityRole="button"
accessibilityLabel={t("common.clear_search")}
>
<Ionicons name="close-circle" size={18} color="#9ca3af" />
</Pressable>
)}
</View>
<FlatList
style={styles.list}
data={files}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => <FileRow file={item} onPress={handleFilePress} />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.4}
ListEmptyComponent={
<View style={styles.emptyState}>
<Ionicons name="folder-open-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
<Text style={styles.emptyText}>
{searchQuery ? t("files.search_empty") : t("files.empty_title")}
</Text>
<Text style={styles.emptyHint}>
{searchQuery ? t("files.search_empty_hint") : t("files.empty_hint")}
</Text>
</View>
}
ListFooterComponent={
hasMore && files.length > 0 ? (
<ActivityIndicator color="#1e40af" style={{ marginVertical: 16 }} />
) : null
}
/>
</View>
);
}
function FileRow({ file, onPress }: { file: FileRecord; onPress: (file: FileRecord) => void }) {
const status = file.processing_status?.status ?? "pending";
const icon = statusIcon(status);
return (
<Pressable
style={rowStyles.row}
onPress={() => onPress(file)}
accessibilityRole="button"
accessibilityLabel={`View details for ${file.original_filename}`}
>
<Ionicons name={icon.name} size={22} color={icon.color} style={rowStyles.icon} />
<View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}>
{file.original_filename}
</Text>
<Text style={rowStyles.meta}>
{formatDate(file.created_at)} · {formatBytes(file.file_size)}
</Text>
</View>
<View style={rowStyles.right}>
<Text style={rowStyles.status}>{status}</Text>
<Ionicons name="chevron-forward" size={16} color="#d1d5db" />
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#f9fafb" },
list: { flex: 1 },
listContent: { padding: 16, paddingTop: 0 },
searchContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff",
marginHorizontal: 16,
marginVertical: 12,
borderRadius: 10,
paddingHorizontal: 12,
borderWidth: 1,
borderColor: "#e5e7eb",
minHeight: 44,
},
searchIcon: { marginRight: 8 },
searchInput: {
flex: 1,
fontSize: 15,
color: "#111827",
paddingVertical: 10,
},
clearButton: {
padding: 4,
minWidth: 44,
minHeight: 44,
alignItems: "center",
justifyContent: "center",
},
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9fafb",
padding: 24,
},
errorText: { color: "#dc2626", fontSize: 15, textAlign: "center", marginBottom: 16 },
retryButton: {
backgroundColor: "#1e40af",
borderRadius: 8,
paddingHorizontal: 24,
paddingVertical: 10,
},
retryText: { color: "#fff", fontWeight: "600" },
emptyState: { alignItems: "center", paddingTop: 60 },
emptyText: { fontSize: 16, color: "#374151", marginBottom: 8 },
emptyHint: {
fontSize: 13,
color: "#6b7280",
textAlign: "center",
paddingHorizontal: 32,
},
});
const rowStyles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff",
borderRadius: 10,
padding: 14,
marginBottom: 10,
shadowColor: "#000",
shadowOpacity: 0.04,
shadowOffset: { width: 0, height: 2 },
shadowRadius: 4,
elevation: 2,
},
icon: { marginRight: 12 },
info: { flex: 1 },
filename: {
fontSize: 14,
fontWeight: "600",
color: "#111827",
marginBottom: 4,
},
meta: { fontSize: 12, color: "#6b7280" },
right: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
status: {
fontSize: 11,
color: "#6b7280",
fontWeight: "500",
textTransform: "capitalize",
},
});