Merge pull request #831 from christianlouis/copilot/restoremobile-pre-d2217531

[WIP] Restore mobile directory to state before commit d22175310
This commit is contained in:
Christian Krakau-Louis
2026-03-24 13:17:43 +01:00
committed by GitHub
25 changed files with 2354 additions and 646 deletions
+14
View File
@@ -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.
+25 -3
View File
@@ -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
}
}
}
],
+22 -8
View File
@@ -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 (
<Tabs
@@ -37,8 +42,8 @@ export default function TabLayout() {
<Tabs.Screen
name="index"
options={{
title: "Upload",
tabBarLabel: "Upload",
title: t("tabs.upload"),
tabBarLabel: t("tabs.upload"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="cloud-upload-outline" size={size} color={color} />
),
@@ -48,23 +53,32 @@ export default function TabLayout() {
<Tabs.Screen
name="files"
options={{
title: "Files",
tabBarLabel: "Files",
title: t("tabs.files"),
tabBarLabel: t("tabs.files"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="document-text-outline" size={size} color={color} />
),
headerTitle: "My Documents",
headerTitle: t("files.title"),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
tabBarLabel: "Profile",
title: t("tabs.profile"),
tabBarLabel: t("tabs.profile"),
tabBarIcon: ({ color, size }) => (
<Ionicons name="person-circle-outline" size={size} color={color} />
),
headerTitle: "Profile",
headerTitle: t("tabs.profile"),
}}
/>
{/* File detail screen hidden from tab bar, accessed via navigation */}
<Tabs.Screen
name="file-detail"
options={{
href: null,
title: t("file_detail.title"),
headerTitle: t("file_detail.title"),
}}
/>
</Tabs>
+4
View File
@@ -0,0 +1,4 @@
/**
* File detail route displays processing status and logs for a single file.
*/
export { default } from "../../src/screens/FileDetailScreen";
+154
View File
@@ -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://<path>` 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<string | null>(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 (
<View style={styles.container}>
<ActivityIndicator size="large" color="#1e40af" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9fafb",
},
});
+49 -5
View File
@@ -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() {
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
{/* +not-found handles unmatched routes such as iOS "Open In…" file paths */}
<Stack.Screen name="+not-found" />
</Stack>
);
}
@@ -132,11 +174,13 @@ function AuthGuard() {
export default function RootLayout() {
return (
<SafeAreaProvider>
<LocaleProvider>
<ShareProvider>
<AuthProvider>
<AuthGuard />
</AuthProvider>
</ShareProvider>
</LocaleProvider>
</SafeAreaProvider>
);
}
+1
View File
@@ -0,0 +1 @@
module.exports = require("eslint-config-expo/flat");
+122 -437
View File
@@ -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",
+3 -2
View File
@@ -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"
},
+8 -1
View File
@@ -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<SharedFile[]>([]);
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(() => {
+116
View File
@@ -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"
}
}
+116
View File
@@ -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"
}
}
+116
View File
@@ -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"
}
}
+116
View File
@@ -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"
}
}
+208
View File
@@ -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 `<LocaleProvider>` 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<string, Record<string, string>>;
const translations: Record<string, TranslationMap> = { 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, string>): 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<void>;
}
const LocaleContext = React.createContext<LocaleContextValue>({
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<void> => {
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 <Text>{t("common.loading")}</Text>;
* }
* ```
*/
export function useLocale(): LocaleContextValue {
return React.useContext(LocaleContext);
}
+116
View File
@@ -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"
}
}
+332
View File
@@ -0,0 +1,332 @@
/**
* FileDetailScreen shows detailed status and processing logs for a single file.
*
* Replicates the web /files/:id and /files/:id/detail views in a
* mobile-friendly layout. Displays file metadata, processing status with
* a progress indicator, and a chronological list of processing log entries.
*/
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router";
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import type { FileDetail } from "../services/api";
import api from "../services/api";
import { useLocale, t } from "../i18n";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function statusColor(status: string): string {
const colors: Record<string, string> = {
completed: "#059669",
processing: "#d97706",
pending: "#6b7280",
failed: "#dc2626",
duplicate: "#6b7280",
};
return colors[status?.toLowerCase()] ?? "#6b7280";
}
function statusIcon(status: string): keyof typeof Ionicons.glyphMap {
const icons: Record<string, keyof typeof Ionicons.glyphMap> = {
completed: "checkmark-circle",
processing: "sync-circle",
pending: "time-outline",
failed: "close-circle",
duplicate: "copy-outline",
};
return icons[status?.toLowerCase()] ?? "document-outline";
}
function logStepIcon(status: string): { name: keyof typeof Ionicons.glyphMap; color: string } {
const lower = status?.toLowerCase();
if (lower === "completed" || lower === "success") return { name: "checkmark-circle", color: "#059669" };
if (lower === "failed" || lower === "error") return { name: "close-circle", color: "#dc2626" };
if (lower === "skipped") return { name: "remove-circle-outline", color: "#9ca3af" };
if (lower === "processing" || lower === "running") return { name: "sync-circle", color: "#d97706" };
return { name: "ellipse-outline", color: "#6b7280" };
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function FileDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [detail, setDetail] = useState<FileDetail | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Subscribe to language changes so translated strings re-render.
useLocale();
const fileId = parseInt(id ?? "0", 10);
const fetchDetail = useCallback(async () => {
if (!fileId) return;
try {
const data = await api.getFileDetail(fileId);
setDetail(data);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load file details");
}
}, [fileId]);
useEffect(() => {
(async () => {
setLoading(true);
await fetchDetail();
setLoading(false);
})();
}, [fetchDetail]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await fetchDetail();
setRefreshing(false);
}, [fetchDetail]);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#1e40af" />
</View>
);
}
if (error || !detail) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error ?? t("file_detail.file_not_found")}</Text>
<Pressable style={styles.retryButton} onPress={handleRefresh}>
<Text style={styles.retryText}>{t("common.retry")}</Text>
</Pressable>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<Text style={styles.backButtonText}>{t("common.back")}</Text>
</Pressable>
</View>
);
}
const file = detail.file;
const status = detail.processing_status;
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />}
>
{/* Header with back button */}
<Pressable
style={styles.backRow}
onPress={() => router.back()}
accessibilityRole="button"
accessibilityLabel={t("file_detail.back")}
>
<Ionicons name="arrow-back" size={20} color="#1e40af" />
<Text style={styles.backLabel}>{t("file_detail.back")}</Text>
</Pressable>
{/* File info card */}
<View style={styles.card}>
<View style={styles.cardHeader}>
<Ionicons
name={statusIcon(status.status)}
size={28}
color={statusColor(status.status)}
style={{ marginRight: 12 }}
/>
<View style={{ flex: 1 }}>
<Text style={styles.filename} numberOfLines={2}>
{file.original_filename}
</Text>
<Text style={[styles.statusBadge, { color: statusColor(status.status) }]}>
{status.status.charAt(0).toUpperCase() + status.status.slice(1)}
</Text>
</View>
</View>
<View style={styles.metaGrid}>
<MetaRow label={t("file_detail.file_size")} value={formatBytes(file.file_size)} />
<MetaRow label={t("file_detail.mime_type")} value={file.mime_type ?? ""} />
<MetaRow label={t("file_detail.uploaded")} value={formatDateTime(file.created_at)} />
<MetaRow label={t("file_detail.file_hash")} value={file.filehash ? `${file.filehash.slice(0, 24)}` : ""} />
<MetaRow label={t("file_detail.last_step")} value={status.last_step ?? ""} />
<MetaRow label={t("file_detail.total_steps")} value={String(status.total_steps)} />
</View>
</View>
{/* Processing logs */}
<View style={styles.card}>
<Text style={styles.sectionTitle}>{t("file_detail.processing_log")}</Text>
{detail.logs.length === 0 ? (
<Text style={styles.emptyLog}>{t("file_detail.no_logs")}</Text>
) : (
detail.logs.map((log, idx) => {
const icon = logStepIcon(log.status);
const isLast = idx === detail.logs.length - 1;
return (
<View key={log.id} style={[styles.logEntry, !isLast && styles.logEntryBorder]}>
<Ionicons name={icon.name} size={18} color={icon.color} style={styles.logIcon} />
<View style={styles.logContent}>
<Text style={styles.logStep}>{log.step_name}</Text>
<Text style={styles.logMessage} numberOfLines={3}>
{log.message}
</Text>
<Text style={styles.logTimestamp}>{formatDateTime(log.timestamp)}</Text>
</View>
</View>
);
})
)}
</View>
</ScrollView>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{label}</Text>
<Text style={styles.metaValue} numberOfLines={1}>
{value}
</Text>
</View>
);
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = StyleSheet.create({
scroll: { flex: 1, backgroundColor: "#f9fafb" },
content: { padding: 16, paddingBottom: 40 },
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9fafb",
padding: 24,
},
errorText: { color: "#dc2626", fontSize: 15, textAlign: "center", marginBottom: 16 },
retryButton: {
backgroundColor: "#1e40af",
borderRadius: 8,
paddingHorizontal: 24,
paddingVertical: 10,
marginBottom: 12,
},
retryText: { color: "#fff", fontWeight: "600" },
backButton: { paddingVertical: 10 },
backButtonText: { color: "#6b7280", fontSize: 14 },
backRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: 16,
minHeight: 44,
},
backLabel: {
fontSize: 15,
color: "#1e40af",
fontWeight: "600",
marginLeft: 6,
},
card: {
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
marginBottom: 16,
shadowColor: "#000",
shadowOpacity: 0.04,
shadowOffset: { width: 0, height: 2 },
shadowRadius: 6,
elevation: 2,
},
cardHeader: {
flexDirection: "row",
alignItems: "flex-start",
marginBottom: 16,
},
filename: {
fontSize: 17,
fontWeight: "700",
color: "#111827",
marginBottom: 4,
},
statusBadge: {
fontSize: 13,
fontWeight: "600",
textTransform: "capitalize",
},
metaGrid: {},
metaRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
},
metaLabel: { fontSize: 13, color: "#6b7280", fontWeight: "500" },
metaValue: { fontSize: 13, color: "#374151", maxWidth: "55%", textAlign: "right" },
sectionTitle: {
fontSize: 15,
fontWeight: "700",
color: "#374151",
marginBottom: 12,
},
emptyLog: { fontSize: 13, color: "#9ca3af", fontStyle: "italic" },
logEntry: {
flexDirection: "row",
alignItems: "flex-start",
paddingVertical: 10,
},
logEntryBorder: {
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
},
logIcon: { marginRight: 10, marginTop: 1 },
logContent: { flex: 1 },
logStep: { fontSize: 13, fontWeight: "600", color: "#374151", marginBottom: 2 },
logMessage: { fontSize: 12, color: "#6b7280", lineHeight: 17, marginBottom: 2 },
logTimestamp: { fontSize: 11, color: "#9ca3af" },
});
+147 -30
View File
@@ -1,8 +1,10 @@
/**
* FilesScreen list of documents processed by DocuElevate.
* FilesScreen list of documents processed by DocuElevate with search.
*/
import React, { useCallback, useEffect, useState } from "react";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
FlatList,
@@ -10,10 +12,12 @@ import {
RefreshControl,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import type { FileRecord } from "../services/api";
import api from "../services/api";
import { useLocale, t } from "../i18n";
function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === undefined) return "";
@@ -34,29 +38,34 @@ function formatDate(iso: string): string {
}
}
function statusEmoji(status: string): string {
const map: Record<string, string> = {
completed: "✅",
processing: "⚙️",
pending: "⏳",
failed: "❌",
duplicate: "🔁",
function statusIcon(status: string): { name: keyof typeof Ionicons.glyphMap; color: string } {
const map: Record<string, { name: keyof typeof Ionicons.glyphMap; color: string }> = {
completed: { name: "checkmark-circle", color: "#059669" },
processing: { name: "sync-circle", color: "#d97706" },
pending: { name: "time-outline", color: "#6b7280" },
failed: { name: "close-circle", color: "#dc2626" },
duplicate: { name: "copy-outline", color: "#6b7280" },
};
return map[status?.toLowerCase()] ?? "📄";
return map[status?.toLowerCase()] ?? { name: "document-outline", color: "#6b7280" };
}
export default function FilesScreen() {
const router = useRouter();
const [files, setFiles] = useState<FileRecord[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Subscribe to language changes so translated strings re-render.
useLocale();
const fetchFiles = useCallback(
async (pageNum: number, replace: boolean) => {
async (pageNum: number, replace: boolean, search?: string) => {
try {
const data = await api.listFiles(pageNum, 20);
const data = await api.listFiles(pageNum, 20, search || undefined);
if (replace) {
setFiles(data);
} else {
@@ -82,18 +91,56 @@ export default function FilesScreen() {
const handleRefresh = useCallback(async () => {
setRefreshing(true);
setPage(1);
await fetchFiles(1, true);
await fetchFiles(1, true, searchQuery);
setRefreshing(false);
}, [fetchFiles]);
}, [fetchFiles, searchQuery]);
const handleLoadMore = useCallback(async () => {
if (!hasMore || loading || refreshing) return;
const next = page + 1;
setPage(next);
await fetchFiles(next, false);
}, [fetchFiles, hasMore, loading, page, refreshing]);
await fetchFiles(next, false, searchQuery);
}, [fetchFiles, hasMore, loading, page, refreshing, searchQuery]);
if (loading) {
const handleSearch = useCallback(
(text: string) => {
setSearchQuery(text);
// Debounce search requests
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(async () => {
setPage(1);
setLoading(true);
try {
await fetchFiles(1, true, text);
} finally {
setLoading(false);
}
}, 400);
},
[fetchFiles]
);
const handleClearSearch = useCallback(async () => {
setSearchQuery("");
setPage(1);
setLoading(true);
try {
await fetchFiles(1, true);
} finally {
setLoading(false);
}
}, [fetchFiles]);
const handleFilePress = useCallback(
(file: FileRecord) => {
router.push({ pathname: "/(tabs)/file-detail", params: { id: String(file.id) } });
},
[router]
);
if (loading && files.length === 0) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#1e40af" />
@@ -101,24 +148,51 @@ export default function FilesScreen() {
);
}
if (error) {
if (error && files.length === 0) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={handleRefresh}>
<Text style={styles.retryText}>Retry</Text>
<Text style={styles.retryText}>{t("common.retry")}</Text>
</Pressable>
</View>
);
}
return (
<View style={styles.container}>
{/* Search bar */}
<View style={styles.searchContainer}>
<Ionicons name="search-outline" size={18} color="#9ca3af" style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder={t("files.search_placeholder")}
placeholderTextColor="#9ca3af"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
accessibilityLabel={t("common.search")}
/>
{searchQuery.length > 0 && (
<Pressable
onPress={handleClearSearch}
style={styles.clearButton}
accessibilityRole="button"
accessibilityLabel={t("common.clear_search")}
>
<Ionicons name="close-circle" size={18} color="#9ca3af" />
</Pressable>
)}
</View>
<FlatList
style={styles.list}
data={files}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => <FileRow file={item} />}
renderItem={({ item }) => <FileRow file={item} onPress={handleFilePress} />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
@@ -126,10 +200,12 @@ export default function FilesScreen() {
onEndReachedThreshold={0.4}
ListEmptyComponent={
<View style={styles.emptyState}>
<Text style={styles.emptyEmoji}>📂</Text>
<Text style={styles.emptyText}>No documents yet.</Text>
<Ionicons name="folder-open-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
<Text style={styles.emptyText}>
{searchQuery ? t("files.search_empty") : t("files.empty_title")}
</Text>
<Text style={styles.emptyHint}>
Upload a document from the Upload tab to get started.
{searchQuery ? t("files.search_empty_hint") : t("files.empty_hint")}
</Text>
</View>
}
@@ -139,14 +215,21 @@ export default function FilesScreen() {
) : null
}
/>
</View>
);
}
function FileRow({ file }: { file: FileRecord }) {
function FileRow({ file, onPress }: { file: FileRecord; onPress: (file: FileRecord) => void }) {
const status = file.processing_status?.status ?? "pending";
const icon = statusIcon(status);
return (
<View style={rowStyles.row}>
<Text style={rowStyles.icon}>{statusEmoji(status)}</Text>
<Pressable
style={rowStyles.row}
onPress={() => onPress(file)}
accessibilityRole="button"
accessibilityLabel={`View details for ${file.original_filename}`}
>
<Ionicons name={icon.name} size={22} color={icon.color} style={rowStyles.icon} />
<View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}>
{file.original_filename}
@@ -155,14 +238,44 @@ function FileRow({ file }: { file: FileRecord }) {
{formatDate(file.created_at)} · {formatBytes(file.file_size)}
</Text>
</View>
<View style={rowStyles.right}>
<Text style={rowStyles.status}>{status}</Text>
<Ionicons name="chevron-forward" size={16} color="#d1d5db" />
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
list: { flex: 1, backgroundColor: "#f9fafb" },
listContent: { padding: 16 },
container: { flex: 1, backgroundColor: "#f9fafb" },
list: { flex: 1 },
listContent: { padding: 16, paddingTop: 0 },
searchContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff",
marginHorizontal: 16,
marginVertical: 12,
borderRadius: 10,
paddingHorizontal: 12,
borderWidth: 1,
borderColor: "#e5e7eb",
minHeight: 44,
},
searchIcon: { marginRight: 8 },
searchInput: {
flex: 1,
fontSize: 15,
color: "#111827",
paddingVertical: 10,
},
clearButton: {
padding: 4,
minWidth: 44,
minHeight: 44,
alignItems: "center",
justifyContent: "center",
},
center: {
flex: 1,
alignItems: "center",
@@ -179,7 +292,6 @@ const styles = StyleSheet.create({
},
retryText: { color: "#fff", fontWeight: "600" },
emptyState: { alignItems: "center", paddingTop: 60 },
emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: { fontSize: 16, color: "#374151", marginBottom: 8 },
emptyHint: {
fontSize: 13,
@@ -203,7 +315,7 @@ const rowStyles = StyleSheet.create({
shadowRadius: 4,
elevation: 2,
},
icon: { fontSize: 22, marginRight: 12 },
icon: { marginRight: 12 },
info: { flex: 1 },
filename: {
fontSize: 14,
@@ -212,6 +324,11 @@ const rowStyles = StyleSheet.create({
marginBottom: 4,
},
meta: { fontSize: 12, color: "#6b7280" },
right: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
status: {
fontSize: 11,
color: "#6b7280",
+83 -21
View File
@@ -24,13 +24,16 @@ import {
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import { useLocale, t } from "../i18n";
export default function LoginScreen() {
const { signIn, signInWithQR } = useAuth();
const router = useRouter();
const [serverUrl, setServerUrl] = useState("");
const [serverUrl, setServerUrl] = useState("https://app.docuelevate.org");
const [loading, setLoading] = useState(false);
const [qrLoading, setQrLoading] = useState(false);
// Subscribe to language changes so translated strings re-render.
useLocale();
// Handle incoming deep links for QR login (docuelevate://qr-login?token=...&server=...)
const handleDeepLink = useCallback(
@@ -46,8 +49,8 @@ export default function LoginScreen() {
}
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "QR login failed";
Alert.alert("QR Login Failed", message);
const message = err instanceof Error ? err.message : t("login.qr_login_failed");
Alert.alert(t("login.qr_login_failed"), message);
} finally {
setQrLoading(false);
}
@@ -70,11 +73,11 @@ export default function LoginScreen() {
async function handleSignIn() {
const url = serverUrl.trim();
if (!url) {
Alert.alert("Server URL required", "Please enter the URL of your DocuElevate server.");
Alert.alert(t("login.server_url_required"), t("login.server_url_required_msg"));
return;
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
Alert.alert("Invalid URL", "The server URL must start with http:// or https://");
Alert.alert(t("login.invalid_url"), t("login.invalid_url_msg"));
return;
}
@@ -82,8 +85,8 @@ export default function LoginScreen() {
try {
await signIn(url);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Sign-in failed";
Alert.alert("Sign-in failed", message);
const message = err instanceof Error ? err.message : t("login.sign_in_failed");
Alert.alert(t("login.sign_in_failed"), message);
} finally {
setLoading(false);
}
@@ -104,12 +107,12 @@ export default function LoginScreen() {
/>
<Text style={styles.logoText}>DocuElevate</Text>
</View>
<Text style={styles.tagline}>Intelligent Document Processing</Text>
<Text style={styles.tagline}>{t("welcome.tagline")}</Text>
<Text style={styles.label}>Server URL</Text>
<Text style={styles.label}>{t("login.server_url")}</Text>
<TextInput
style={styles.input}
placeholder="https://your-docuelevate-server.com"
placeholder={t("login.server_url_placeholder")}
placeholderTextColor="#9ca3af"
value={serverUrl}
onChangeText={setServerUrl}
@@ -118,7 +121,7 @@ export default function LoginScreen() {
keyboardType="url"
returnKeyType="go"
onSubmitEditing={handleSignIn}
accessibilityLabel="Server URL"
accessibilityLabel={t("login.server_url")}
/>
<Pressable
@@ -126,18 +129,18 @@ export default function LoginScreen() {
onPress={handleSignIn}
disabled={loading || qrLoading}
accessibilityRole="button"
accessibilityLabel="Sign in with SSO"
accessibilityLabel={t("login.sign_in_sso")}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign in with SSO</Text>
<Text style={styles.buttonText}>{t("login.sign_in_sso")}</Text>
)}
</Pressable>
<View style={styles.dividerRow}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or</Text>
<Text style={styles.dividerText}>{t("login.or")}</Text>
<View style={styles.dividerLine} />
</View>
@@ -148,27 +151,64 @@ export default function LoginScreen() {
}}
disabled={loading || qrLoading}
accessibilityRole="button"
accessibilityLabel="Sign in with QR code"
accessibilityLabel={t("login.scan_qr")}
>
{qrLoading ? (
<ActivityIndicator color="#1e40af" />
) : (
<Text style={styles.qrButtonText}>📱 Scan QR Code to Login</Text>
<Text style={styles.qrButtonText}>{t("login.scan_qr")}</Text>
)}
</Pressable>
<Text style={styles.hint}>
Sign in via SSO or scan a QR code from the web app.
</Text>
<Text style={styles.hint}>{t("login.hint")}</Text>
<Pressable
onPress={() => router.back()}
accessibilityRole="button"
accessibilityLabel="Back to welcome screen"
accessibilityLabel={t("login.back")}
style={styles.backLink}
>
<Text style={styles.backLinkText}> Back</Text>
<Text style={styles.backLinkText}>{t("login.back")}</Text>
</Pressable>
{/* Legal links accessible pre-login for GDPR / Apple compliance */}
<View style={styles.legalLinks}>
<Pressable
onPress={() => {
const base = serverUrl.trim() || "https://app.docuelevate.org";
Linking.openURL(`${base.replace(/\/$/, "")}/privacy`);
}}
accessibilityRole="link"
accessibilityLabel={t("legal.privacy_policy")}
style={styles.legalLinkButton}
>
<Text style={styles.legalLinkText}>{t("legal.privacy_policy")}</Text>
</Pressable>
<Text style={styles.legalSeparator}>·</Text>
<Pressable
onPress={() => {
const base = serverUrl.trim() || "https://app.docuelevate.org";
Linking.openURL(`${base.replace(/\/$/, "")}/terms`);
}}
accessibilityRole="link"
accessibilityLabel={t("legal.terms")}
style={styles.legalLinkButton}
>
<Text style={styles.legalLinkText}>{t("legal.terms")}</Text>
</Pressable>
<Text style={styles.legalSeparator}>·</Text>
<Pressable
onPress={() => {
const base = serverUrl.trim() || "https://app.docuelevate.org";
Linking.openURL(`${base.replace(/\/$/, "")}/imprint`);
}}
accessibilityRole="link"
accessibilityLabel={t("legal.imprint")}
style={styles.legalLinkButton}
>
<Text style={styles.legalLinkText}>{t("legal.imprint")}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
);
@@ -291,4 +331,26 @@ const styles = StyleSheet.create({
fontSize: 13,
color: "#6b7280",
},
legalLinks: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginTop: 16,
flexWrap: "wrap",
},
legalLinkButton: {
minHeight: 44,
justifyContent: "center",
paddingHorizontal: 4,
},
legalLinkText: {
fontSize: 12,
color: "#9ca3af",
textDecorationLine: "underline",
},
legalSeparator: {
fontSize: 12,
color: "#d1d5db",
marginHorizontal: 4,
},
});
+222 -14
View File
@@ -2,6 +2,8 @@
* ProfileScreen authenticated user profile and settings.
*/
import Constants from "expo-constants";
import * as Linking from "expo-linking";
import React from "react";
import {
Alert,
@@ -9,30 +11,84 @@ import {
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import { useLocale, getSupportedLanguages, t } from "../i18n";
import api from "../services/api";
const DEFAULT_SERVER_URL = "https://app.docuelevate.org";
export default function ProfileScreen() {
const { user, signOut, baseUrl } = useAuth();
const { lang, setLang } = useLocale();
const effectiveBaseUrl = baseUrl || DEFAULT_SERVER_URL;
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
const languages = getSupportedLanguages();
async function handleLanguageSelect(code: string) {
await setLang(code);
// Fire-and-forget: sync the choice to the server so it persists across
// platforms (desktop web will reflect this preference too).
api.setServerLanguage(code).catch(() => {
// Network errors are non-critical the local change is already applied.
});
}
function handleSignOut() {
Alert.alert("Sign out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
Alert.alert(t("profile.sign_out_title"), t("profile.sign_out_msg"), [
{ text: t("common.cancel"), style: "cancel" },
{
text: "Sign out",
text: t("profile.sign_out"),
style: "destructive",
onPress: signOut,
},
]);
}
function handleDeleteAccount() {
Alert.alert(
t("profile.delete_account_title"),
t("profile.delete_account_msg"),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("profile.delete_account"),
style: "destructive",
onPress: () => {
Linking.openURL(`${effectiveBaseUrl}/account/delete`).catch(() => {
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.delete_account") }));
});
},
},
]
);
}
function openPrivacyPolicy() {
Linking.openURL(`${effectiveBaseUrl}/privacy`).catch(() => {
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.privacy_policy") }));
});
}
function openTermsOfService() {
Linking.openURL(`${effectiveBaseUrl}/terms`).catch(() => {
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.terms_of_service") }));
});
}
function openImprint() {
Linking.openURL(`${effectiveBaseUrl}/imprint`).catch(() => {
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.imprint") }));
});
}
if (!user) {
return (
<View style={styles.center}>
<Text style={styles.emptyText}>Not signed in</Text>
<Text style={styles.emptyText}>{t("profile.not_signed_in")}</Text>
</View>
);
}
@@ -56,44 +112,121 @@ export default function ProfileScreen() {
)}
<Text style={styles.displayName}>{user.display_name ?? user.owner_id}</Text>
{user.email && <Text style={styles.email}>{user.email}</Text>}
{user.is_admin && <Text style={styles.adminBadge}>Admin</Text>}
{user.is_admin && <Text style={styles.adminBadge}>{t("profile.admin")}</Text>}
</View>
{/* Server info */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Connection</Text>
<Text style={styles.sectionTitle}>{t("profile.connection")}</Text>
<View style={styles.row}>
<Text style={styles.rowLabel}>Server</Text>
<Text style={styles.rowLabel}>{t("profile.server")}</Text>
<Text style={styles.rowValue} numberOfLines={1}>
{baseUrl || ""}
{effectiveBaseUrl}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.rowLabel}>User ID</Text>
<Text style={styles.rowLabel}>{t("profile.user_id")}</Text>
<Text style={styles.rowValue} numberOfLines={1}>
{user.owner_id}
</Text>
</View>
</View>
{/* Danger zone */}
{/* Settings */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t("profile.settings")}</Text>
<Text style={styles.settingLabel}>{t("profile.language")}</Text>
<View style={styles.languageGrid}>
{languages.map((l) => (
<Pressable
key={l.code}
style={[
styles.languageChip,
lang === l.code && styles.languageChipActive,
]}
onPress={() => handleLanguageSelect(l.code)}
accessibilityRole="button"
accessibilityLabel={`Set language to ${l.label}`}
accessibilityState={{ selected: lang === l.code }}
>
<Text
style={[
styles.languageChipText,
lang === l.code && styles.languageChipTextActive,
]}
>
{l.label}
</Text>
</Pressable>
))}
</View>
</View>
{/* Legal & Privacy */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t("profile.legal")}</Text>
<Pressable
style={styles.linkRow}
onPress={openPrivacyPolicy}
accessibilityRole="link"
accessibilityLabel={t("profile.privacy_policy")}
>
<Text style={styles.linkText}>{t("profile.privacy_policy")}</Text>
<Text style={styles.linkChevron}></Text>
</Pressable>
<Pressable
style={styles.linkRow}
onPress={openTermsOfService}
accessibilityRole="link"
accessibilityLabel={t("profile.terms_of_service")}
>
<Text style={styles.linkText}>{t("profile.terms_of_service")}</Text>
<Text style={styles.linkChevron}></Text>
</Pressable>
<Pressable
style={[styles.linkRow, styles.linkRowLast]}
onPress={openImprint}
accessibilityRole="link"
accessibilityLabel={t("profile.imprint")}
>
<Text style={styles.linkText}>{t("profile.imprint")}</Text>
<Text style={styles.linkChevron}></Text>
</Pressable>
</View>
{/* Sign out */}
<View style={styles.section}>
<Pressable
style={styles.signOutButton}
onPress={handleSignOut}
accessibilityRole="button"
accessibilityLabel="Sign out"
accessibilityLabel={t("profile.sign_out")}
>
<Text style={styles.signOutText}>Sign out</Text>
<Text style={styles.signOutText}>{t("profile.sign_out")}</Text>
</Pressable>
</View>
{/* Account deletion Apple Guideline 5.1.1(v) */}
<View style={styles.section}>
<Pressable
style={styles.deleteAccountButton}
onPress={handleDeleteAccount}
accessibilityRole="button"
accessibilityLabel={t("profile.delete_account")}
>
<Text style={styles.deleteAccountText}>{t("profile.delete_account")}</Text>
</Pressable>
</View>
{/* App version */}
<Text style={styles.versionText}>DocuElevate v{appVersion}</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
scroll: { flex: 1, backgroundColor: "#f9fafb" },
content: { padding: 20 },
content: { padding: 20, paddingBottom: 40 },
center: {
flex: 1,
alignItems: "center",
@@ -180,6 +313,27 @@ const styles = StyleSheet.create({
maxWidth: "60%",
textAlign: "right",
},
linkRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
minHeight: 44,
},
linkRowLast: {
borderBottomWidth: 0,
},
linkText: {
fontSize: 15,
color: "#1e40af",
},
linkChevron: {
fontSize: 18,
color: "#9ca3af",
fontWeight: "600",
},
signOutButton: {
backgroundColor: "#fee2e2",
borderRadius: 10,
@@ -192,4 +346,58 @@ const styles = StyleSheet.create({
fontWeight: "700",
fontSize: 15,
},
deleteAccountButton: {
backgroundColor: "#ffffff",
borderRadius: 10,
borderWidth: 1,
borderColor: "#dc2626",
paddingVertical: 14,
alignItems: "center",
minHeight: 48,
},
deleteAccountText: {
color: "#dc2626",
fontWeight: "600",
fontSize: 14,
},
versionText: {
fontSize: 12,
color: "#9ca3af",
textAlign: "center",
marginTop: 8,
},
settingLabel: {
fontSize: 14,
color: "#374151",
fontWeight: "500",
marginBottom: 10,
},
languageGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
languageChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: "#f3f4f6",
borderWidth: 1,
borderColor: "#e5e7eb",
minHeight: 36,
justifyContent: "center",
},
languageChipActive: {
backgroundColor: "#dbeafe",
borderColor: "#1e40af",
},
languageChipText: {
fontSize: 13,
color: "#6b7280",
fontWeight: "500",
},
languageChipTextActive: {
color: "#1e40af",
fontWeight: "700",
},
});
+145 -49
View File
@@ -12,7 +12,9 @@
* track the real-time processing status of each uploaded file.
*/
import { Ionicons } from "@expo/vector-icons";
import * as DocumentPicker from "expo-document-picker";
import * as FileSystem from "expo-file-system";
import * as ImagePicker from "expo-image-picker";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
@@ -26,7 +28,9 @@ import {
} from "react-native";
import { useAuth } from "../context/AuthContext";
import { useShare } from "../context/ShareContext";
import { normalizeFileUri } from "../utils/normalizeUri";
import api from "../services/api";
import { useLocale, t } from "../i18n";
/** Statuses that indicate processing has finished (no further polling needed). */
const TERMINAL_STATUSES = new Set(["completed", "failed", "duplicate"]);
@@ -53,6 +57,8 @@ export default function UploadScreen() {
const { isAuthenticated } = useAuth();
const { pendingFiles, clearPendingFiles } = useShare();
const [uploads, setUploads] = useState<UploadItem[]>([]);
// Subscribe to language changes so translated strings re-render.
useLocale();
// Keep a ref in sync so the polling interval can read current state without
// capturing a stale closure.
@@ -61,16 +67,85 @@ export default function UploadScreen() {
uploadsRef.current = uploads;
}, [uploads]);
// Track URIs that have already been uploaded in this session so that
// duplicate share-sheet deliveries (iOS can fire both the Linking handler
// and +not-found.tsx for the same file) do not trigger repeated uploads.
const uploadedUrisRef = useRef<Set<string>>(new Set());
// ---------------------------------------------------------------------------
// Core helpers (declared before the effects that depend on them)
// ---------------------------------------------------------------------------
/**
* Ensure a file URI is accessible for upload.
*
* Files received via the iOS Share Sheet / "Open In…" may reference paths
* outside the app's sandbox or use security-scoped URLs that React Native's
* fetch cannot read directly. This helper copies such files to the app's
* cache directory so the upload can proceed reliably.
*
* URIs from expo-image-picker and expo-document-picker are already in the
* app's cache and are returned unchanged.
*/
const ensureLocalUri = useCallback(async (uri: string, filename: string): Promise<string> => {
// Android content:// URIs are handled natively by React Native's fetch.
if (!uri.startsWith("file://")) return uri;
// Files already in the app's cache or documents directory are accessible.
const cacheDir = FileSystem.cacheDirectory;
const docDir = FileSystem.documentDirectory;
if (cacheDir && uri.startsWith(cacheDir)) return uri;
if (docDir && uri.startsWith(docDir)) return uri;
// External file (e.g. from iOS Inbox or security-scoped URL) copy to
// cache so the upload has guaranteed read access.
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const destUri = `${cacheDir}shared_${Date.now()}_${safeName}`;
try {
await FileSystem.copyAsync({ from: uri, to: destUri });
return destUri;
} catch (copyErr) {
// Copy failed fall back to the original URI (might work for some paths).
console.warn("[ensureLocalUri] copyAsync failed:", { from: uri, to: destUri, error: copyErr });
return uri;
}
}, []);
const uploadFile = useCallback(async (uri: string, filename: string, mimeType?: string) => {
const id = `${Date.now()}-${filename}`;
// Deduplicate: skip if this exact URI was already uploaded in this session.
// This guards against duplicate share-sheet deliveries from iOS where the
// Linking handler and +not-found.tsx fire for the same file.
const normUri = normalizeFileUri(uri);
if (uploadedUrisRef.current.has(normUri)) {
console.debug("[uploadFile] skipping duplicate URI:", uri);
return;
}
uploadedUrisRef.current.add(normUri);
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}-${filename}`;
setUploads((prev) => [{ id, filename, status: "uploading", uri, mimeType }, ...prev]);
try {
const resp = await api.uploadFile(uri, filename, mimeType);
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
@@ -78,13 +153,16 @@ export default function UploadScreen() {
: 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,7 +177,23 @@ export default function UploadScreen() {
);
try {
const resp = await api.uploadFile(item.uri, item.filename, item.mimeType);
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
@@ -107,13 +201,14 @@ export default function UploadScreen() {
: 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,16 +304,19 @@ 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];
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()}.${ext}`;
const filename = asset.fileName ?? `photo_${Date.now()}_${i}.${ext}`;
await uploadFile(asset.uri, filename, asset.mimeType ?? "image/jpeg");
}
}
}
async function handleFilePicker() {
try {
@@ -234,14 +332,14 @@ export default function UploadScreen() {
}
}
} catch (err: unknown) {
Alert.alert("File picker error", err instanceof Error ? err.message : "Could not open file picker");
Alert.alert(t("upload.file_picker_error"), err instanceof Error ? err.message : t("upload.file_picker_error_msg"));
}
}
if (!isAuthenticated) {
return (
<View style={styles.center}>
<Text style={styles.emptyText}>Please sign in to upload documents.</Text>
<Text style={styles.emptyText}>{t("upload.sign_in_required")}</Text>
</View>
);
}
@@ -254,30 +352,30 @@ export default function UploadScreen() {
style={[styles.actionButton, styles.cameraButton]}
onPress={handleCamera}
accessibilityRole="button"
accessibilityLabel="Capture document with camera"
accessibilityLabel={t("upload.capture_label")}
>
<Text style={styles.actionIcon}>📷</Text>
<Text style={styles.actionLabel}>Camera</Text>
<Ionicons name="camera-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>{t("upload.camera")}</Text>
</Pressable>
<Pressable
style={[styles.actionButton, styles.photoLibraryButton]}
onPress={handlePhotoLibrary}
accessibilityRole="button"
accessibilityLabel="Select photo from library"
accessibilityLabel={t("upload.photo_label")}
>
<Text style={styles.actionIcon}>🖼</Text>
<Text style={styles.actionLabel}>Photos</Text>
<Ionicons name="images-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>{t("upload.photos")}</Text>
</Pressable>
<Pressable
style={[styles.actionButton, styles.fileButton]}
onPress={handleFilePicker}
accessibilityRole="button"
accessibilityLabel="Pick file from device"
accessibilityLabel={t("upload.file_label")}
>
<Text style={styles.actionIcon}>📄</Text>
<Text style={styles.actionLabel}>Files</Text>
<Ionicons name="document-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>{t("upload.files")}</Text>
</Pressable>
</View>
@@ -285,13 +383,9 @@ export default function UploadScreen() {
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
{uploads.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyEmoji}></Text>
<Text style={styles.emptyText}>
Tap Camera, Photos, or Files to upload a document.
</Text>
<Text style={styles.emptyHint}>
You can also share files from other apps directly to DocuElevate.
</Text>
<Ionicons name="cloud-upload-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
<Text style={styles.emptyText}>{t("upload.empty_title")}</Text>
<Text style={styles.emptyHint}>{t("upload.empty_hint")}</Text>
</View>
) : (
uploads.map((item) => (
@@ -304,21 +398,24 @@ export default function UploadScreen() {
}
function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) {
const uploadIcons: Record<UploadItem["status"], string> = {
pending: "⏳",
uploading: "⬆️",
done: "✅",
error: "❌",
// Subscribe to language changes so status labels re-render.
useLocale();
const uploadIconProps: Record<UploadItem["status"], { name: keyof typeof Ionicons.glyphMap; color: string }> = {
pending: { name: "time-outline", color: "#6b7280" },
uploading: { name: "arrow-up-circle-outline", color: "#1e40af" },
done: { name: "checkmark-circle", color: "#059669" },
error: { name: "close-circle", color: "#dc2626" },
};
/** Human-readable label for the server-side processing status. */
function serverStatusLabel(s: string): string {
const labels: Record<string, string> = {
pending: "Queued for processing…",
processing: "Processing",
completed: "Processed ✓",
failed: "Processing failed",
duplicate: "Duplicate already processed",
pending: t("upload.status_queued"),
processing: t("upload.status_processing"),
completed: t("upload.status_completed"),
failed: t("upload.status_failed"),
duplicate: t("upload.status_duplicate"),
};
return labels[s] ?? s;
}
@@ -327,9 +424,9 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
function handleLongPress() {
if (!canRetry) return;
Alert.alert("Retry Upload", `Do you want to retry uploading "${item.filename}"?`, [
{ text: "Cancel", style: "cancel" },
{ text: "Retry", onPress: () => onRetry(item) },
Alert.alert(t("upload.retry_title"), t("upload.retry_msg", { filename: item.filename }), [
{ text: t("common.cancel"), style: "cancel" },
{ text: t("common.retry"), onPress: () => onRetry(item) },
]);
}
@@ -339,10 +436,10 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
onPress={canRetry ? () => onRetry(item) : undefined}
style={rowStyles.row}
accessibilityRole={canRetry ? "button" : "none"}
accessibilityLabel={canRetry ? `Retry uploading ${item.filename}` : undefined}
accessibilityLabel={canRetry ? `${t("common.retry")} ${item.filename}` : undefined}
accessibilityHint={canRetry ? "Tap or long-press to retry this upload" : undefined}
>
<Text style={rowStyles.icon}>{uploadIcons[item.status]}</Text>
<Ionicons name={uploadIconProps[item.status].name} size={22} color={uploadIconProps[item.status].color} style={rowStyles.icon} />
<View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}>
{item.filename}
@@ -351,7 +448,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
<ActivityIndicator size="small" color="#1e40af" />
)}
{item.status === "done" && !item.serverStatus && (
<Text style={rowStyles.statusQueued}>Queued for processing</Text>
<Text style={rowStyles.statusQueued}>{t("upload.status_queued")}</Text>
)}
{item.status === "done" && item.serverStatus && (
<Text
@@ -370,7 +467,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
<View>
<Text style={rowStyles.statusError}>{item.error}</Text>
{canRetry && (
<Text style={rowStyles.retryHint}>Tap to retry</Text>
<Text style={rowStyles.retryHint}>{t("upload.tap_retry")}</Text>
)}
</View>
)}
@@ -397,7 +494,7 @@ const styles = StyleSheet.create({
cameraButton: { backgroundColor: "#1e40af" },
photoLibraryButton: { backgroundColor: "#7c3aed" },
fileButton: { backgroundColor: "#059669" },
actionIcon: { fontSize: 28, marginBottom: 6 },
actionIcon: { marginBottom: 6 },
actionLabel: {
color: "#fff",
fontSize: 14,
@@ -409,7 +506,6 @@ const styles = StyleSheet.create({
alignItems: "center",
paddingTop: 60,
},
emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: {
fontSize: 16,
color: "#374151",
@@ -443,7 +539,7 @@ const rowStyles = StyleSheet.create({
shadowRadius: 4,
elevation: 2,
},
icon: { fontSize: 22, marginRight: 12 },
icon: { marginRight: 12 },
info: { flex: 1 },
filename: {
fontSize: 14,
+81 -29
View File
@@ -6,6 +6,7 @@
*/
import { useRouter } from "expo-router";
import * as Linking from "expo-linking";
import React from "react";
import {
Image,
@@ -16,27 +17,31 @@ import {
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
const FEATURES: { icon: string; title: string; description: string }[] = [
{
icon: "🔍",
title: "OCR & Text Extraction",
description: "Convert scanned PDFs and images into fully searchable text automatically.",
},
{
icon: "🤖",
title: "AI Metadata Extraction",
description: "AI classifies documents and pulls out key fields like dates, amounts, and subjects.",
},
{
icon: "☁️",
title: "Multi-Cloud Storage",
description: "Route processed files to Dropbox, Google Drive, OneDrive, S3, Nextcloud, and more.",
},
];
import { useLocale, t } from "../i18n";
export default function WelcomeScreen() {
const router = useRouter();
// Subscribe to language changes so translated strings re-render.
useLocale();
const features = [
{
icon: "🔍",
title: t("welcome.feature_ocr_title"),
description: t("welcome.feature_ocr_desc"),
},
{
icon: "🤖",
title: t("welcome.feature_ai_title"),
description: t("welcome.feature_ai_desc"),
},
{
icon: "☁️",
title: t("welcome.feature_cloud_title"),
description: t("welcome.feature_cloud_desc"),
},
];
return (
<SafeAreaView style={styles.safe}>
<ScrollView
@@ -54,16 +59,13 @@ export default function WelcomeScreen() {
/>
</View>
<Text style={styles.appName}>DocuElevate</Text>
<Text style={styles.tagline}>Intelligent Document Processing</Text>
<Text style={styles.heroDescription}>
Ingest documents, run OCR, extract metadata with AI, and route files
to your cloud storage all in one seamless pipeline.
</Text>
<Text style={styles.tagline}>{t("welcome.tagline")}</Text>
<Text style={styles.heroDescription}>{t("welcome.description")}</Text>
</View>
{/* Feature highlights */}
<View style={styles.features}>
{FEATURES.map((feature) => (
{features.map((feature) => (
<View key={feature.title} style={styles.featureRow}>
<Text style={styles.featureIcon} aria-hidden={true}>{feature.icon}</Text>
<View style={styles.featureText}>
@@ -79,14 +81,42 @@ export default function WelcomeScreen() {
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
onPress={() => router.push("/(auth)/login")}
accessibilityRole="button"
accessibilityLabel="Get started — connect to your DocuElevate server"
accessibilityLabel={t("welcome.get_started")}
>
<Text style={styles.buttonText}>Get Started</Text>
<Text style={styles.buttonText}>{t("welcome.get_started")}</Text>
</Pressable>
<Text style={styles.hint}>
Connect to your self-hosted or cloud DocuElevate server.
</Text>
<Text style={styles.hint}>{t("welcome.hint")}</Text>
{/* Legal links accessible pre-login for GDPR / Apple compliance */}
<View style={styles.legalLinks}>
<Pressable
onPress={() => Linking.openURL("https://app.docuelevate.org/privacy")}
accessibilityRole="link"
accessibilityLabel={t("legal.privacy_policy")}
style={styles.legalLinkButton}
>
<Text style={styles.legalLinkText}>{t("legal.privacy_policy")}</Text>
</Pressable>
<Text style={styles.legalSeparator}>·</Text>
<Pressable
onPress={() => Linking.openURL("https://app.docuelevate.org/terms")}
accessibilityRole="link"
accessibilityLabel={t("legal.terms")}
style={styles.legalLinkButton}
>
<Text style={styles.legalLinkText}>{t("legal.terms")}</Text>
</Pressable>
<Text style={styles.legalSeparator}>·</Text>
<Pressable
onPress={() => Linking.openURL("https://app.docuelevate.org/imprint")}
accessibilityRole="link"
accessibilityLabel={t("legal.imprint")}
style={styles.legalLinkButton}
>
<Text style={styles.legalLinkText}>{t("legal.imprint")}</Text>
</Pressable>
</View>
</ScrollView>
</SafeAreaView>
);
@@ -206,4 +236,26 @@ const styles = StyleSheet.create({
color: "rgba(255,255,255,0.55)",
textAlign: "center",
},
legalLinks: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginTop: 20,
flexWrap: "wrap",
},
legalLinkButton: {
minHeight: 44,
justifyContent: "center",
paddingHorizontal: 4,
},
legalLinkText: {
fontSize: 12,
color: "rgba(255,255,255,0.65)",
textDecorationLine: "underline",
},
legalSeparator: {
fontSize: 12,
color: "rgba(255,255,255,0.45)",
marginHorizontal: 4,
},
});
+44 -1
View File
@@ -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<WhoAmIResponse>("GET", "/api/mobile/whoami");
}
/** Sync the user's preferred UI language to the server. */
async setServerLanguage(lang: string): Promise<void> {
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<FileDetail> {
return this.request<FileDetail>("GET", `/api/files/${fileId}`);
}
}
export const api = new DocuElevateAPI();
+44
View File
@@ -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<string, string> = {
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;
}
+20
View File
@@ -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;
}