restore(mobile): restore mobile/ directory to pre-d2217531 state
Restored mobile/ from d22175310a711e7ebdd8062ae29a54f0136dc3f6^ (parent commitd94e9ca4bc). Commitd22175310a(google-labs-jules[bot], 2026-03-23T14:45:22Z) introduced an SSRF security fix for IMAP connections but unintentionally deleted or truncated a large number of files across the repository, including 24 files under mobile/. This commit targets only the mobile/ directory and restores the following files to their pre-d2217531 state: - mobile/README.md - mobile/app.json - mobile/app/(tabs)/_layout.tsx - mobile/app/(tabs)/file-detail.tsx (re-added) - mobile/app/+not-found.tsx (re-added) - mobile/app/_layout.tsx - mobile/eslint.config.js (re-added) - mobile/package-lock.json - mobile/package.json - mobile/src/context/ShareContext.tsx - mobile/src/i18n/de.json (re-added) - mobile/src/i18n/en.json (re-added) - mobile/src/i18n/es.json (re-added) - mobile/src/i18n/fr.json (re-added) - mobile/src/i18n/index.ts (re-added) - mobile/src/i18n/it.json (re-added) - mobile/src/screens/FileDetailScreen.tsx (re-added) - mobile/src/screens/FilesScreen.tsx - mobile/src/screens/LoginScreen.tsx - mobile/src/screens/ProfileScreen.tsx - mobile/src/screens/UploadScreen.tsx - mobile/src/screens/WelcomeScreen.tsx - mobile/src/services/api.ts - mobile/src/utils/mimeTypes.ts (re-added) - mobile/src/utils/normalizeUri.ts (re-added) Security fixes introduced byd2217531that are unrelated to mobile/ (IMAP SSRF fix in app/utils/network.py and app/tasks/imap_tasks.py) are preserved — this restore targets only files under mobile/.
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* FileDetailScreen – shows detailed status and processing logs for a single file.
|
||||
*
|
||||
* Replicates the web /files/:id and /files/:id/detail views in a
|
||||
* mobile-friendly layout. Displays file metadata, processing status with
|
||||
* a progress indicator, and a chronological list of processing log entries.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import type { FileDetail } from "../services/api";
|
||||
import api from "../services/api";
|
||||
import { useLocale, t } from "../i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): 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 formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
completed: "#059669",
|
||||
processing: "#d97706",
|
||||
pending: "#6b7280",
|
||||
failed: "#dc2626",
|
||||
duplicate: "#6b7280",
|
||||
};
|
||||
return colors[status?.toLowerCase()] ?? "#6b7280";
|
||||
}
|
||||
|
||||
function statusIcon(status: string): keyof typeof Ionicons.glyphMap {
|
||||
const icons: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
completed: "checkmark-circle",
|
||||
processing: "sync-circle",
|
||||
pending: "time-outline",
|
||||
failed: "close-circle",
|
||||
duplicate: "copy-outline",
|
||||
};
|
||||
return icons[status?.toLowerCase()] ?? "document-outline";
|
||||
}
|
||||
|
||||
function logStepIcon(status: string): { name: keyof typeof Ionicons.glyphMap; color: string } {
|
||||
const lower = status?.toLowerCase();
|
||||
if (lower === "completed" || lower === "success") return { name: "checkmark-circle", color: "#059669" };
|
||||
if (lower === "failed" || lower === "error") return { name: "close-circle", color: "#dc2626" };
|
||||
if (lower === "skipped") return { name: "remove-circle-outline", color: "#9ca3af" };
|
||||
if (lower === "processing" || lower === "running") return { name: "sync-circle", color: "#d97706" };
|
||||
return { name: "ellipse-outline", color: "#6b7280" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function FileDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [detail, setDetail] = useState<FileDetail | null>(null);
|
||||
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);
|
||||
|
||||
const fetchDetail = useCallback(async () => {
|
||||
if (!fileId) return;
|
||||
try {
|
||||
const data = await api.getFileDetail(fileId);
|
||||
setDetail(data);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load file details");
|
||||
}
|
||||
}, [fileId]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
await fetchDetail();
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [fetchDetail]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await fetchDetail();
|
||||
setRefreshing(false);
|
||||
}, [fetchDetail]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator size="large" color="#1e40af" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !detail) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.errorText}>{error ?? t("file_detail.file_not_found")}</Text>
|
||||
<Pressable style={styles.retryButton} onPress={handleRefresh}>
|
||||
<Text style={styles.retryText}>{t("common.retry")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<Text style={styles.backButtonText}>{t("common.back")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const file = detail.file;
|
||||
const status = detail.processing_status;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />}
|
||||
>
|
||||
{/* Header with back button */}
|
||||
<Pressable
|
||||
style={styles.backRow}
|
||||
onPress={() => router.back()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("file_detail.back")}
|
||||
>
|
||||
<Ionicons name="arrow-back" size={20} color="#1e40af" />
|
||||
<Text style={styles.backLabel}>{t("file_detail.back")}</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* File info card */}
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Ionicons
|
||||
name={statusIcon(status.status)}
|
||||
size={28}
|
||||
color={statusColor(status.status)}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.filename} numberOfLines={2}>
|
||||
{file.original_filename}
|
||||
</Text>
|
||||
<Text style={[styles.statusBadge, { color: statusColor(status.status) }]}>
|
||||
{status.status.charAt(0).toUpperCase() + status.status.slice(1)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.metaGrid}>
|
||||
<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}>{t("file_detail.processing_log")}</Text>
|
||||
{detail.logs.length === 0 ? (
|
||||
<Text style={styles.emptyLog}>{t("file_detail.no_logs")}</Text>
|
||||
) : (
|
||||
detail.logs.map((log, idx) => {
|
||||
const icon = logStepIcon(log.status);
|
||||
const isLast = idx === detail.logs.length - 1;
|
||||
return (
|
||||
<View key={log.id} style={[styles.logEntry, !isLast && styles.logEntryBorder]}>
|
||||
<Ionicons name={icon.name} size={18} color={icon.color} style={styles.logIcon} />
|
||||
<View style={styles.logContent}>
|
||||
<Text style={styles.logStep}>{log.step_name}</Text>
|
||||
<Text style={styles.logMessage} numberOfLines={3}>
|
||||
{log.message}
|
||||
</Text>
|
||||
<Text style={styles.logTimestamp}>{formatDateTime(log.timestamp)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MetaRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>{label}</Text>
|
||||
<Text style={styles.metaValue} numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1, backgroundColor: "#f9fafb" },
|
||||
content: { padding: 16, paddingBottom: 40 },
|
||||
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,
|
||||
marginBottom: 12,
|
||||
},
|
||||
retryText: { color: "#fff", fontWeight: "600" },
|
||||
backButton: { paddingVertical: 10 },
|
||||
backButtonText: { color: "#6b7280", fontSize: 14 },
|
||||
backRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
minHeight: 44,
|
||||
},
|
||||
backLabel: {
|
||||
fontSize: 15,
|
||||
color: "#1e40af",
|
||||
fontWeight: "600",
|
||||
marginLeft: 6,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.04,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 6,
|
||||
elevation: 2,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 16,
|
||||
},
|
||||
filename: {
|
||||
fontSize: 17,
|
||||
fontWeight: "700",
|
||||
color: "#111827",
|
||||
marginBottom: 4,
|
||||
},
|
||||
statusBadge: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
metaGrid: {},
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f3f4f6",
|
||||
},
|
||||
metaLabel: { fontSize: 13, color: "#6b7280", fontWeight: "500" },
|
||||
metaValue: { fontSize: 13, color: "#374151", maxWidth: "55%", textAlign: "right" },
|
||||
sectionTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
color: "#374151",
|
||||
marginBottom: 12,
|
||||
},
|
||||
emptyLog: { fontSize: 13, color: "#9ca3af", fontStyle: "italic" },
|
||||
logEntry: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
paddingVertical: 10,
|
||||
},
|
||||
logEntryBorder: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f3f4f6",
|
||||
},
|
||||
logIcon: { marginRight: 10, marginTop: 1 },
|
||||
logContent: { flex: 1 },
|
||||
logStep: { fontSize: 13, fontWeight: "600", color: "#374151", marginBottom: 2 },
|
||||
logMessage: { fontSize: 12, color: "#6b7280", lineHeight: 17, marginBottom: 2 },
|
||||
logTimestamp: { fontSize: 11, color: "#9ca3af" },
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* FilesScreen – list of documents processed by DocuElevate.
|
||||
* FilesScreen – list of documents processed by DocuElevate with search.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
@@ -10,10 +12,12 @@ import {
|
||||
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 "–";
|
||||
@@ -34,29 +38,34 @@ function formatDate(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function statusEmoji(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
completed: "✅",
|
||||
processing: "⚙️",
|
||||
pending: "⏳",
|
||||
failed: "❌",
|
||||
duplicate: "🔁",
|
||||
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()] ?? "📄";
|
||||
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) => {
|
||||
async (pageNum: number, replace: boolean, search?: string) => {
|
||||
try {
|
||||
const data = await api.listFiles(pageNum, 20);
|
||||
const data = await api.listFiles(pageNum, 20, search || undefined);
|
||||
if (replace) {
|
||||
setFiles(data);
|
||||
} else {
|
||||
@@ -82,18 +91,56 @@ export default function FilesScreen() {
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
setPage(1);
|
||||
await fetchFiles(1, true);
|
||||
await fetchFiles(1, true, searchQuery);
|
||||
setRefreshing(false);
|
||||
}, [fetchFiles]);
|
||||
}, [fetchFiles, searchQuery]);
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!hasMore || loading || refreshing) return;
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
await fetchFiles(next, false);
|
||||
}, [fetchFiles, hasMore, loading, page, refreshing]);
|
||||
await fetchFiles(next, false, searchQuery);
|
||||
}, [fetchFiles, hasMore, loading, page, refreshing, searchQuery]);
|
||||
|
||||
if (loading) {
|
||||
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" />
|
||||
@@ -101,52 +148,88 @@ export default function FilesScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
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}>Retry</Text>
|
||||
<Text style={styles.retryText}>{t("common.retry")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
style={styles.list}
|
||||
data={files}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) => <FileRow file={item} />}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.4}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyEmoji}>📂</Text>
|
||||
<Text style={styles.emptyText}>No documents yet.</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
Upload a document from the Upload tab to get started.
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
hasMore && files.length > 0 ? (
|
||||
<ActivityIndicator color="#1e40af" style={{ marginVertical: 16 }} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<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 }: { file: FileRecord }) {
|
||||
function FileRow({ file, onPress }: { file: FileRecord; onPress: (file: FileRecord) => void }) {
|
||||
const status = file.processing_status?.status ?? "pending";
|
||||
const icon = statusIcon(status);
|
||||
return (
|
||||
<View style={rowStyles.row}>
|
||||
<Text style={rowStyles.icon}>{statusEmoji(status)}</Text>
|
||||
<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}
|
||||
@@ -155,14 +238,44 @@ function FileRow({ file }: { file: FileRecord }) {
|
||||
{formatDate(file.created_at)} · {formatBytes(file.file_size)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={rowStyles.status}>{status}</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({
|
||||
list: { flex: 1, backgroundColor: "#f9fafb" },
|
||||
listContent: { padding: 16 },
|
||||
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",
|
||||
@@ -179,7 +292,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
retryText: { color: "#fff", fontWeight: "600" },
|
||||
emptyState: { alignItems: "center", paddingTop: 60 },
|
||||
emptyEmoji: { fontSize: 48, marginBottom: 12 },
|
||||
emptyText: { fontSize: 16, color: "#374151", marginBottom: 8 },
|
||||
emptyHint: {
|
||||
fontSize: 13,
|
||||
@@ -203,7 +315,7 @@ const rowStyles = StyleSheet.create({
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
icon: { fontSize: 22, marginRight: 12 },
|
||||
icon: { marginRight: 12 },
|
||||
info: { flex: 1 },
|
||||
filename: {
|
||||
fontSize: 14,
|
||||
@@ -212,6 +324,11 @@ const rowStyles = StyleSheet.create({
|
||||
marginBottom: 4,
|
||||
},
|
||||
meta: { fontSize: 12, color: "#6b7280" },
|
||||
right: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
},
|
||||
status: {
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
|
||||
@@ -24,13 +24,16 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useLocale, t } from "../i18n";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { signIn, signInWithQR } = useAuth();
|
||||
const router = useRouter();
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
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,27 +151,64 @@ 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 */}
|
||||
<View style={styles.legalLinks}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
const base = serverUrl.trim() || "https://app.docuelevate.org";
|
||||
Linking.openURL(`${base.replace(/\/$/, "")}/privacy`);
|
||||
}}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={t("legal.privacy_policy")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>{t("legal.privacy_policy")}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.legalSeparator}>·</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
const base = serverUrl.trim() || "https://app.docuelevate.org";
|
||||
Linking.openURL(`${base.replace(/\/$/, "")}/terms`);
|
||||
}}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={t("legal.terms")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>{t("legal.terms")}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.legalSeparator}>·</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
const base = serverUrl.trim() || "https://app.docuelevate.org";
|
||||
Linking.openURL(`${base.replace(/\/$/, "")}/imprint`);
|
||||
}}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={t("legal.imprint")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>{t("legal.imprint")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
@@ -291,4 +331,26 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
color: "#6b7280",
|
||||
},
|
||||
legalLinks: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginTop: 16,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
legalLinkButton: {
|
||||
minHeight: 44,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
legalLinkText: {
|
||||
fontSize: 12,
|
||||
color: "#9ca3af",
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
legalSeparator: {
|
||||
fontSize: 12,
|
||||
color: "#d1d5db",
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* ProfileScreen – authenticated user profile and settings.
|
||||
*/
|
||||
|
||||
import Constants from "expo-constants";
|
||||
import * as Linking from "expo-linking";
|
||||
import React from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -9,30 +11,84 @@ import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
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 { 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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function handleDeleteAccount() {
|
||||
Alert.alert(
|
||||
t("profile.delete_account_title"),
|
||||
t("profile.delete_account_msg"),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("profile.delete_account"),
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
Linking.openURL(`${effectiveBaseUrl}/account/delete`).catch(() => {
|
||||
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.delete_account") }));
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function openPrivacyPolicy() {
|
||||
Linking.openURL(`${effectiveBaseUrl}/privacy`).catch(() => {
|
||||
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.privacy_policy") }));
|
||||
});
|
||||
}
|
||||
|
||||
function openTermsOfService() {
|
||||
Linking.openURL(`${effectiveBaseUrl}/terms`).catch(() => {
|
||||
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(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>
|
||||
);
|
||||
}
|
||||
@@ -56,44 +112,121 @@ 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}>
|
||||
{baseUrl || "–"}
|
||||
{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>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Danger zone */}
|
||||
{/* Settings */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t("profile.settings")}</Text>
|
||||
<Text style={styles.settingLabel}>{t("profile.language")}</Text>
|
||||
<View style={styles.languageGrid}>
|
||||
{languages.map((l) => (
|
||||
<Pressable
|
||||
key={l.code}
|
||||
style={[
|
||||
styles.languageChip,
|
||||
lang === l.code && styles.languageChipActive,
|
||||
]}
|
||||
onPress={() => handleLanguageSelect(l.code)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Set language to ${l.label}`}
|
||||
accessibilityState={{ selected: lang === l.code }}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.languageChipText,
|
||||
lang === l.code && styles.languageChipTextActive,
|
||||
]}
|
||||
>
|
||||
{l.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Legal & Privacy */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t("profile.legal")}</Text>
|
||||
<Pressable
|
||||
style={styles.linkRow}
|
||||
onPress={openPrivacyPolicy}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={t("profile.privacy_policy")}
|
||||
>
|
||||
<Text style={styles.linkText}>{t("profile.privacy_policy")}</Text>
|
||||
<Text style={styles.linkChevron}>›</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.linkRow}
|
||||
onPress={openTermsOfService}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={t("profile.terms_of_service")}
|
||||
>
|
||||
<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={t("profile.imprint")}
|
||||
>
|
||||
<Text style={styles.linkText}>{t("profile.imprint")}</Text>
|
||||
<Text style={styles.linkChevron}>›</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Sign out */}
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
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>
|
||||
|
||||
{/* Account deletion – Apple Guideline 5.1.1(v) */}
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={styles.deleteAccountButton}
|
||||
onPress={handleDeleteAccount}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("profile.delete_account")}
|
||||
>
|
||||
<Text style={styles.deleteAccountText}>{t("profile.delete_account")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* App version */}
|
||||
<Text style={styles.versionText}>DocuElevate v{appVersion}</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: { flex: 1, backgroundColor: "#f9fafb" },
|
||||
content: { padding: 20 },
|
||||
content: { padding: 20, paddingBottom: 40 },
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
@@ -180,6 +313,27 @@ const styles = StyleSheet.create({
|
||||
maxWidth: "60%",
|
||||
textAlign: "right",
|
||||
},
|
||||
linkRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f3f4f6",
|
||||
minHeight: 44,
|
||||
},
|
||||
linkRowLast: {
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 15,
|
||||
color: "#1e40af",
|
||||
},
|
||||
linkChevron: {
|
||||
fontSize: 18,
|
||||
color: "#9ca3af",
|
||||
fontWeight: "600",
|
||||
},
|
||||
signOutButton: {
|
||||
backgroundColor: "#fee2e2",
|
||||
borderRadius: 10,
|
||||
@@ -192,4 +346,58 @@ const styles = StyleSheet.create({
|
||||
fontWeight: "700",
|
||||
fontSize: 15,
|
||||
},
|
||||
deleteAccountButton: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: "#dc2626",
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
minHeight: 48,
|
||||
},
|
||||
deleteAccountText: {
|
||||
color: "#dc2626",
|
||||
fontWeight: "600",
|
||||
fontSize: 14,
|
||||
},
|
||||
versionText: {
|
||||
fontSize: 12,
|
||||
color: "#9ca3af",
|
||||
textAlign: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
settingLabel: {
|
||||
fontSize: 14,
|
||||
color: "#374151",
|
||||
fontWeight: "500",
|
||||
marginBottom: 10,
|
||||
},
|
||||
languageGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
},
|
||||
languageChip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "#f3f4f6",
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e7eb",
|
||||
minHeight: 36,
|
||||
justifyContent: "center",
|
||||
},
|
||||
languageChipActive: {
|
||||
backgroundColor: "#dbeafe",
|
||||
borderColor: "#1e40af",
|
||||
},
|
||||
languageChipText: {
|
||||
fontSize: 13,
|
||||
color: "#6b7280",
|
||||
fontWeight: "500",
|
||||
},
|
||||
languageChipTextActive: {
|
||||
color: "#1e40af",
|
||||
fontWeight: "700",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
* track the real-time processing status of each uploaded file.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as DocumentPicker from "expo-document-picker";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
@@ -26,7 +28,9 @@ import {
|
||||
} from "react-native";
|
||||
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"]);
|
||||
@@ -53,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.
|
||||
@@ -61,30 +67,102 @@ export default function UploadScreen() {
|
||||
uploadsRef.current = uploads;
|
||||
}, [uploads]);
|
||||
|
||||
// Track URIs that have already been uploaded in this session so that
|
||||
// duplicate share-sheet deliveries (iOS can fire both the Linking handler
|
||||
// and +not-found.tsx for the same file) do not trigger repeated uploads.
|
||||
const uploadedUrisRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core helpers (declared before the effects that depend on them)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ensure a file URI is accessible for upload.
|
||||
*
|
||||
* Files received via the iOS Share Sheet / "Open In…" may reference paths
|
||||
* outside the app's sandbox or use security-scoped URLs that React Native's
|
||||
* fetch cannot read directly. This helper copies such files to the app's
|
||||
* cache directory so the upload can proceed reliably.
|
||||
*
|
||||
* URIs from expo-image-picker and expo-document-picker are already in the
|
||||
* app's cache and are returned unchanged.
|
||||
*/
|
||||
const ensureLocalUri = useCallback(async (uri: string, filename: string): Promise<string> => {
|
||||
// Android content:// URIs are handled natively by React Native's fetch.
|
||||
if (!uri.startsWith("file://")) return uri;
|
||||
|
||||
// Files already in the app's cache or documents directory are accessible.
|
||||
const cacheDir = FileSystem.cacheDirectory;
|
||||
const docDir = FileSystem.documentDirectory;
|
||||
if (cacheDir && uri.startsWith(cacheDir)) return uri;
|
||||
if (docDir && uri.startsWith(docDir)) return uri;
|
||||
|
||||
// External file (e.g. from iOS Inbox or security-scoped URL) – copy to
|
||||
// cache so the upload has guaranteed read access.
|
||||
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const destUri = `${cacheDir}shared_${Date.now()}_${safeName}`;
|
||||
try {
|
||||
await FileSystem.copyAsync({ from: uri, to: destUri });
|
||||
return destUri;
|
||||
} catch (copyErr) {
|
||||
// Copy failed – fall back to the original URI (might work for some paths).
|
||||
console.warn("[ensureLocalUri] copyAsync failed:", { from: uri, to: destUri, error: copyErr });
|
||||
return uri;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadFile = useCallback(async (uri: string, filename: string, mimeType?: string) => {
|
||||
const id = `${Date.now()}-${filename}`;
|
||||
// Deduplicate: skip if this exact URI was already uploaded in this session.
|
||||
// This guards against duplicate share-sheet deliveries from iOS where the
|
||||
// Linking handler and +not-found.tsx fire for the same file.
|
||||
const normUri = normalizeFileUri(uri);
|
||||
if (uploadedUrisRef.current.has(normUri)) {
|
||||
console.debug("[uploadFile] skipping duplicate URI:", uri);
|
||||
return;
|
||||
}
|
||||
uploadedUrisRef.current.add(normUri);
|
||||
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}-${filename}`;
|
||||
setUploads((prev) => [{ id, filename, status: "uploading", uri, mimeType }, ...prev]);
|
||||
|
||||
try {
|
||||
const resp = await api.uploadFile(uri, filename, mimeType);
|
||||
setUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
|
||||
: item
|
||||
)
|
||||
);
|
||||
const localUri = await ensureLocalUri(uri, filename);
|
||||
const resp = await api.uploadFile(localUri, filename, mimeType);
|
||||
if (resp.status === "duplicate" && resp.duplicate_of) {
|
||||
// Server rejected the file as a known duplicate — mark as done and
|
||||
// set the server-side status to "duplicate" so it appears as a
|
||||
// terminal status and is not polled further.
|
||||
setUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
status: "done",
|
||||
fileId: resp.duplicate_of!.original_file_id,
|
||||
originalFilename: resp.original_filename,
|
||||
serverStatus: "duplicate",
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Allow retrying this URI on failure.
|
||||
uploadedUrisRef.current.delete(normUri);
|
||||
const msg = err instanceof Error ? err.message : "Upload failed";
|
||||
setUploads((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item))
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
}, [ensureLocalUri]);
|
||||
|
||||
const retryUpload = useCallback(async (item: UploadItem) => {
|
||||
if (!item.uri) return;
|
||||
@@ -99,21 +177,38 @@ export default function UploadScreen() {
|
||||
);
|
||||
|
||||
try {
|
||||
const resp = await api.uploadFile(item.uri, item.filename, item.mimeType);
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === item.id
|
||||
? { ...u, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
|
||||
: u
|
||||
)
|
||||
);
|
||||
const localUri = await ensureLocalUri(item.uri, item.filename);
|
||||
const resp = await api.uploadFile(localUri, item.filename, item.mimeType);
|
||||
if (resp.status === "duplicate" && resp.duplicate_of) {
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === item.id
|
||||
? {
|
||||
...u,
|
||||
status: "done",
|
||||
fileId: resp.duplicate_of!.original_file_id,
|
||||
originalFilename: resp.original_filename,
|
||||
serverStatus: "duplicate",
|
||||
}
|
||||
: u
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === item.id
|
||||
? { ...u, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
|
||||
: u
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Upload failed";
|
||||
setUploads((prev) =>
|
||||
prev.map((u) => (u.id === item.id ? { ...u, status: "error", error: msg } : u))
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
}, [ensureLocalUri]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Polling – check server-side processing status every 5 seconds
|
||||
@@ -176,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;
|
||||
}
|
||||
@@ -199,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;
|
||||
}
|
||||
@@ -209,14 +304,17 @@ export default function UploadScreen() {
|
||||
mediaTypes: ["images"],
|
||||
quality: 0.9,
|
||||
allowsEditing: false,
|
||||
allowsMultipleSelection: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets.length > 0) {
|
||||
const asset = result.assets[0];
|
||||
// Derive extension from MIME type so the filename matches the actual format
|
||||
const ext = asset.mimeType?.split("/")[1]?.replace("jpeg", "jpg") ?? "jpg";
|
||||
const filename = asset.fileName ?? `photo_${Date.now()}.${ext}`;
|
||||
await uploadFile(asset.uri, filename, asset.mimeType ?? "image/jpeg");
|
||||
for (let i = 0; i < result.assets.length; i++) {
|
||||
const asset = result.assets[i];
|
||||
// Derive extension from MIME type so the filename matches the actual format
|
||||
const ext = asset.mimeType?.split("/")[1]?.replace("jpeg", "jpg") ?? "jpg";
|
||||
const filename = asset.fileName ?? `photo_${Date.now()}_${i}.${ext}`;
|
||||
await uploadFile(asset.uri, filename, asset.mimeType ?? "image/jpeg");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,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>
|
||||
);
|
||||
}
|
||||
@@ -254,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")}
|
||||
>
|
||||
<Text style={styles.actionIcon}>📷</Text>
|
||||
<Text style={styles.actionLabel}>Camera</Text>
|
||||
<Ionicons name="camera-outline" size={28} color="#fff" style={styles.actionIcon} />
|
||||
<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")}
|
||||
>
|
||||
<Text style={styles.actionIcon}>🖼️</Text>
|
||||
<Text style={styles.actionLabel}>Photos</Text>
|
||||
<Ionicons name="images-outline" size={28} color="#fff" style={styles.actionIcon} />
|
||||
<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")}
|
||||
>
|
||||
<Text style={styles.actionIcon}>📄</Text>
|
||||
<Text style={styles.actionLabel}>Files</Text>
|
||||
<Ionicons name="document-outline" size={28} color="#fff" style={styles.actionIcon} />
|
||||
<Text style={styles.actionLabel}>{t("upload.files")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -285,13 +383,9 @@ export default function UploadScreen() {
|
||||
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
|
||||
{uploads.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyEmoji}>☁️</Text>
|
||||
<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>
|
||||
<Ionicons name="cloud-upload-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
|
||||
<Text style={styles.emptyText}>{t("upload.empty_title")}</Text>
|
||||
<Text style={styles.emptyHint}>{t("upload.empty_hint")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
uploads.map((item) => (
|
||||
@@ -304,21 +398,24 @@ export default function UploadScreen() {
|
||||
}
|
||||
|
||||
function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) {
|
||||
const uploadIcons: Record<UploadItem["status"], string> = {
|
||||
pending: "⏳",
|
||||
uploading: "⬆️",
|
||||
done: "✅",
|
||||
error: "❌",
|
||||
// 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" },
|
||||
done: { name: "checkmark-circle", color: "#059669" },
|
||||
error: { name: "close-circle", color: "#dc2626" },
|
||||
};
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -327,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) },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -339,10 +436,10 @@ 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}
|
||||
>
|
||||
<Text style={rowStyles.icon}>{uploadIcons[item.status]}</Text>
|
||||
<Ionicons name={uploadIconProps[item.status].name} size={22} color={uploadIconProps[item.status].color} style={rowStyles.icon} />
|
||||
<View style={rowStyles.info}>
|
||||
<Text style={rowStyles.filename} numberOfLines={1}>
|
||||
{item.filename}
|
||||
@@ -351,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
|
||||
@@ -370,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>
|
||||
)}
|
||||
@@ -397,7 +494,7 @@ const styles = StyleSheet.create({
|
||||
cameraButton: { backgroundColor: "#1e40af" },
|
||||
photoLibraryButton: { backgroundColor: "#7c3aed" },
|
||||
fileButton: { backgroundColor: "#059669" },
|
||||
actionIcon: { fontSize: 28, marginBottom: 6 },
|
||||
actionIcon: { marginBottom: 6 },
|
||||
actionLabel: {
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
@@ -409,7 +506,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
paddingTop: 60,
|
||||
},
|
||||
emptyEmoji: { fontSize: 48, marginBottom: 12 },
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: "#374151",
|
||||
@@ -443,7 +539,7 @@ const rowStyles = StyleSheet.create({
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
icon: { fontSize: 22, marginRight: 12 },
|
||||
icon: { marginRight: 12 },
|
||||
info: { flex: 1 },
|
||||
filename: {
|
||||
fontSize: 14,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useRouter } from "expo-router";
|
||||
import * as Linking from "expo-linking";
|
||||
import React from "react";
|
||||
import {
|
||||
Image,
|
||||
@@ -16,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
|
||||
@@ -54,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}>
|
||||
@@ -79,14 +81,42 @@ 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={t("legal.privacy_policy")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<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={t("legal.terms")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<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={t("legal.imprint")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>{t("legal.imprint")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -206,4 +236,26 @@ const styles = StyleSheet.create({
|
||||
color: "rgba(255,255,255,0.55)",
|
||||
textAlign: "center",
|
||||
},
|
||||
legalLinks: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginTop: 20,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
legalLinkButton: {
|
||||
minHeight: 44,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
legalLinkText: {
|
||||
fontSize: 12,
|
||||
color: "rgba(255,255,255,0.65)",
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
legalSeparator: {
|
||||
fontSize: 12,
|
||||
color: "rgba(255,255,255,0.45)",
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user