3b5ca04ebc
- 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>
339 lines
9.6 KiB
TypeScript
339 lines
9.6 KiB
TypeScript
/**
|
||
* 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",
|
||
},
|
||
});
|