From aca12858c126febccc3524ed6dd73d30e61f48f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:10:30 +0000 Subject: [PATCH 1/2] Initial plan From 2f3c22000cfb861f1ee24a46fce9e8fffffd85db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:12:05 +0000 Subject: [PATCH 2/2] restore(mobile): restore mobile/ directory to pre-d2217531 state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restored mobile/ from d22175310a711e7ebdd8062ae29a54f0136dc3f6^ (parent commit d94e9ca4bc536c668e9b10770ae7fbb9b0d42a40). Commit d22175310a7 (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 by d2217531 that 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/. --- mobile/README.md | 14 + mobile/app.json | 28 +- mobile/app/(tabs)/_layout.tsx | 30 +- mobile/app/(tabs)/file-detail.tsx | 4 + mobile/app/+not-found.tsx | 154 +++++++ mobile/app/_layout.tsx | 64 ++- mobile/eslint.config.js | 1 + mobile/package-lock.json | 559 ++++++------------------ mobile/package.json | 5 +- mobile/src/context/ShareContext.tsx | 9 +- mobile/src/i18n/de.json | 116 +++++ mobile/src/i18n/en.json | 116 +++++ mobile/src/i18n/es.json | 116 +++++ mobile/src/i18n/fr.json | 116 +++++ mobile/src/i18n/index.ts | 208 +++++++++ mobile/src/i18n/it.json | 116 +++++ mobile/src/screens/FileDetailScreen.tsx | 332 ++++++++++++++ mobile/src/screens/FilesScreen.tsx | 225 +++++++--- mobile/src/screens/LoginScreen.tsx | 104 ++++- mobile/src/screens/ProfileScreen.tsx | 236 +++++++++- mobile/src/screens/UploadScreen.tsx | 228 +++++++--- mobile/src/screens/WelcomeScreen.tsx | 110 +++-- mobile/src/services/api.ts | 45 +- mobile/src/utils/mimeTypes.ts | 44 ++ mobile/src/utils/normalizeUri.ts | 20 + 25 files changed, 2354 insertions(+), 646 deletions(-) create mode 100644 mobile/app/(tabs)/file-detail.tsx create mode 100644 mobile/app/+not-found.tsx create mode 100644 mobile/eslint.config.js create mode 100644 mobile/src/i18n/de.json create mode 100644 mobile/src/i18n/en.json create mode 100644 mobile/src/i18n/es.json create mode 100644 mobile/src/i18n/fr.json create mode 100644 mobile/src/i18n/index.ts create mode 100644 mobile/src/i18n/it.json create mode 100644 mobile/src/screens/FileDetailScreen.tsx create mode 100644 mobile/src/utils/mimeTypes.ts create mode 100644 mobile/src/utils/normalizeUri.ts diff --git a/mobile/README.md b/mobile/README.md index 815ab301..601d71c3 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -163,6 +163,16 @@ The app registers itself as a share target so any file can be sent directly to D The root layout (`app/_layout.tsx`) listens for incoming URLs via `Linking.addEventListener` (warm start) and `Linking.getInitialURL()` (cold start). If the URL uses the `docuelevate://` scheme it is automatically rewritten to `file://` before being forwarded. Incoming files are stored in `ShareContext` and automatically uploaded by `UploadScreen`. +#### Handling "unmatched route" errors from "Open In…" + +iOS sometimes delivers the file path under the `docuelevate://` scheme: + +``` +docuelevate://private/var/mobile/Library/Mobile Documents/…/Invoice.pdf +``` + +expo-router strips the scheme and tries to match `/private/var/mobile/…` as an in-app route. The catch-all `app/+not-found.tsx` intercepts this, detects the filesystem-path pattern, adds the file directly to `ShareContext`, and redirects to the Upload tab. `UploadScreen` picks up the pending file and begins uploading automatically. The `Linking` listener in the root layout may also fire for the same URL; `ShareContext` deduplicates by URI to prevent double uploads. + **Supported iOS file types:** PDF, images (JPEG / PNG / GIF / BMP / TIFF / WebP), plain text, Word (`.docx`, `.doc`), Excel (`.xlsx`, `.xls`), PowerPoint (`.pptx`, `.ppt`), and any other file (`public.data`). To use the share sheet: @@ -174,6 +184,10 @@ To use the share sheet: > **Note:** `CFBundleDocumentTypes` with `LSHandlerRank: Alternate` means DocuElevate appears in the share sheet as an option but does **not** become the default app for any file type. +#### iOS Action Extension (future enhancement) + +Apps like DeepL ("Translate in DeepL") appear as **Action Extensions** in the iOS share sheet, which requires a separate Xcode target and native Swift code. This is planned as a future enhancement. The current `CFBundleDocumentTypes` approach places DocuElevate in the "Open With" row of the share sheet. + ### Android – how it works `app.json` declares `intentFilters` for `ACTION_SEND` and `ACTION_SEND_MULTIPLE` with `mimeType: "*/*"`. When a user shares a file from another app and selects DocuElevate, Android delivers the content URI through the share intent, which is captured via `Linking.getInitialURL()` and processed the same way as on iOS. diff --git a/mobile/app.json b/mobile/app.json index 26cc937b..0e755fed 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -22,9 +22,9 @@ "NSCameraUsageDescription": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload.", "NSPhotoLibraryUsageDescription": "DocuElevate accesses your photo library to select documents for upload.", "NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.", - "UIBackgroundModes": ["fetch", "remote-notification"], + "UIBackgroundModes": ["remote-notification"], "ITSAppUsesNonExemptEncryption": false, - "LSSupportsOpeningDocumentsInPlace": true, + "LSSupportsOpeningDocumentsInPlace": false, "CFBundleDocumentTypes": [ { "CFBundleTypeName": "All Documents", @@ -86,7 +86,29 @@ "expo-build-properties", { "ios": { - "buildReactNativeFromSource": true + "buildReactNativeFromSource": true, + "privacyManifests": { + "NSPrivacyAccessedAPITypes": [ + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults", + "NSPrivacyAccessedAPITypeReasons": ["CA92.1"] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp", + "NSPrivacyAccessedAPITypeReasons": ["C617.1"] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace", + "NSPrivacyAccessedAPITypeReasons": ["E174.1"] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime", + "NSPrivacyAccessedAPITypeReasons": ["35F9.1"] + } + ], + "NSPrivacyCollectedDataTypes": [], + "NSPrivacyTracking": false + } } } ], diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index c115f7de..7068f4d2 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -11,10 +11,15 @@ import { Ionicons } from "@expo/vector-icons"; import React from "react"; import { usePushNotifications } from "../../src/hooks/usePushNotifications"; import { useAuth } from "../../src/context/AuthContext"; +import { useLocale, t } from "../../src/i18n"; export default function TabLayout() { const { isAuthenticated } = useAuth(); usePushNotifications(isAuthenticated); + // Subscribe to language changes so tab labels re-render when the language + // is switched. The `lang` variable is intentionally unused – its only + // purpose is to make this component a consumer of LocaleContext. + useLocale(); return ( ( ), @@ -48,23 +53,32 @@ export default function TabLayout() { ( ), - headerTitle: "My Documents", + headerTitle: t("files.title"), }} /> ( ), - headerTitle: "Profile", + headerTitle: t("tabs.profile"), + }} + /> + {/* File detail screen – hidden from tab bar, accessed via navigation */} + diff --git a/mobile/app/(tabs)/file-detail.tsx b/mobile/app/(tabs)/file-detail.tsx new file mode 100644 index 00000000..ad30c207 --- /dev/null +++ b/mobile/app/(tabs)/file-detail.tsx @@ -0,0 +1,4 @@ +/** + * File detail route – displays processing status and logs for a single file. + */ +export { default } from "../../src/screens/FileDetailScreen"; diff --git a/mobile/app/+not-found.tsx b/mobile/app/+not-found.tsx new file mode 100644 index 00000000..d88f1999 --- /dev/null +++ b/mobile/app/+not-found.tsx @@ -0,0 +1,154 @@ +/** + * Catch-all "not found" route for expo-router. + * + * This screen intercepts two different situations: + * + * 1. **iOS "Open In…" / share sheet** — iOS delivers files to the app via a + * `docuelevate://` URL. expo-router strips the custom scheme and + * tries to match the raw filesystem path (e.g. + * `/private/var/mobile/Library/…/file.pdf`) as an in-app route. Because + * no such route exists, expo-router previously threw "unmatched route + * docuelevate://…" and the upload never happened. + * + * This screen detects the filesystem-path pattern, adds the file directly + * to `ShareContext`, and redirects to the Upload tab. `UploadScreen` + * picks up the pending file and begins uploading automatically. + * + * The `Linking` listener in `_layout.tsx` may also fire for the same URL; + * `ShareContext.addPendingFile` deduplicates by URI so the file is only + * uploaded once. + * + * 2. **Any other unmatched in-app route** — redirect silently to the root so + * the user isn't left on a blank error page. + */ + +import { usePathname, useRouter } from "expo-router"; +import React, { useEffect } from "react"; +import { ActivityIndicator, StyleSheet, View } from "react-native"; +import { useShare } from "../src/context/ShareContext"; +import { mimeTypeFromFilename } from "../src/utils/mimeTypes"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * First path-segment names that identify iOS/Android sandbox filesystem paths. + * These can never be expo-router route-group names, so their presence is a + * strong positive signal that the URL is a shared file rather than a route. + * + * iOS: /private/var/mobile/… → "private" + * /var/mobile/… → "var" (symlink to /private/var/mobile) + * /tmp/… → "tmp" + * Android: /data/user/0/… → "data" + * /storage/emulated/0/… → "storage" + */ +const FS_PATH_ROOTS = ["private", "var", "tmp", "data", "storage"]; + +/** + * Route-group / special-file prefixes that identify genuine in-app routes + * rather than filesystem path segments. + * + * ⚠️ Keep this list in sync with the top-level entries in the `app/` + * directory. Add an entry here if you add a new top-level route group + * that does **not** use the parentheses convention. + */ +const IN_APP_ROUTE_PREFIXES = [ + "(auth)", // app/(auth)/ + "(tabs)", // app/(tabs)/ + "_", // expo-router special files (_layout, _sitemap, …) + "+", // expo-router special files (+not-found, …) + "--", // Expo Go development proxy prefix +]; + +/** + * Return `true` when `pathname` looks like a filesystem path delivered by iOS + * "Open In…" (e.g. `/private/var/mobile/Library/…/file.pdf`) rather than a + * legitimate in-app route. + * + * Detection strategy: + * 1. **Positive check** – if the first path segment matches a known device + * filesystem root (see `FS_PATH_ROOTS`), it is definitely a file path. + * 2. **Fallback negative check** – if the path does not start with any known + * in-app route prefix (see `IN_APP_ROUTE_PREFIXES`), treat it as a file + * path. This is a heuristic but safe because expo-router route groups + * always use parentheses (e.g. `(auth)`, `(tabs)`). + */ +function looksLikeFilePath(pathname: string): boolean { + const stripped = pathname.replace(/^\/+/, ""); + if (stripped.length === 0) return false; + + // Positive signal: path starts with a known device filesystem root segment. + const firstSegment = stripped.split("/")[0]; + if (FS_PATH_ROOTS.includes(firstSegment)) return true; + + // Fallback: paths that start with a known in-app route prefix are routes. + return !IN_APP_ROUTE_PREFIXES.some((prefix) => stripped.startsWith(prefix)); +} + +/** + * Extract a display filename from a filesystem path. + * Handles URL-encoded characters and strips query strings. + */ +function filenameFromPath(pathname: string): string { + try { + const decoded = decodeURIComponent(pathname); + const segments = decoded.split("/").filter(Boolean); + const last = segments[segments.length - 1] ?? "shared_file"; + return last.split("?")[0] || "shared_file"; + } catch { + return "shared_file"; + } +} + +// --------------------------------------------------------------------------- +// Screen component +// --------------------------------------------------------------------------- + +export default function NotFoundScreen() { + const pathname = usePathname(); + const router = useRouter(); + const { addPendingFile } = useShare(); + + // Guard: track which pathname has been handled so the effect does not + // re-fire when `router` or `addPendingFile` change identity mid-navigation. + const handledRef = React.useRef(null); + + useEffect(() => { + if (handledRef.current === pathname) return; // already handled + handledRef.current = pathname; + + if (looksLikeFilePath(pathname)) { + // Filesystem path from iOS "Open In…" – add the file to ShareContext + // and redirect to the Upload tab. UploadScreen will pick up the + // pending file and begin uploading automatically. + // + // The pathname from expo-router is the raw filesystem path + // (e.g. "/private/var/mobile/Library/…/file.pdf"). Reconstruct a + // file:// URI so the upload logic can read the file. + const fileUri = `file://${pathname}`; + const filename = filenameFromPath(pathname); + addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) }); + router.replace("/(tabs)/"); + } else { + // Truly unknown in-app route – fall back to the root redirect. + router.replace("/"); + } + }, [pathname, router, addPendingFile]); + + // Show a brief spinner while the redirect is in flight. + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#f9fafb", + }, +}); diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 7202a704..0feda68f 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -9,6 +9,11 @@ * sheet (CFBundleDocumentTypes) or Android via a SEND intent, the incoming * file:// / content:// URL is captured and forwarded to UploadScreen via * ShareContext. + * + * The companion `+not-found.tsx` handles the case where expo-router receives + * a `docuelevate://` URL with a filesystem path (from iOS "Open In…") and + * cannot match it to a route. It adds the file directly to ShareContext and + * redirects to the Upload tab so the file is uploaded transparently. */ import * as Linking from "expo-linking"; @@ -18,6 +23,8 @@ import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { AuthProvider, useAuth } from "../src/context/AuthContext"; import { ShareProvider, useShare } from "../src/context/ShareContext"; +import { LocaleProvider, useLocale, isLanguageSupported } from "../src/i18n"; +import { mimeTypeFromFilename } from "../src/utils/mimeTypes"; // --------------------------------------------------------------------------- // Helpers @@ -26,6 +33,13 @@ import { ShareProvider, useShare } from "../src/context/ShareContext"; /** The custom URL scheme registered in app.json. */ const APP_SCHEME_PREFIX = "docuelevate://"; +/** + * Known deep-link path prefixes that should NOT be treated as shared files. + * These are in-app deep-link routes handled by their respective screens + * (e.g. QR login, OAuth callback). + */ +const DEEP_LINK_PATHS = ["qr-login", "callback"]; + /** Extract a display filename from a file:// or content:// URI. */ function filenameFromUri(uri: string): string { try { @@ -43,12 +57,18 @@ function filenameFromUri(uri: string): string { * URLs to ShareContext. Extracted as a module-level factory so the handler * itself is created once and can be easily unit-tested without a React context. * - * On iOS the Share Sheet / "Open In" action may deliver the file path under + * On iOS the Share Sheet / "Open In…" action may deliver the file path under * the app's custom URL scheme (`docuelevate://…/file.pdf`) instead of a plain * `file://` URL. When that happens we rewrite the URL to `file:///…` so the * upload logic can read the file normally. + * + * Note: expo-router also receives the same URL and will attempt to match it as + * an in-app route. When no route matches it renders `+not-found.tsx`, which + * adds the file to ShareContext directly and redirects to the Upload tab. + * Both this handler and `+not-found.tsx` call `addPendingFile`; + * `ShareContext` deduplicates by URI so the file is only uploaded once. */ -function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) => void) { +function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string; mimeType?: string }) => void) { return ({ url }: { url: string }) => { let fileUri = url; @@ -57,13 +77,22 @@ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) = // (expo-router groups always start with "("). if (url.startsWith(APP_SCHEME_PREFIX)) { const path = url.slice(APP_SCHEME_PREFIX.length); - if (path.length > 0 && !path.startsWith("(")) { + + // Skip known in-app deep-link paths (e.g. qr-login, callback). + // These are handled by their respective screens, not the share flow. + const pathBase = path.split("?")[0].replace(/^\/+/, ""); + if (DEEP_LINK_PATHS.includes(pathBase) || path.startsWith("(")) { + return; + } + + if (path.length > 0) { fileUri = "file:///" + path.replace(/^\/+/, ""); } } if (!fileUri.startsWith("file://") && !fileUri.startsWith("content://")) return; - addPendingFile({ uri: fileUri, filename: filenameFromUri(fileUri) }); + const filename = filenameFromUri(fileUri); + addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) }); }; } @@ -72,11 +101,22 @@ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string }) = // --------------------------------------------------------------------------- function AuthGuard() { - const { isLoading, isAuthenticated } = useAuth(); + const { isLoading, isAuthenticated, user } = useAuth(); const { addPendingFile } = useShare(); + const { setLang } = useLocale(); const segments = useSegments(); const router = useRouter(); + // Apply the server-side language preference whenever the user profile is + // loaded (on login or app resume). This syncs the language set on the + // desktop/web client to the mobile app. If the server language is not + // supported by the mobile app, we leave the current language unchanged. + useEffect(() => { + if (user?.preferred_language && isLanguageSupported(user.preferred_language)) { + void setLang(user.preferred_language); + } + }, [user?.preferred_language, setLang]); + // Listen for files shared from other apps (iOS Share Sheet / Android Intent). // Both cold-start (app was not running) and warm-start (app in background) // cases are handled. @@ -121,6 +161,8 @@ function AuthGuard() { + {/* +not-found handles unmatched routes such as iOS "Open In…" file paths */} + ); } @@ -132,11 +174,13 @@ function AuthGuard() { export default function RootLayout() { return ( - - - - - + + + + + + + ); } diff --git a/mobile/eslint.config.js b/mobile/eslint.config.js new file mode 100644 index 00000000..7d4d4459 --- /dev/null +++ b/mobile/eslint.config.js @@ -0,0 +1 @@ +module.exports = require("eslint-config-expo/flat"); diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 1073f1ff..fb89fda2 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -27,6 +27,7 @@ "expo-image-manipulator": "~14.0.8", "expo-image-picker": "~17.0.10", "expo-linking": "~8.0.11", + "expo-localization": "~17.0.8", "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", @@ -44,7 +45,7 @@ "devDependencies": { "@babel/core": "^7.24.0", "@types/react": "~19.1.0", - "eslint": "^8.57.0", + "eslint": "^9.0.0", "eslint-config-expo": "~10.0.0", "typescript": "^5.3.0" }, @@ -1633,37 +1634,40 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { @@ -2313,22 +2317,6 @@ "node": ">=18.18.0" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -2343,14 +2331,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", @@ -2667,44 +2647,6 @@ "@tybys/wasm-util": "^0.10.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@nolyfill/is-core-module": { "version": "1.0.39", "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", @@ -5766,19 +5708,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -6068,60 +5997,63 @@ } }, "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-expo": { @@ -6260,191 +6192,6 @@ "eslint": ">=8.10" } }, - "node_modules/eslint-plugin-expo/node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-expo/node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/eslint-plugin-expo/node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-expo/node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-expo/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-expo/node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-expo/node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/eslint-plugin-expo/node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/eslint-plugin-expo/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint-plugin-import": { "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", @@ -6586,9 +6333,9 @@ } }, "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6596,7 +6343,7 @@ "estraverse": "^5.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6615,19 +6362,45 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7078,6 +6851,19 @@ "react-native": "*" } }, + "node_modules/expo-localization": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz", + "integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==", + "license": "MIT", + "dependencies": { + "rtl-detect": "^1.0.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "node_modules/expo-manifests": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz", @@ -7335,16 +7121,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -7428,16 +7204,16 @@ } }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/fill-range": { @@ -7512,24 +7288,23 @@ } }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -7827,16 +7602,13 @@ } }, "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7877,13 +7649,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -8518,16 +8283,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", @@ -10891,27 +10646,6 @@ "inherits": "~2.0.3" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -11484,17 +11218,6 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -11532,29 +11255,11 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } + "node_modules/rtl-detect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz", + "integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==", + "license": "BSD-3-Clause" }, "node_modules/safe-array-concat": { "version": "1.1.3", @@ -12483,13 +12188,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -12651,19 +12349,6 @@ "node": ">=4" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", diff --git a/mobile/package.json b/mobile/package.json index bc62426c..6037583d 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -8,7 +8,7 @@ "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", - "lint": "eslint src --ext .ts,.tsx", + "lint": "eslint src", "type-check": "tsc --noEmit", "build:ios": "eas build --platform ios", "build:android": "eas build --platform android", @@ -36,6 +36,7 @@ "expo-image-manipulator": "~14.0.8", "expo-image-picker": "~17.0.10", "expo-linking": "~8.0.11", + "expo-localization": "~17.0.8", "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", @@ -53,7 +54,7 @@ "devDependencies": { "@babel/core": "^7.24.0", "@types/react": "~19.1.0", - "eslint": "^8.57.0", + "eslint": "^9.0.0", "eslint-config-expo": "~10.0.0", "typescript": "^5.3.0" }, diff --git a/mobile/src/context/ShareContext.tsx b/mobile/src/context/ShareContext.tsx index 43643bdb..caae315c 100644 --- a/mobile/src/context/ShareContext.tsx +++ b/mobile/src/context/ShareContext.tsx @@ -9,6 +9,7 @@ */ import React, { createContext, useCallback, useContext, useState } from "react"; +import { normalizeFileUri } from "../utils/normalizeUri"; export interface SharedFile { uri: string; @@ -32,7 +33,13 @@ export function ShareProvider({ children }: { children: React.ReactNode }) { const [pendingFiles, setPendingFiles] = useState([]); const addPendingFile = useCallback((file: SharedFile) => { - setPendingFiles((prev) => [...prev, file]); + setPendingFiles((prev) => { + // Deduplicate by normalised URI so the same file is not uploaded twice + // when both the Linking handler (_layout.tsx) and +not-found.tsx fire. + const norm = normalizeFileUri(file.uri); + if (prev.some((f) => normalizeFileUri(f.uri) === norm)) return prev; + return [...prev, file]; + }); }, []); const clearPendingFiles = useCallback(() => { diff --git a/mobile/src/i18n/de.json b/mobile/src/i18n/de.json new file mode 100644 index 00000000..79589bf5 --- /dev/null +++ b/mobile/src/i18n/de.json @@ -0,0 +1,116 @@ +{ + "common": { + "retry": "Erneut versuchen", + "cancel": "Abbrechen", + "back": "Zurück", + "error": "Fehler", + "loading": "Laden…", + "search": "Suchen", + "clear_search": "Suche löschen" + }, + "welcome": { + "tagline": "Intelligente Dokumentenverarbeitung", + "description": "Dokumente einlesen, OCR durchführen, Metadaten mit KI extrahieren und Dateien in Ihren Cloud-Speicher leiten – alles in einer nahtlosen Pipeline.", + "get_started": "Loslegen", + "hint": "Verbinden Sie sich mit Ihrem selbst gehosteten oder Cloud-DocuElevate-Server.", + "feature_ocr_title": "OCR & Texterkennung", + "feature_ocr_desc": "Gescannte PDFs und Bilder automatisch in durchsuchbaren Text umwandeln.", + "feature_ai_title": "KI-Metadatenextraktion", + "feature_ai_desc": "KI klassifiziert Dokumente und extrahiert Schlüsselfelder wie Datum, Beträge und Betreff.", + "feature_cloud_title": "Multi-Cloud-Speicher", + "feature_cloud_desc": "Verarbeitete Dateien an Dropbox, Google Drive, OneDrive, S3, Nextcloud und mehr weiterleiten." + }, + "login": { + "server_url": "Server-URL", + "server_url_placeholder": "https://ihr-docuelevate-server.com", + "sign_in_sso": "Mit SSO anmelden", + "scan_qr": "📱 QR-Code scannen zum Anmelden", + "hint": "Melden Sie sich per SSO an oder scannen Sie einen QR-Code aus der Web-App.", + "back": "← Zurück", + "or": "oder", + "server_url_required": "Server-URL erforderlich", + "server_url_required_msg": "Bitte geben Sie die URL Ihres DocuElevate-Servers ein.", + "invalid_url": "Ungültige URL", + "invalid_url_msg": "Die Server-URL muss mit http:// oder https:// beginnen", + "sign_in_failed": "Anmeldung fehlgeschlagen", + "qr_login_failed": "QR-Anmeldung fehlgeschlagen" + }, + "upload": { + "camera": "Kamera", + "photos": "Fotos", + "files": "Dateien", + "camera_access_title": "Kamerazugriff erforderlich", + "camera_access_msg": "Bitte erlauben Sie den Kamerazugriff in den Einstellungen, um Dokumente aufzunehmen.", + "photo_access_title": "Fotobibliothek-Zugriff erforderlich", + "photo_access_msg": "Bitte erlauben Sie den Zugriff auf die Fotobibliothek in den Einstellungen.", + "file_picker_error": "Dateiauswahl-Fehler", + "file_picker_error_msg": "Dateiauswahl konnte nicht geöffnet werden", + "empty_title": "Tippen Sie auf Kamera, Fotos oder Dateien, um ein Dokument hochzuladen.", + "empty_hint": "Sie können auch Dateien aus anderen Apps direkt an DocuElevate senden.", + "sign_in_required": "Bitte melden Sie sich an, um Dokumente hochzuladen.", + "status_queued": "In der Warteschlange…", + "status_processing": "Wird verarbeitet…", + "status_completed": "Verarbeitet", + "status_failed": "Verarbeitung fehlgeschlagen", + "status_duplicate": "Duplikat – bereits verarbeitet", + "tap_retry": "Zum Wiederholen tippen", + "retry_title": "Upload wiederholen", + "retry_msg": "Möchten Sie den Upload von \"{filename}\" wiederholen?", + "capture_label": "Dokument mit Kamera aufnehmen", + "photo_label": "Foto aus der Bibliothek auswählen", + "file_label": "Datei vom Gerät auswählen" + }, + "files": { + "title": "Meine Dokumente", + "search_placeholder": "Dokumente durchsuchen…", + "empty_title": "Noch keine Dokumente.", + "empty_hint": "Laden Sie ein Dokument über den Upload-Tab hoch.", + "search_empty": "Keine Dokumente gefunden.", + "search_empty_hint": "Versuchen Sie einen anderen Suchbegriff.", + "view_details": "Details für {filename} anzeigen" + }, + "file_detail": { + "title": "Dateidetails", + "back": "Zurück zu Dateien", + "file_size": "Dateigröße", + "mime_type": "MIME-Typ", + "uploaded": "Hochgeladen", + "file_hash": "Datei-Hash", + "last_step": "Letzter Schritt", + "total_steps": "Gesamtschritte", + "processing_log": "Verarbeitungsprotokoll", + "no_logs": "Noch keine Verarbeitungsprotokolle.", + "file_not_found": "Datei nicht gefunden" + }, + "profile": { + "title": "Profil", + "not_signed_in": "Nicht angemeldet", + "connection": "Verbindung", + "server": "Server", + "user_id": "Benutzer-ID", + "legal": "Rechtliches", + "privacy_policy": "Datenschutzrichtlinie", + "terms_of_service": "Nutzungsbedingungen", + "imprint": "Impressum", + "sign_out": "Abmelden", + "sign_out_title": "Abmelden", + "sign_out_msg": "Möchten Sie sich wirklich abmelden?", + "delete_account": "Konto löschen", + "delete_account_title": "Konto löschen", + "delete_account_msg": "Dadurch werden Ihr Konto und alle zugehörigen Daten dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", + "could_not_open": "Konnte {page} nicht öffnen. Bitte versuchen Sie es erneut.", + "admin": "Admin", + "settings": "Einstellungen", + "language": "Sprache" + }, + "legal": { + "privacy_policy": "Datenschutz", + "terms": "AGB", + "imprint": "Impressum" + }, + "tabs": { + "upload": "Hochladen", + "files": "Dateien", + "profile": "Profil" + } +} diff --git a/mobile/src/i18n/en.json b/mobile/src/i18n/en.json new file mode 100644 index 00000000..137503c3 --- /dev/null +++ b/mobile/src/i18n/en.json @@ -0,0 +1,116 @@ +{ + "common": { + "retry": "Retry", + "cancel": "Cancel", + "back": "Back", + "error": "Error", + "loading": "Loading…", + "search": "Search", + "clear_search": "Clear search" + }, + "welcome": { + "tagline": "Intelligent Document Processing", + "description": "Ingest documents, run OCR, extract metadata with AI, and route files to your cloud storage — all in one seamless pipeline.", + "get_started": "Get Started", + "hint": "Connect to your self-hosted or cloud DocuElevate server.", + "feature_ocr_title": "OCR & Text Extraction", + "feature_ocr_desc": "Convert scanned PDFs and images into fully searchable text automatically.", + "feature_ai_title": "AI Metadata Extraction", + "feature_ai_desc": "AI classifies documents and pulls out key fields like dates, amounts, and subjects.", + "feature_cloud_title": "Multi-Cloud Storage", + "feature_cloud_desc": "Route processed files to Dropbox, Google Drive, OneDrive, S3, Nextcloud, and more." + }, + "login": { + "server_url": "Server URL", + "server_url_placeholder": "https://your-docuelevate-server.com", + "sign_in_sso": "Sign in with SSO", + "scan_qr": "📱 Scan QR Code to Login", + "hint": "Sign in via SSO or scan a QR code from the web app.", + "back": "← Back", + "or": "or", + "server_url_required": "Server URL required", + "server_url_required_msg": "Please enter the URL of your DocuElevate server.", + "invalid_url": "Invalid URL", + "invalid_url_msg": "The server URL must start with http:// or https://", + "sign_in_failed": "Sign-in failed", + "qr_login_failed": "QR Login Failed" + }, + "upload": { + "camera": "Camera", + "photos": "Photos", + "files": "Files", + "camera_access_title": "Camera access required", + "camera_access_msg": "Please grant camera access in Settings to capture documents.", + "photo_access_title": "Photo library access required", + "photo_access_msg": "Please grant photo library access in Settings to select images.", + "file_picker_error": "File picker error", + "file_picker_error_msg": "Could not open file picker", + "empty_title": "Tap Camera, Photos, or Files to upload a document.", + "empty_hint": "You can also share files from other apps directly to DocuElevate.", + "sign_in_required": "Please sign in to upload documents.", + "status_queued": "Queued for processing…", + "status_processing": "Processing…", + "status_completed": "Processed", + "status_failed": "Processing failed", + "status_duplicate": "Duplicate – already processed", + "tap_retry": "Tap to retry", + "retry_title": "Retry Upload", + "retry_msg": "Do you want to retry uploading \"{filename}\"?", + "capture_label": "Capture document with camera", + "photo_label": "Select photo from library", + "file_label": "Pick file from device" + }, + "files": { + "title": "My Documents", + "search_placeholder": "Search documents…", + "empty_title": "No documents yet.", + "empty_hint": "Upload a document from the Upload tab to get started.", + "search_empty": "No documents match your search.", + "search_empty_hint": "Try a different search term.", + "view_details": "View details for {filename}" + }, + "file_detail": { + "title": "File Details", + "back": "Back to Files", + "file_size": "File Size", + "mime_type": "MIME Type", + "uploaded": "Uploaded", + "file_hash": "File Hash", + "last_step": "Last Step", + "total_steps": "Total Steps", + "processing_log": "Processing Log", + "no_logs": "No processing logs yet.", + "file_not_found": "File not found" + }, + "profile": { + "title": "Profile", + "not_signed_in": "Not signed in", + "connection": "Connection", + "server": "Server", + "user_id": "User ID", + "legal": "Legal", + "privacy_policy": "Privacy Policy", + "terms_of_service": "Terms of Service", + "imprint": "Imprint", + "sign_out": "Sign out", + "sign_out_title": "Sign out", + "sign_out_msg": "Are you sure you want to sign out?", + "delete_account": "Delete Account", + "delete_account_title": "Delete Account", + "delete_account_msg": "This will permanently delete your account and all associated data. This action cannot be undone.", + "could_not_open": "Could not open the {page}. Please try again.", + "admin": "Admin", + "settings": "Settings", + "language": "Language" + }, + "legal": { + "privacy_policy": "Privacy Policy", + "terms": "Terms", + "imprint": "Imprint" + }, + "tabs": { + "upload": "Upload", + "files": "Files", + "profile": "Profile" + } +} diff --git a/mobile/src/i18n/es.json b/mobile/src/i18n/es.json new file mode 100644 index 00000000..eb434f34 --- /dev/null +++ b/mobile/src/i18n/es.json @@ -0,0 +1,116 @@ +{ + "common": { + "retry": "Reintentar", + "cancel": "Cancelar", + "back": "Atrás", + "error": "Error", + "loading": "Cargando…", + "search": "Buscar", + "clear_search": "Borrar búsqueda" + }, + "welcome": { + "tagline": "Procesamiento Inteligente de Documentos", + "description": "Ingiere documentos, ejecuta OCR, extrae metadatos con IA y envía archivos a tu almacenamiento en la nube — todo en una sola línea de trabajo.", + "get_started": "Comenzar", + "hint": "Conéctate a tu servidor DocuElevate autoalojado o en la nube.", + "feature_ocr_title": "OCR y Extracción de Texto", + "feature_ocr_desc": "Convierte PDFs e imágenes escaneadas en texto completamente buscable automáticamente.", + "feature_ai_title": "Extracción de Metadatos con IA", + "feature_ai_desc": "La IA clasifica documentos y extrae campos clave como fechas, montos y asuntos.", + "feature_cloud_title": "Almacenamiento Multi-Nube", + "feature_cloud_desc": "Envía archivos procesados a Dropbox, Google Drive, OneDrive, S3, Nextcloud y más." + }, + "login": { + "server_url": "URL del Servidor", + "server_url_placeholder": "https://tu-servidor-docuelevate.com", + "sign_in_sso": "Iniciar sesión con SSO", + "scan_qr": "📱 Escanear código QR para iniciar sesión", + "hint": "Inicia sesión mediante SSO o escanea un código QR desde la app web.", + "back": "← Atrás", + "or": "o", + "server_url_required": "URL del servidor requerida", + "server_url_required_msg": "Por favor ingresa la URL de tu servidor DocuElevate.", + "invalid_url": "URL inválida", + "invalid_url_msg": "La URL del servidor debe comenzar con http:// o https://", + "sign_in_failed": "Error al iniciar sesión", + "qr_login_failed": "Error en inicio de sesión QR" + }, + "upload": { + "camera": "Cámara", + "photos": "Fotos", + "files": "Archivos", + "camera_access_title": "Acceso a la cámara requerido", + "camera_access_msg": "Permite el acceso a la cámara en Ajustes para capturar documentos.", + "photo_access_title": "Acceso a la biblioteca de fotos requerido", + "photo_access_msg": "Permite el acceso a la biblioteca de fotos en Ajustes para seleccionar imágenes.", + "file_picker_error": "Error del selector de archivos", + "file_picker_error_msg": "No se pudo abrir el selector de archivos", + "empty_title": "Toca Cámara, Fotos o Archivos para subir un documento.", + "empty_hint": "También puedes compartir archivos desde otras apps directamente a DocuElevate.", + "sign_in_required": "Inicia sesión para subir documentos.", + "status_queued": "En cola para procesamiento…", + "status_processing": "Procesando…", + "status_completed": "Procesado", + "status_failed": "Procesamiento fallido", + "status_duplicate": "Duplicado – ya procesado", + "tap_retry": "Toca para reintentar", + "retry_title": "Reintentar Subida", + "retry_msg": "¿Deseas reintentar la subida de \"{filename}\"?", + "capture_label": "Capturar documento con la cámara", + "photo_label": "Seleccionar foto de la biblioteca", + "file_label": "Seleccionar archivo del dispositivo" + }, + "files": { + "title": "Mis Documentos", + "search_placeholder": "Buscar documentos…", + "empty_title": "Aún no hay documentos.", + "empty_hint": "Sube un documento desde la pestaña Subir para comenzar.", + "search_empty": "Ningún documento coincide con tu búsqueda.", + "search_empty_hint": "Intenta con otro término de búsqueda.", + "view_details": "Ver detalles de {filename}" + }, + "file_detail": { + "title": "Detalles del Archivo", + "back": "Volver a Archivos", + "file_size": "Tamaño", + "mime_type": "Tipo MIME", + "uploaded": "Subido", + "file_hash": "Hash del Archivo", + "last_step": "Último Paso", + "total_steps": "Pasos Totales", + "processing_log": "Registro de Procesamiento", + "no_logs": "Aún no hay registros de procesamiento.", + "file_not_found": "Archivo no encontrado" + }, + "profile": { + "title": "Perfil", + "not_signed_in": "No has iniciado sesión", + "connection": "Conexión", + "server": "Servidor", + "user_id": "ID de Usuario", + "legal": "Legal", + "privacy_policy": "Política de Privacidad", + "terms_of_service": "Términos de Servicio", + "imprint": "Aviso Legal", + "sign_out": "Cerrar sesión", + "sign_out_title": "Cerrar sesión", + "sign_out_msg": "¿Estás seguro de que deseas cerrar sesión?", + "delete_account": "Eliminar Cuenta", + "delete_account_title": "Eliminar Cuenta", + "delete_account_msg": "Esto eliminará permanentemente tu cuenta y todos los datos asociados. Esta acción no se puede deshacer.", + "could_not_open": "No se pudo abrir {page}. Inténtalo de nuevo.", + "admin": "Admin", + "settings": "Configuración", + "language": "Idioma" + }, + "legal": { + "privacy_policy": "Privacidad", + "terms": "Términos", + "imprint": "Aviso Legal" + }, + "tabs": { + "upload": "Subir", + "files": "Archivos", + "profile": "Perfil" + } +} diff --git a/mobile/src/i18n/fr.json b/mobile/src/i18n/fr.json new file mode 100644 index 00000000..3760d4d5 --- /dev/null +++ b/mobile/src/i18n/fr.json @@ -0,0 +1,116 @@ +{ + "common": { + "retry": "Réessayer", + "cancel": "Annuler", + "back": "Retour", + "error": "Erreur", + "loading": "Chargement…", + "search": "Rechercher", + "clear_search": "Effacer la recherche" + }, + "welcome": { + "tagline": "Traitement Intelligent de Documents", + "description": "Ingérez des documents, lancez l'OCR, extrayez les métadonnées avec l'IA et transférez les fichiers vers votre stockage cloud — le tout dans un flux unique.", + "get_started": "Commencer", + "hint": "Connectez-vous à votre serveur DocuElevate auto-hébergé ou cloud.", + "feature_ocr_title": "OCR et Extraction de Texte", + "feature_ocr_desc": "Convertissez automatiquement les PDF scannés et les images en texte entièrement consultable.", + "feature_ai_title": "Extraction de Métadonnées par IA", + "feature_ai_desc": "L'IA classe les documents et extrait les champs clés comme les dates, montants et sujets.", + "feature_cloud_title": "Stockage Multi-Cloud", + "feature_cloud_desc": "Transférez les fichiers traités vers Dropbox, Google Drive, OneDrive, S3, Nextcloud et plus." + }, + "login": { + "server_url": "URL du Serveur", + "server_url_placeholder": "https://votre-serveur-docuelevate.com", + "sign_in_sso": "Se connecter avec SSO", + "scan_qr": "📱 Scanner le code QR pour se connecter", + "hint": "Connectez-vous via SSO ou scannez un code QR depuis l'application web.", + "back": "← Retour", + "or": "ou", + "server_url_required": "URL du serveur requise", + "server_url_required_msg": "Veuillez entrer l'URL de votre serveur DocuElevate.", + "invalid_url": "URL invalide", + "invalid_url_msg": "L'URL du serveur doit commencer par http:// ou https://", + "sign_in_failed": "Échec de la connexion", + "qr_login_failed": "Échec de la connexion QR" + }, + "upload": { + "camera": "Appareil photo", + "photos": "Photos", + "files": "Fichiers", + "camera_access_title": "Accès à l'appareil photo requis", + "camera_access_msg": "Veuillez autoriser l'accès à l'appareil photo dans les Réglages pour capturer des documents.", + "photo_access_title": "Accès à la photothèque requis", + "photo_access_msg": "Veuillez autoriser l'accès à la photothèque dans les Réglages pour sélectionner des images.", + "file_picker_error": "Erreur du sélecteur de fichiers", + "file_picker_error_msg": "Impossible d'ouvrir le sélecteur de fichiers", + "empty_title": "Appuyez sur Appareil photo, Photos ou Fichiers pour télécharger un document.", + "empty_hint": "Vous pouvez aussi partager des fichiers depuis d'autres applications vers DocuElevate.", + "sign_in_required": "Veuillez vous connecter pour télécharger des documents.", + "status_queued": "En file d'attente…", + "status_processing": "En cours de traitement…", + "status_completed": "Traité", + "status_failed": "Échec du traitement", + "status_duplicate": "Doublon – déjà traité", + "tap_retry": "Appuyez pour réessayer", + "retry_title": "Réessayer le téléchargement", + "retry_msg": "Voulez-vous réessayer le téléchargement de \"{filename}\" ?", + "capture_label": "Capturer un document avec l'appareil photo", + "photo_label": "Sélectionner une photo de la bibliothèque", + "file_label": "Choisir un fichier depuis l'appareil" + }, + "files": { + "title": "Mes Documents", + "search_placeholder": "Rechercher des documents…", + "empty_title": "Pas encore de documents.", + "empty_hint": "Téléchargez un document depuis l'onglet Télécharger pour commencer.", + "search_empty": "Aucun document ne correspond à votre recherche.", + "search_empty_hint": "Essayez un autre terme de recherche.", + "view_details": "Voir les détails de {filename}" + }, + "file_detail": { + "title": "Détails du Fichier", + "back": "Retour aux Fichiers", + "file_size": "Taille", + "mime_type": "Type MIME", + "uploaded": "Téléchargé", + "file_hash": "Hash du Fichier", + "last_step": "Dernière Étape", + "total_steps": "Étapes Totales", + "processing_log": "Journal de Traitement", + "no_logs": "Pas encore de journaux de traitement.", + "file_not_found": "Fichier non trouvé" + }, + "profile": { + "title": "Profil", + "not_signed_in": "Non connecté", + "connection": "Connexion", + "server": "Serveur", + "user_id": "ID Utilisateur", + "legal": "Mentions Légales", + "privacy_policy": "Politique de Confidentialité", + "terms_of_service": "Conditions d'Utilisation", + "imprint": "Mentions Légales", + "sign_out": "Se déconnecter", + "sign_out_title": "Se déconnecter", + "sign_out_msg": "Êtes-vous sûr de vouloir vous déconnecter ?", + "delete_account": "Supprimer le Compte", + "delete_account_title": "Supprimer le Compte", + "delete_account_msg": "Cela supprimera définitivement votre compte et toutes les données associées. Cette action est irréversible.", + "could_not_open": "Impossible d'ouvrir {page}. Veuillez réessayer.", + "admin": "Admin", + "settings": "Paramètres", + "language": "Langue" + }, + "legal": { + "privacy_policy": "Confidentialité", + "terms": "Conditions", + "imprint": "Mentions Légales" + }, + "tabs": { + "upload": "Télécharger", + "files": "Fichiers", + "profile": "Profil" + } +} diff --git a/mobile/src/i18n/index.ts b/mobile/src/i18n/index.ts new file mode 100644 index 00000000..84bc1504 --- /dev/null +++ b/mobile/src/i18n/index.ts @@ -0,0 +1,208 @@ +/** + * Lightweight i18n module for the DocuElevate mobile app. + * + * Uses the device locale (via expo-localization) to select the best matching + * translation file. Falls back to English for missing keys or unsupported + * locales. + * + * Supported languages: English, German, Spanish, French, Italian. + * + * ## React integration + * + * Wrap the app root in `` and call `useLocale()` in any + * component that renders translated strings. `useLocale()` returns the + * active language code and a `setLang` setter that: + * 1. Updates the in-memory `currentLanguage` variable (so `t()` picks it up) + * 2. Triggers a React re-render of every consumer + * 3. Persists the choice to AsyncStorage (survives app restarts) + * + * Language priority on startup: + * server preference (from /api/mobile/whoami) > AsyncStorage > device locale > "en" + */ + +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { getLocales } from "expo-localization"; +import React from "react"; + +import de from "./de.json"; +import en from "./en.json"; +import es from "./es.json"; +import fr from "./fr.json"; +import it from "./it.json"; + +// --------------------------------------------------------------------------- +// Translation catalog +// --------------------------------------------------------------------------- + +type TranslationMap = Record>; + +const translations: Record = { en, de, es, fr, it }; + +// --------------------------------------------------------------------------- +// Locale detection +// --------------------------------------------------------------------------- + +const LANG_STORAGE_KEY = "@docuelevate:language"; + +/** Resolve the best-matching language code from the device locale list. */ +function detectLanguage(): string { + try { + const locales = getLocales(); + if (locales.length > 0) { + // Try exact match first (e.g. "de"), then fall back to language prefix + const code = locales[0].languageCode?.toLowerCase(); + if (code && translations[code]) return code; + } + } catch { + // getLocales() can throw on some platforms – default to English + } + return "en"; +} + +let currentLanguage: string = detectLanguage(); + +// --------------------------------------------------------------------------- +// Plain-function public API (framework-agnostic) +// --------------------------------------------------------------------------- + +/** + * Translate a dot-separated key, e.g. `t("upload.camera")`. + * + * Supports simple placeholder interpolation: + * `t("upload.retry_msg", { filename: "doc.pdf" })` + * replaces `{filename}` in the translated string. + * + * Falls back to the English value, then to the raw key if no translation + * exists. + */ +export function t(key: string, params?: Record): string { + const [section, ...rest] = key.split("."); + const subKey = rest.join("."); + + let value = + translations[currentLanguage]?.[section]?.[subKey] ?? + translations.en?.[section]?.[subKey] ?? + key; + + if (params) { + for (const [k, v] of Object.entries(params)) { + value = value.replaceAll(`{${k}}`, v); + } + } + + return value; +} + +/** Return the current language code (e.g. "en", "de"). */ +export function getLanguage(): string { + return currentLanguage; +} + +/** + * Update the active language in memory. + * Prefer `useLocale().setLang` inside React components – it also persists + * the choice and triggers re-renders. + */ +export function setLanguage(lang: string): void { + if (translations[lang]) { + currentLanguage = lang; + } +} + +/** Return true if the given language code is supported by the mobile app. */ +export function isLanguageSupported(lang: string): boolean { + return Object.prototype.hasOwnProperty.call(translations, lang); +} + +/** Return the list of supported language codes. */ +export function getSupportedLanguages(): { code: string; label: string }[] { + return [ + { code: "en", label: "English" }, + { code: "de", label: "Deutsch" }, + { code: "es", label: "Español" }, + { code: "fr", label: "Français" }, + { code: "it", label: "Italiano" }, + ]; +} + +// --------------------------------------------------------------------------- +// React integration – context + provider + hook +// --------------------------------------------------------------------------- + +interface LocaleContextValue { + /** The active language code, e.g. "en" or "de". */ + lang: string; + /** + * Switch to a new language. Persists the choice to AsyncStorage and + * triggers a re-render of every `useLocale()` consumer. + */ + setLang: (code: string) => Promise; +} + +const LocaleContext = React.createContext({ + lang: currentLanguage, + // Default setter used outside of a provider – updates in-memory only. + setLang: async (code: string) => { + setLanguage(code); + }, +}); + +/** + * Wrap the app root in `LocaleProvider` to enable reactive language switching. + * + * On mount it reads the persisted language from AsyncStorage so the user's + * choice survives app restarts. The server-preferred language is applied + * externally (see `AuthGuard` in `app/_layout.tsx`) after the profile is + * fetched from `/api/mobile/whoami`. + */ +export function LocaleProvider({ children }: { children: React.ReactNode }): React.ReactElement { + const [lang, setLangState] = React.useState(currentLanguage); + + // Restore the persisted language preference once on app start. + React.useEffect(() => { + AsyncStorage.getItem(LANG_STORAGE_KEY) + .then((saved) => { + if (saved && isLanguageSupported(saved)) { + setLanguage(saved); + setLangState(saved); + } + }) + .catch(() => { + // Ignore read errors – fall back to device-detected language. + }); + }, []); + + const setLang = React.useCallback(async (code: string): Promise => { + if (!isLanguageSupported(code)) return; + setLanguage(code); + setLangState(code); + try { + await AsyncStorage.setItem(LANG_STORAGE_KEY, code); + } catch { + // Ignore write errors – the in-memory change is still applied. + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // setLangState is a React state setter – its identity is guaranteed stable + + const value = React.useMemo(() => ({ lang, setLang }), [lang, setLang]); + + return React.createElement(LocaleContext.Provider, { value }, children); +} + +/** + * Hook that subscribes to language changes. + * + * Any component calling `useLocale()` re-renders automatically when the + * language changes. Call `t()` freely inside the component body – the + * re-render will pick up the new translations. + * + * ```tsx + * function MyScreen() { + * const { lang, setLang } = useLocale(); // subscribes to changes + * return {t("common.loading")}; + * } + * ``` + */ +export function useLocale(): LocaleContextValue { + return React.useContext(LocaleContext); +} diff --git a/mobile/src/i18n/it.json b/mobile/src/i18n/it.json new file mode 100644 index 00000000..71fc3699 --- /dev/null +++ b/mobile/src/i18n/it.json @@ -0,0 +1,116 @@ +{ + "common": { + "retry": "Riprova", + "cancel": "Annulla", + "back": "Indietro", + "error": "Errore", + "loading": "Caricamento…", + "search": "Cerca", + "clear_search": "Cancella ricerca" + }, + "welcome": { + "tagline": "Elaborazione Intelligente dei Documenti", + "description": "Acquisisci documenti, esegui l'OCR, estrai metadati con l'IA e invia i file al tuo cloud storage — tutto in un unico flusso.", + "get_started": "Inizia", + "hint": "Collegati al tuo server DocuElevate self-hosted o cloud.", + "feature_ocr_title": "OCR ed Estrazione Testo", + "feature_ocr_desc": "Converti automaticamente PDF e immagini scansionate in testo completamente ricercabile.", + "feature_ai_title": "Estrazione Metadati con IA", + "feature_ai_desc": "L'IA classifica i documenti ed estrae campi chiave come date, importi e oggetti.", + "feature_cloud_title": "Archiviazione Multi-Cloud", + "feature_cloud_desc": "Invia i file elaborati a Dropbox, Google Drive, OneDrive, S3, Nextcloud e altro." + }, + "login": { + "server_url": "URL del Server", + "server_url_placeholder": "https://il-tuo-server-docuelevate.com", + "sign_in_sso": "Accedi con SSO", + "scan_qr": "📱 Scansiona il codice QR per accedere", + "hint": "Accedi tramite SSO o scansiona un codice QR dall'app web.", + "back": "← Indietro", + "or": "o", + "server_url_required": "URL del server richiesto", + "server_url_required_msg": "Inserisci l'URL del tuo server DocuElevate.", + "invalid_url": "URL non valido", + "invalid_url_msg": "L'URL del server deve iniziare con http:// o https://", + "sign_in_failed": "Accesso fallito", + "qr_login_failed": "Accesso QR fallito" + }, + "upload": { + "camera": "Fotocamera", + "photos": "Foto", + "files": "File", + "camera_access_title": "Accesso alla fotocamera richiesto", + "camera_access_msg": "Consenti l'accesso alla fotocamera nelle Impostazioni per acquisire documenti.", + "photo_access_title": "Accesso alla libreria foto richiesto", + "photo_access_msg": "Consenti l'accesso alla libreria foto nelle Impostazioni per selezionare immagini.", + "file_picker_error": "Errore nel selettore file", + "file_picker_error_msg": "Impossibile aprire il selettore file", + "empty_title": "Tocca Fotocamera, Foto o File per caricare un documento.", + "empty_hint": "Puoi anche condividere file da altre app direttamente su DocuElevate.", + "sign_in_required": "Accedi per caricare documenti.", + "status_queued": "In coda per l'elaborazione…", + "status_processing": "Elaborazione in corso…", + "status_completed": "Elaborato", + "status_failed": "Elaborazione fallita", + "status_duplicate": "Duplicato – già elaborato", + "tap_retry": "Tocca per riprovare", + "retry_title": "Riprova Caricamento", + "retry_msg": "Vuoi riprovare a caricare \"{filename}\"?", + "capture_label": "Acquisisci documento con la fotocamera", + "photo_label": "Seleziona foto dalla libreria", + "file_label": "Seleziona file dal dispositivo" + }, + "files": { + "title": "I Miei Documenti", + "search_placeholder": "Cerca documenti…", + "empty_title": "Nessun documento ancora.", + "empty_hint": "Carica un documento dalla scheda Carica per iniziare.", + "search_empty": "Nessun documento corrisponde alla tua ricerca.", + "search_empty_hint": "Prova con un altro termine di ricerca.", + "view_details": "Visualizza dettagli per {filename}" + }, + "file_detail": { + "title": "Dettagli File", + "back": "Torna ai File", + "file_size": "Dimensione", + "mime_type": "Tipo MIME", + "uploaded": "Caricato", + "file_hash": "Hash del File", + "last_step": "Ultimo Passaggio", + "total_steps": "Passaggi Totali", + "processing_log": "Registro di Elaborazione", + "no_logs": "Nessun registro di elaborazione ancora.", + "file_not_found": "File non trovato" + }, + "profile": { + "title": "Profilo", + "not_signed_in": "Non connesso", + "connection": "Connessione", + "server": "Server", + "user_id": "ID Utente", + "legal": "Legale", + "privacy_policy": "Informativa sulla Privacy", + "terms_of_service": "Termini di Servizio", + "imprint": "Note Legali", + "sign_out": "Esci", + "sign_out_title": "Esci", + "sign_out_msg": "Sei sicuro di voler uscire?", + "delete_account": "Elimina Account", + "delete_account_title": "Elimina Account", + "delete_account_msg": "Questo eliminerà permanentemente il tuo account e tutti i dati associati. Questa azione non può essere annullata.", + "could_not_open": "Impossibile aprire {page}. Riprova.", + "admin": "Admin", + "settings": "Impostazioni", + "language": "Lingua" + }, + "legal": { + "privacy_policy": "Privacy", + "terms": "Termini", + "imprint": "Note Legali" + }, + "tabs": { + "upload": "Carica", + "files": "File", + "profile": "Profilo" + } +} diff --git a/mobile/src/screens/FileDetailScreen.tsx b/mobile/src/screens/FileDetailScreen.tsx new file mode 100644 index 00000000..8d7cad20 --- /dev/null +++ b/mobile/src/screens/FileDetailScreen.tsx @@ -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 = { + 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 = { + 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(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(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 ( + + + + ); + } + + if (error || !detail) { + return ( + + {error ?? t("file_detail.file_not_found")} + + {t("common.retry")} + + router.back()}> + {t("common.back")} + + + ); + } + + const file = detail.file; + const status = detail.processing_status; + + return ( + } + > + {/* Header with back button */} + router.back()} + accessibilityRole="button" + accessibilityLabel={t("file_detail.back")} + > + + {t("file_detail.back")} + + + {/* File info card */} + + + + + + {file.original_filename} + + + {status.status.charAt(0).toUpperCase() + status.status.slice(1)} + + + + + + + + + + + + + + + {/* Processing logs */} + + {t("file_detail.processing_log")} + {detail.logs.length === 0 ? ( + {t("file_detail.no_logs")} + ) : ( + detail.logs.map((log, idx) => { + const icon = logStepIcon(log.status); + const isLast = idx === detail.logs.length - 1; + return ( + + + + {log.step_name} + + {log.message} + + {formatDateTime(log.timestamp)} + + + ); + }) + )} + + + ); +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function MetaRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + + {value} + + + ); +} + +// --------------------------------------------------------------------------- +// 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" }, +}); diff --git a/mobile/src/screens/FilesScreen.tsx b/mobile/src/screens/FilesScreen.tsx index 3a2a21d4..d40b1c65 100644 --- a/mobile/src/screens/FilesScreen.tsx +++ b/mobile/src/screens/FilesScreen.tsx @@ -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 = { - completed: "✅", - processing: "⚙️", - pending: "⏳", - failed: "❌", - duplicate: "🔁", +function statusIcon(status: string): { name: keyof typeof Ionicons.glyphMap; color: string } { + const map: Record = { + 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([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const searchTimeoutRef = useRef | 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 ( @@ -101,52 +148,88 @@ export default function FilesScreen() { ); } - if (error) { + if (error && files.length === 0) { return ( {error} - Retry + {t("common.retry")} ); } return ( - String(item.id)} - contentContainerStyle={styles.listContent} - renderItem={({ item }) => } - refreshControl={ - - } - onEndReached={handleLoadMore} - onEndReachedThreshold={0.4} - ListEmptyComponent={ - - 📂 - No documents yet. - - Upload a document from the Upload tab to get started. - - - } - ListFooterComponent={ - hasMore && files.length > 0 ? ( - - ) : null - } - /> + + {/* Search bar */} + + + + {searchQuery.length > 0 && ( + + + + )} + + + String(item.id)} + contentContainerStyle={styles.listContent} + renderItem={({ item }) => } + refreshControl={ + + } + onEndReached={handleLoadMore} + onEndReachedThreshold={0.4} + ListEmptyComponent={ + + + + {searchQuery ? t("files.search_empty") : t("files.empty_title")} + + + {searchQuery ? t("files.search_empty_hint") : t("files.empty_hint")} + + + } + ListFooterComponent={ + hasMore && files.length > 0 ? ( + + ) : null + } + /> + ); } -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 ( - - {statusEmoji(status)} + onPress(file)} + accessibilityRole="button" + accessibilityLabel={`View details for ${file.original_filename}`} + > + {file.original_filename} @@ -155,14 +238,44 @@ function FileRow({ file }: { file: FileRecord }) { {formatDate(file.created_at)} · {formatBytes(file.file_size)} - {status} - + + {status} + + + ); } 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", diff --git a/mobile/src/screens/LoginScreen.tsx b/mobile/src/screens/LoginScreen.tsx index 8a0ace90..e92a5596 100644 --- a/mobile/src/screens/LoginScreen.tsx +++ b/mobile/src/screens/LoginScreen.tsx @@ -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() { /> DocuElevate - Intelligent Document Processing + {t("welcome.tagline")} - Server URL + {t("login.server_url")} {loading ? ( ) : ( - Sign in with SSO + {t("login.sign_in_sso")} )} - or + {t("login.or")} @@ -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 ? ( ) : ( - 📱 Scan QR Code to Login + {t("login.scan_qr")} )} - - Sign in via SSO or scan a QR code from the web app. - + {t("login.hint")} router.back()} accessibilityRole="button" - accessibilityLabel="Back to welcome screen" + accessibilityLabel={t("login.back")} style={styles.backLink} > - ← Back + {t("login.back")} + + {/* Legal links – accessible pre-login for GDPR / Apple compliance */} + + { + const base = serverUrl.trim() || "https://app.docuelevate.org"; + Linking.openURL(`${base.replace(/\/$/, "")}/privacy`); + }} + accessibilityRole="link" + accessibilityLabel={t("legal.privacy_policy")} + style={styles.legalLinkButton} + > + {t("legal.privacy_policy")} + + · + { + const base = serverUrl.trim() || "https://app.docuelevate.org"; + Linking.openURL(`${base.replace(/\/$/, "")}/terms`); + }} + accessibilityRole="link" + accessibilityLabel={t("legal.terms")} + style={styles.legalLinkButton} + > + {t("legal.terms")} + + · + { + const base = serverUrl.trim() || "https://app.docuelevate.org"; + Linking.openURL(`${base.replace(/\/$/, "")}/imprint`); + }} + accessibilityRole="link" + accessibilityLabel={t("legal.imprint")} + style={styles.legalLinkButton} + > + {t("legal.imprint")} + + ); @@ -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, + }, }); diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx index ecc39d72..3990e1df 100644 --- a/mobile/src/screens/ProfileScreen.tsx +++ b/mobile/src/screens/ProfileScreen.tsx @@ -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 ( - Not signed in + {t("profile.not_signed_in")} ); } @@ -56,44 +112,121 @@ export default function ProfileScreen() { )} {user.display_name ?? user.owner_id} {user.email && {user.email}} - {user.is_admin && Admin} + {user.is_admin && {t("profile.admin")}} {/* Server info */} - Connection + {t("profile.connection")} - Server + {t("profile.server")} - {baseUrl || "–"} + {effectiveBaseUrl} - User ID + {t("profile.user_id")} {user.owner_id} - {/* Danger zone */} + {/* Settings */} + + {t("profile.settings")} + {t("profile.language")} + + {languages.map((l) => ( + handleLanguageSelect(l.code)} + accessibilityRole="button" + accessibilityLabel={`Set language to ${l.label}`} + accessibilityState={{ selected: lang === l.code }} + > + + {l.label} + + + ))} + + + + {/* Legal & Privacy */} + + {t("profile.legal")} + + {t("profile.privacy_policy")} + + + + {t("profile.terms_of_service")} + + + + {t("profile.imprint")} + + + + + {/* Sign out */} - Sign out + {t("profile.sign_out")} + + {/* Account deletion – Apple Guideline 5.1.1(v) */} + + + {t("profile.delete_account")} + + + + {/* App version */} + DocuElevate v{appVersion} ); } 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", + }, }); diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx index 0a2788cf..daa00a01 100644 --- a/mobile/src/screens/UploadScreen.tsx +++ b/mobile/src/screens/UploadScreen.tsx @@ -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([]); + // 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>(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 => { + // 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 ( - Please sign in to upload documents. + {t("upload.sign_in_required")} ); } @@ -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")} > - 📷 - Camera + + {t("upload.camera")} - 🖼️ - Photos + + {t("upload.photos")} - 📄 - Files + + {t("upload.files")} @@ -285,13 +383,9 @@ export default function UploadScreen() { {uploads.length === 0 ? ( - ☁️ - - Tap Camera, Photos, or Files to upload a document. - - - You can also share files from other apps directly to DocuElevate. - + + {t("upload.empty_title")} + {t("upload.empty_hint")} ) : ( uploads.map((item) => ( @@ -304,21 +398,24 @@ export default function UploadScreen() { } function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) { - const uploadIcons: Record = { - pending: "⏳", - uploading: "⬆️", - done: "✅", - error: "❌", + // Subscribe to language changes so status labels re-render. + useLocale(); + + const uploadIconProps: Record = { + 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 = { - 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} > - {uploadIcons[item.status]} + {item.filename} @@ -351,7 +448,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload )} {item.status === "done" && !item.serverStatus && ( - Queued for processing… + {t("upload.status_queued")} )} {item.status === "done" && item.serverStatus && ( {item.error} {canRetry && ( - Tap to retry + {t("upload.tap_retry")} )} )} @@ -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, diff --git a/mobile/src/screens/WelcomeScreen.tsx b/mobile/src/screens/WelcomeScreen.tsx index f779456c..8f3b8127 100644 --- a/mobile/src/screens/WelcomeScreen.tsx +++ b/mobile/src/screens/WelcomeScreen.tsx @@ -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 ( DocuElevate - Intelligent Document Processing - - Ingest documents, run OCR, extract metadata with AI, and route files - to your cloud storage — all in one seamless pipeline. - + {t("welcome.tagline")} + {t("welcome.description")} {/* Feature highlights */} - {FEATURES.map((feature) => ( + {features.map((feature) => ( {feature.icon} @@ -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")} > - Get Started + {t("welcome.get_started")} - - Connect to your self-hosted or cloud DocuElevate server. - + {t("welcome.hint")} + + {/* Legal links – accessible pre-login for GDPR / Apple compliance */} + + Linking.openURL("https://app.docuelevate.org/privacy")} + accessibilityRole="link" + accessibilityLabel={t("legal.privacy_policy")} + style={styles.legalLinkButton} + > + {t("legal.privacy_policy")} + + · + Linking.openURL("https://app.docuelevate.org/terms")} + accessibilityRole="link" + accessibilityLabel={t("legal.terms")} + style={styles.legalLinkButton} + > + {t("legal.terms")} + + · + Linking.openURL("https://app.docuelevate.org/imprint")} + accessibilityRole="link" + accessibilityLabel={t("legal.imprint")} + style={styles.legalLinkButton} + > + {t("legal.imprint")} + + ); @@ -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, + }, }); diff --git a/mobile/src/services/api.ts b/mobile/src/services/api.ts index 4df11f4d..27861f85 100644 --- a/mobile/src/services/api.ts +++ b/mobile/src/services/api.ts @@ -26,6 +26,7 @@ export interface WhoAmIResponse { email: string | null; avatar_url: string | null; is_admin: boolean; + preferred_language: string | null; } export interface GenerateTokenResponse { @@ -66,10 +67,42 @@ export interface FileRecord { } export interface UploadResponse { - task_id: string; + task_id?: string; status: string; original_filename: string; stored_filename: string; + duplicate_of?: { + duplicate_type: string; + original_file_id: number; + original_filename: string; + message: string; + }; +} + +export interface ProcessingLog { + id: number; + task_id: string; + step_name: string; + status: string; + message: string; + timestamp: string; +} + +export interface FileDetail { + file: { + id: number; + filehash: string; + original_filename: string; + local_filename: string; + file_size: number; + mime_type: string; + created_at: string; + }; + processing_status: ProcessingStatus; + logs: ProcessingLog[]; + files_on_disk: { + original: boolean; + }; } // --------------------------------------------------------------------------- @@ -177,6 +210,11 @@ class DocuElevateAPI { return this.request("GET", "/api/mobile/whoami"); } + /** Sync the user's preferred UI language to the server. */ + async setServerLanguage(lang: string): Promise { + await this.request("POST", "/api/i18n/language", { body: { language: lang } }); + } + // ------------------------------------------------------------------------- // Push notifications // ------------------------------------------------------------------------- @@ -223,6 +261,11 @@ class DocuElevateAPI { ); return data.processing_status; } + + /** Get full file details including processing logs. */ + async getFileDetail(fileId: number): Promise { + return this.request("GET", `/api/files/${fileId}`); + } } export const api = new DocuElevateAPI(); diff --git a/mobile/src/utils/mimeTypes.ts b/mobile/src/utils/mimeTypes.ts new file mode 100644 index 00000000..82f6de91 --- /dev/null +++ b/mobile/src/utils/mimeTypes.ts @@ -0,0 +1,44 @@ +/** + * Shared MIME type utilities for the DocuElevate mobile app. + * + * Used by the Linking handler in _layout.tsx, the catch-all +not-found.tsx, + * and any other code that needs to infer a MIME type from a file extension. + */ + +/** + * Common MIME type mappings for file extensions. + * Used to infer the MIME type of files shared via the Share Sheet / "Open In…" + * so the server receives a correct Content-Type instead of application/octet-stream. + */ +export const EXT_TO_MIME: Record = { + pdf: "application/pdf", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + bmp: "image/bmp", + tiff: "image/tiff", + tif: "image/tiff", + webp: "image/webp", + heic: "image/heic", + heif: "image/heif", + txt: "text/plain", + csv: "text/csv", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + rtf: "application/rtf", + html: "text/html", + xml: "application/xml", + json: "application/json", + zip: "application/zip", +}; + +/** Infer MIME type from a filename's extension, or undefined if unknown. */ +export function mimeTypeFromFilename(filename: string): string | undefined { + const ext = filename.split(".").pop()?.toLowerCase(); + return ext ? EXT_TO_MIME[ext] : undefined; +} diff --git a/mobile/src/utils/normalizeUri.ts b/mobile/src/utils/normalizeUri.ts new file mode 100644 index 00000000..d32e7d7f --- /dev/null +++ b/mobile/src/utils/normalizeUri.ts @@ -0,0 +1,20 @@ +/** + * Normalise a file URI for deduplication. + * + * - Decode percent-encoding (`%20` → ` `) + * - Collapse consecutive slashes after the scheme (`file:////` → `file:///`) + * - Strip trailing slashes + */ +export function normalizeFileUri(uri: string): string { + let norm: string; + try { + norm = decodeURIComponent(uri); + } catch { + norm = uri; + } + // Collapse multiple slashes after the scheme (e.g. file://// → file:///) + norm = norm.replace(/^(file:\/\/)\/{2,}/, "$1/"); + // Strip trailing slash + norm = norm.replace(/\/+$/, ""); + return norm; +}