fix: merge main, address code review feedback for security fix PR #816

- Merge origin/main into branch (resolve conflict in integrations_dashboard.html)
- Add defensive JSON parsing with try/except for integration.config
- Wrap tester() call in try/except to prevent 500 errors from bad config
- Add i18n key integrations.connection_test_failed_fallback in en.json
- Reference i18n key in template JS fallback message
- Update SECURITY_AUDIT.md: add fix date (2026-03-23), update doc date
- Remove accidental revert.sh file
- Fix missing MagicMock/patch imports in test file
- Add tests for invalid JSON config and tester exception error paths

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/daebb70e-059a-4601-8864-88eef49f99cf
This commit is contained in:
copilot-swe-agent[bot]
2026-03-23 16:21:09 +00:00
parent 41d6f682c0
commit 1a195a96bd
359 changed files with 139902 additions and 2252 deletions
+10 -1
View File
@@ -1,9 +1,11 @@
name: Create builds
name: Build and submit
on:
push:
branches:
- main
paths:
- mobile/**
jobs:
build_android:
@@ -27,3 +29,10 @@ jobs:
params:
platform: ios
profile: production
submit_ios:
name: Submit iOS to App Store Connect
needs: [build_ios]
type: submit
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
+59 -9
View File
@@ -6,6 +6,7 @@ Native mobile application for DocuElevate, built with **React Native** and **Exp
- 🔐 **SSO Login** authenticate via your DocuElevate server's OAuth2/SSO provider; an API token is auto-generated and stored securely in the device keychain
- 📷 **Camera Capture** scan documents directly with the device camera
- 🖼️ **Photo Library** select existing photos from the device's photo library for upload
- 📄 **File Picker** upload PDFs, images, and Office documents from the device's Files app
- 🔗 **Share Extension** send files from any app directly to DocuElevate via the iOS/Android share sheet
- 🔔 **Push Notifications** receive real-time push notifications when documents finish processing (via Expo push notifications)
@@ -78,6 +79,25 @@ eas build --platform all
- The app runs and bundles correctly without `google-services.json`; only Android push notifications will be unavailable
- For Play Store submission: create a service account in Google Play Console, download the JSON key as `google-play-service-account.json`, and update `eas.json`
## CI/CD
An EAS Cloud Workflow (`mobile/.eas/workflows/create-builds.yml`) runs automatically when changes inside `mobile/` are pushed to `main`:
1. **Path filtering** — only commits that modify files under `mobile/` trigger a build; backend-only changes are skipped.
2. **Parallel builds** — iOS and Android production builds run at the same time on EAS Build.
3. **Auto-submit to Apple** — after the iOS build succeeds, the workflow submits the binary to App Store Connect (TestFlight) using the credentials in `eas.json``submit.production.ios`.
> An [App Store Connect API Key](https://docs.expo.dev/app-signing/app-credentials/#app-store-connect-api-key) must be configured in EAS (`eas credentials`) for non-interactive submission.
### Version management
Build numbers (`ios.buildNumber` / `android.versionCode`) are managed **remotely** by EAS — see `eas.json`:
- `"appVersionSource": "remote"` — EAS tracks the current build number on its servers, so each CI build automatically receives a unique, incrementing number without committing changes back to the repo.
- `"autoIncrement": true` (production profile) — EAS bumps the build number before every production build.
The values in `app.json` are used as the **initial seed** when the remote version is first created; after that they are informational only. Use `eas build:version:get` / `eas build:version:set` to inspect or override the remote version.
## Configuration
No code changes are needed to point the app at a different server. The server URL is entered by the user on the login screen and stored in the device's secure store.
@@ -119,29 +139,58 @@ mobile/
├── tsconfig.json
└── src/
├── context/
── AuthContext.tsx # Authentication state management
── AuthContext.tsx # Authentication state management
│ └── ShareContext.tsx # Shared-file queue (iOS Share Sheet / Android Intent)
├── hooks/
│ └── usePushNotifications.ts # Push notification registration
├── screens/
│ ├── WelcomeScreen.tsx # Branded intro / onboarding
│ ├── LoginScreen.tsx # SSO login
│ ├── UploadScreen.tsx # Camera capture + file picker
│ ├── UploadScreen.tsx # Camera capture + photo library + file picker
│ ├── FilesScreen.tsx # Document list
│ └── ProfileScreen.tsx # User profile + sign out
└── services/
└── api.ts # DocuElevate API client
```
## Share Extension (iOS)
## Share Sheet (iOS) / Share Intent (Android)
The app registers the `docuelevate://` URL scheme and the `com.docuelevate.app` bundle identifier. To enable the share sheet:
The app registers itself as a share target so any file can be sent directly to DocuElevate from another app.
1. Ensure the app is installed on the device
2. Open any file in Files, Mail, Safari, etc.
3. Tap the share icon → find **DocuElevate** in the share sheet
4. The file is uploaded immediately
### iOS how it works
Android uses a similar intent filter configured in `app.json`.
`app.json` declares `CFBundleDocumentTypes` in the iOS `infoPlist` section. This tells iOS which file types the app can receive, causing it to appear in the share sheet when the user shares a matching file. When the user taps **DocuElevate** in the share sheet, iOS passes the file path to the app via `application:openURL:options:`. The URL may arrive as a standard `file://` path or under the app's custom `docuelevate://` scheme.
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:
1. Ensure the app is installed on the device.
2. Open any supported file in Files, Mail, Safari, etc.
3. Tap the **Share** button → find **DocuElevate** in the share sheet.
4. The file is uploaded immediately.
> **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.
## Backend API
@@ -156,6 +205,7 @@ The mobile app uses the following backend endpoints:
| `GET` | `/api/mobile/whoami` | Get current user profile |
| `POST` | `/api/ui-upload` | Upload file for processing |
| `GET` | `/api/files` | List processed documents |
| `GET` | `/api/files/{id}` | Get processing status of a single file |
Authentication uses `Authorization: Bearer <api_token>` on all requests.
+66 -9
View File
@@ -11,19 +11,42 @@
"resizeMode": "contain",
"backgroundColor": "#1e40af"
},
"assetBundlePatterns": ["**/*"],
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "org.docuelevate.mobile",
"appleTeamId": "975U2ZESBM",
"infoPlist": {
"NSCameraUsageDescription": "DocuElevate uses the camera to capture documents for upload.",
"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"],
"ITSAppUsesNonExemptEncryption": false
"UIBackgroundModes": ["remote-notification"],
"ITSAppUsesNonExemptEncryption": false,
"LSSupportsOpeningDocumentsInPlace": false,
"CFBundleDocumentTypes": [
{
"CFBundleTypeName": "All Documents",
"CFBundleTypeRole": "Viewer",
"LSHandlerRank": "Alternate",
"LSItemContentTypes": [
"public.content",
"public.data",
"public.image",
"com.adobe.pdf",
"public.plain-text",
"org.openxmlformats.wordprocessingml.document",
"org.openxmlformats.spreadsheetml.sheet",
"org.openxmlformats.presentationml.presentation",
"com.microsoft.word.doc",
"com.microsoft.excel.xls",
"com.microsoft.powerpoint.ppt"
]
}
]
},
"buildNumber": "3"
"buildNumber": "7"
},
"android": {
"adaptiveIcon": {
@@ -38,7 +61,19 @@
"RECEIVE_BOOT_COMPLETED",
"VIBRATE"
],
"versionCode": 1
"intentFilters": [
{
"action": "SEND",
"data": [{ "mimeType": "*/*" }],
"category": ["DEFAULT"]
},
{
"action": "SEND_MULTIPLE",
"data": [{ "mimeType": "*/*" }],
"category": ["DEFAULT"]
}
],
"versionCode": 7
},
"web": {
"favicon": "./assets/favicon.png",
@@ -51,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
}
}
}
],
@@ -60,13 +117,13 @@
{
"icon": "./assets/notification-icon.png",
"color": "#1e40af",
"sounds": ["./assets/notification-sound.wav"]
"sounds": ["./assets/notification_sound.wav"]
}
],
[
"expo-camera",
{
"cameraPermission": "DocuElevate uses the camera to capture documents for upload."
"cameraPermission": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload."
}
],
"expo-document-picker",
+1
View File
@@ -12,6 +12,7 @@ export default function AuthLayout() {
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="login" />
<Stack.Screen name="qr-scanner" />
</Stack>
);
}
+4
View File
@@ -0,0 +1,4 @@
/**
* QR scanner route camera-based QR code scanning for mobile login.
*/
export { default } from "../../src/screens/QRScannerScreen";
+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",
},
});
+123 -4
View File
@@ -4,23 +4,135 @@
* Wraps the entire app in AuthProvider and SafeAreaProvider, then uses the
* AuthGuard component to redirect between the unauthenticated (auth) route
* group and the authenticated (tabs) route group based on session state.
*
* ShareProvider + Linking listener: when iOS opens the app via the share
* 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";
import { Stack, useRouter, useSegments } from "expo-router";
import React, { useEffect } from "react";
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
// ---------------------------------------------------------------------------
/** 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 {
const decoded = decodeURIComponent(uri);
// Take the last path segment and strip any query string
const last = decoded.split("/").pop() ?? "shared_file";
return last.split("?")[0] || "shared_file";
} catch {
return "shared_file";
}
}
/**
* Build a Linking URL handler that forwards incoming file:// / content://
* 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
* 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; mimeType?: string }) => void) {
return ({ url }: { url: string }) => {
let fileUri = url;
// iOS may pass a filesystem path under the app's custom scheme.
// Rewrite it to a file:// URL unless it looks like an in-app deep-link
// (expo-router groups always start with "(").
if (url.startsWith(APP_SCHEME_PREFIX)) {
const path = url.slice(APP_SCHEME_PREFIX.length);
// 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;
const filename = filenameFromUri(fileUri);
addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) });
};
}
// ---------------------------------------------------------------------------
// Auth guard redirects to the correct route group after auth state resolves
// ---------------------------------------------------------------------------
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.
useEffect(() => {
const handleIncomingUrl = makeUrlHandler(addPendingFile);
// Cold start app launched directly by a share action
Linking.getInitialURL().then((url) => {
if (url) handleIncomingUrl({ url });
});
// Warm start app was already running when the share action occurred
const subscription = Linking.addEventListener("url", handleIncomingUrl);
return () => subscription.remove();
}, [addPendingFile]);
useEffect(() => {
if (isLoading) return;
@@ -46,8 +158,11 @@ function AuthGuard() {
return (
<Stack screenOptions={{ headerShown: false }}>
<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>
);
}
@@ -59,9 +174,13 @@ function AuthGuard() {
export default function RootLayout() {
return (
<SafeAreaProvider>
<AuthProvider>
<AuthGuard />
</AuthProvider>
<LocaleProvider>
<ShareProvider>
<AuthProvider>
<AuthGuard />
</AuthProvider>
</ShareProvider>
</LocaleProvider>
</SafeAreaProvider>
);
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Root index route redirects to the auth flow on launch.
*
* expo-router renders this when the "/" route is matched (i.e. on cold start).
* Without this file, a stale default scaffold page ("Hello World") can appear
* if one was left behind by a previous build or Expo CLI scaffolding.
*
* The redirect targets the (auth) group; the AuthGuard in _layout.tsx will
* immediately forward authenticated users to (tabs).
*/
import { Redirect } from "expo-router";
import React from "react";
export default function RootIndex() {
return <Redirect href="/(auth)/" />;
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"cli": {
"version": ">= 5.9.0",
"appVersionSource": "local"
"appVersionSource": "remote"
},
"build": {
"development": {
+1
View File
@@ -0,0 +1 @@
module.exports = require("eslint-config-expo/flat");
+132 -447
View File
@@ -20,13 +20,14 @@
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.8",
"expo-dev-client": "~6.0.20",
"expo-device": "~7.0.3",
"expo-device": "~8.0.10",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.0",
"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",
@@ -39,12 +40,12 @@
"react-native": "0.81.5",
"react-native-safe-area-context": "5.6.0",
"react-native-screens": "4.16.0",
"react-native-web": "~0.20.0"
"react-native-web": "~0.21.0"
},
"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",
@@ -3721,7 +3663,7 @@
"version": "19.1.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -5569,7 +5511,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/data-view-buffer": {
@@ -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"
@@ -6971,9 +6744,9 @@
}
},
"node_modules/expo-device": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/expo-device/-/expo-device-7.0.3.tgz",
"integrity": "sha512-uNGhDYmpDj/3GySWZmRiYSt52Phdim11p0pXfgpCq/nMks0+UPZwl3D0vin5N8/gpVe5yzb13GYuFxiVoDyniw==",
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz",
"integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==",
"license": "MIT",
"dependencies": {
"ua-parser-js": "^0.7.33"
@@ -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",
@@ -11114,9 +10848,9 @@
}
},
"node_modules/react-native-web": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.20.0.tgz",
"integrity": "sha512-OOSgrw+aON6R3hRosCau/xVxdLzbjEcsLysYedka0ZON4ZZe6n9xgeN9ZkoejhARM36oTlUgHIQqxGutEJ9Wxg==",
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.6",
@@ -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",
+5 -4
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",
@@ -30,12 +30,13 @@
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.8",
"expo-device": "~7.0.3",
"expo-device": "~8.0.10",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"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",
@@ -46,14 +47,14 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-web": "~0.20.0",
"react-native-web": "~0.21.0",
"react-native-safe-area-context": "5.6.0",
"react-native-screens": "4.16.0"
},
"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"
},
+16
View File
@@ -38,6 +38,7 @@ export interface AuthState {
user: WhoAmIResponse | null;
baseUrl: string;
signIn: (serverUrl: string) => Promise<void>;
signInWithQR: (serverUrl: string, challengeToken: string) => Promise<void>;
signOut: () => Promise<void>;
setToken: (token: string) => Promise<void>;
}
@@ -52,6 +53,7 @@ const AuthContext = createContext<AuthState>({
user: null,
baseUrl: "",
signIn: async () => {},
signInWithQR: async () => {},
signOut: async () => {},
setToken: async () => {},
});
@@ -143,6 +145,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
[setToken]
);
const signInWithQR = useCallback(
async (serverUrl: string, challengeToken: string) => {
const cleanUrl = serverUrl.replace(/\/$/, "");
await api.init(cleanUrl);
setBaseUrl(cleanUrl);
const deviceInfo = await _getDeviceName();
const resp = await api.claimQRChallenge(challengeToken, deviceInfo);
await setToken(resp.token);
},
[setToken]
);
const signOut = useCallback(async () => {
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
@@ -158,6 +173,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
user,
baseUrl,
signIn,
signInWithQR,
signOut,
setToken,
}}
+58
View File
@@ -0,0 +1,58 @@
/**
* ShareContext propagates files received from the iOS Share Sheet or the
* Android Share Intent to the UploadScreen so they can be uploaded
* automatically.
*
* The root layout listens for incoming file:// / content:// URLs via
* expo-linking and calls addPendingFile(). UploadScreen consumes the context,
* uploads each pending file, then calls clearPendingFiles().
*/
import React, { createContext, useCallback, useContext, useState } from "react";
import { normalizeFileUri } from "../utils/normalizeUri";
export interface SharedFile {
uri: string;
filename: string;
mimeType?: string;
}
interface ShareContextValue {
pendingFiles: SharedFile[];
addPendingFile: (file: SharedFile) => void;
clearPendingFiles: () => void;
}
const ShareContext = createContext<ShareContextValue>({
pendingFiles: [],
addPendingFile: () => {},
clearPendingFiles: () => {},
});
export function ShareProvider({ children }: { children: React.ReactNode }) {
const [pendingFiles, setPendingFiles] = useState<SharedFile[]>([]);
const addPendingFile = useCallback((file: SharedFile) => {
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(() => {
setPendingFiles([]);
}, []);
return (
<ShareContext.Provider value={{ pendingFiles, addPendingFile, clearPendingFiles }}>
{children}
</ShareContext.Provider>
);
}
export function useShare(): ShareContextValue {
return useContext(ShareContext);
}
+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" },
});
+173 -55
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> = {
processed: "✅",
processing: "⚙️",
queued: "⏳",
failed: "❌",
uploaded: "⬆️",
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,67 +148,134 @@ export default function FilesScreen() {
);
}
if (error) {
if (error && files.length === 0) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={handleRefresh}>
<Text style={styles.retryText}>Retry</Text>
<Text style={styles.retryText}>{t("common.retry")}</Text>
</Pressable>
</View>
);
}
return (
<FlatList
style={styles.list}
data={files}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => <FileRow file={item} />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.4}
ListEmptyComponent={
<View style={styles.emptyState}>
<Text style={styles.emptyEmoji}>📂</Text>
<Text style={styles.emptyText}>No documents yet.</Text>
<Text style={styles.emptyHint}>
Upload a document from the Upload tab to get started.
</Text>
</View>
}
ListFooterComponent={
hasMore && files.length > 0 ? (
<ActivityIndicator color="#1e40af" style={{ marginVertical: 16 }} />
) : null
}
/>
<View style={styles.container}>
{/* Search bar */}
<View style={styles.searchContainer}>
<Ionicons name="search-outline" size={18} color="#9ca3af" style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder={t("files.search_placeholder")}
placeholderTextColor="#9ca3af"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
accessibilityLabel={t("common.search")}
/>
{searchQuery.length > 0 && (
<Pressable
onPress={handleClearSearch}
style={styles.clearButton}
accessibilityRole="button"
accessibilityLabel={t("common.clear_search")}
>
<Ionicons name="close-circle" size={18} color="#9ca3af" />
</Pressable>
)}
</View>
<FlatList
style={styles.list}
data={files}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => <FileRow file={item} onPress={handleFilePress} />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.4}
ListEmptyComponent={
<View style={styles.emptyState}>
<Ionicons name="folder-open-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
<Text style={styles.emptyText}>
{searchQuery ? t("files.search_empty") : t("files.empty_title")}
</Text>
<Text style={styles.emptyHint}>
{searchQuery ? t("files.search_empty_hint") : t("files.empty_hint")}
</Text>
</View>
}
ListFooterComponent={
hasMore && files.length > 0 ? (
<ActivityIndicator color="#1e40af" style={{ marginVertical: 16 }} />
) : null
}
/>
</View>
);
}
function FileRow({ file }: { file: FileRecord }) {
function FileRow({ file, onPress }: { file: FileRecord; onPress: (file: FileRecord) => void }) {
const status = file.processing_status?.status ?? "pending";
const icon = statusIcon(status);
return (
<View style={rowStyles.row}>
<Text style={rowStyles.icon}>{statusEmoji(file.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.filename}
{file.original_filename}
</Text>
<Text style={rowStyles.meta}>
{formatDate(file.created_at)} · {formatBytes(file.file_size)}
</Text>
</View>
<Text style={rowStyles.status}>{file.status}</Text>
</View>
<View style={rowStyles.right}>
<Text style={rowStyles.status}>{status}</Text>
<Ionicons name="chevron-forward" size={16} color="#d1d5db" />
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
list: { flex: 1, backgroundColor: "#f9fafb" },
listContent: { padding: 16 },
container: { flex: 1, backgroundColor: "#f9fafb" },
list: { flex: 1 },
listContent: { padding: 16, paddingTop: 0 },
searchContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff",
marginHorizontal: 16,
marginVertical: 12,
borderRadius: 10,
paddingHorizontal: 12,
borderWidth: 1,
borderColor: "#e5e7eb",
minHeight: 44,
},
searchIcon: { marginRight: 8 },
searchInput: {
flex: 1,
fontSize: 15,
color: "#111827",
paddingVertical: 10,
},
clearButton: {
padding: 4,
minWidth: 44,
minHeight: 44,
alignItems: "center",
justifyContent: "center",
},
center: {
flex: 1,
alignItems: "center",
@@ -178,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,
@@ -202,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,
@@ -211,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",
+176 -23
View File
@@ -1,13 +1,16 @@
/**
* LoginScreen server URL entry and SSO sign-in.
* LoginScreen server URL entry, SSO sign-in, and QR code login.
*
* Renders a server URL input and a "Sign in with SSO" button that opens the
* DocuElevate web login page in the system browser. On success the
* AuthContext stores the API token and navigates to the main app.
* Renders a server URL input, a "Sign in with SSO" button that opens the
* DocuElevate web login page in the system browser, and a "Scan QR Code"
* button that opens the device camera to scan a QR code generated from the
* web interface. On success the AuthContext stores the API token and
* navigates to the main app.
*/
import * as Linking from "expo-linking";
import { useRouter } from "expo-router";
import React, { useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
@@ -21,21 +24,60 @@ import {
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import { useLocale, t } from "../i18n";
export default function LoginScreen() {
const { signIn } = useAuth();
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(
async (event: { url: string }) => {
try {
const url = new URL(event.url);
if (url.hostname === "qr-login" || url.pathname === "/qr-login") {
const token = url.searchParams.get("token");
const server = url.searchParams.get("server");
if (token && server) {
setQrLoading(true);
await signInWithQR(server, token);
}
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t("login.qr_login_failed");
Alert.alert(t("login.qr_login_failed"), message);
} finally {
setQrLoading(false);
}
},
[signInWithQR]
);
useEffect(() => {
// Listen for incoming deep links
const subscription = Linking.addEventListener("url", handleDeepLink);
// Check if the app was opened via a deep link
Linking.getInitialURL().then((url) => {
if (url) handleDeepLink({ url });
});
return () => subscription.remove();
}, [handleDeepLink]);
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;
}
@@ -43,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);
}
@@ -65,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}
@@ -79,35 +121,94 @@ export default function LoginScreen() {
keyboardType="url"
returnKeyType="go"
onSubmitEditing={handleSignIn}
accessibilityLabel="Server URL"
accessibilityLabel={t("login.server_url")}
/>
<Pressable
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleSignIn}
disabled={loading}
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>
<Text style={styles.hint}>
You will be redirected to your organisation's sign-in page.
</Text>
<View style={styles.dividerRow}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>{t("login.or")}</Text>
<View style={styles.dividerLine} />
</View>
<Pressable
style={[styles.qrButton, qrLoading && styles.buttonDisabled]}
onPress={() => {
router.push("/(auth)/qr-scanner");
}}
disabled={loading || qrLoading}
accessibilityRole="button"
accessibilityLabel={t("login.scan_qr")}
>
{qrLoading ? (
<ActivityIndicator color="#1e40af" />
) : (
<Text style={styles.qrButtonText}>{t("login.scan_qr")}</Text>
)}
</Pressable>
<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>
);
@@ -184,6 +285,36 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: "600",
},
dividerRow: {
flexDirection: "row",
alignItems: "center",
marginVertical: 16,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: "#e5e7eb",
},
dividerText: {
marginHorizontal: 12,
fontSize: 12,
color: "#9ca3af",
},
qrButton: {
borderWidth: 1,
borderColor: "#1e40af",
borderRadius: 8,
paddingVertical: 14,
alignItems: "center",
justifyContent: "center",
minHeight: 48,
backgroundColor: "#eff6ff",
},
qrButtonText: {
color: "#1e40af",
fontSize: 15,
fontWeight: "600",
},
hint: {
marginTop: 16,
fontSize: 12,
@@ -200,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",
},
});
+295
View File
@@ -0,0 +1,295 @@
/**
* QRScannerScreen camera-based QR code scanner for mobile login.
*
* Opens the device camera and scans for QR codes containing a
* `docuelevate://qr-login?token=...&server=...` payload. On successful
* scan the token is claimed via the API and the user is signed in.
*/
import { CameraView, useCameraPermissions } from "expo-camera";
import { useRouter } from "expo-router";
import React, { useCallback, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
export default function QRScannerScreen() {
const { signInWithQR } = useAuth();
const router = useRouter();
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
const [processing, setProcessing] = useState(false);
const processingRef = useRef(false);
const handleBarCodeScanned = useCallback(
async (result: { data: string }) => {
// Prevent duplicate scans while processing
if (processingRef.current) return;
const { data } = result;
// Only accept docuelevate:// QR codes
if (!data.startsWith("docuelevate://qr-login")) return;
processingRef.current = true;
setScanned(true);
setProcessing(true);
try {
const url = new URL(data);
const token = url.searchParams.get("token");
const server = url.searchParams.get("server");
if (!token || !server) {
Alert.alert("Invalid QR Code", "This QR code does not contain valid login information.");
setScanned(false);
processingRef.current = false;
setProcessing(false);
return;
}
await signInWithQR(server, token);
// signInWithQR updates AuthContext → AuthGuard redirects to main app
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "QR login failed";
Alert.alert("QR Login Failed", message);
setScanned(false);
processingRef.current = false;
setProcessing(false);
}
},
[signInWithQR]
);
// Permissions not yet determined
if (!permission) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#1e40af" />
</View>
);
}
// Permission denied
if (!permission.granted) {
return (
<View style={styles.centered}>
<Text style={styles.permissionText}>
Camera access is required to scan QR codes.
</Text>
<Pressable
style={styles.permissionButton}
onPress={requestPermission}
accessibilityRole="button"
accessibilityLabel="Grant camera access"
>
<Text style={styles.permissionButtonText}>Grant Camera Access</Text>
</Pressable>
<Pressable
onPress={() => router.back()}
style={styles.backLink}
accessibilityRole="button"
accessibilityLabel="Go back"
>
<Text style={styles.backLinkText}> Back</Text>
</Pressable>
</View>
);
}
return (
<View style={styles.container}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{
barcodeTypes: ["qr"],
}}
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
/>
{/* Overlay with scan area indicator */}
<View style={styles.overlay}>
<View style={styles.overlayTop} />
<View style={styles.overlayMiddle}>
<View style={styles.overlaySide} />
<View style={styles.scanArea}>
<View style={[styles.corner, styles.cornerTopLeft]} />
<View style={[styles.corner, styles.cornerTopRight]} />
<View style={[styles.corner, styles.cornerBottomLeft]} />
<View style={[styles.corner, styles.cornerBottomRight]} />
</View>
<View style={styles.overlaySide} />
</View>
<View style={styles.overlayBottom}>
{processing ? (
<View style={styles.statusContainer}>
<ActivityIndicator color="#fff" />
<Text style={styles.statusText}>Signing in</Text>
</View>
) : (
<Text style={styles.instructionText}>
Point your camera at the QR code{"\n"}shown on the DocuElevate web app
</Text>
)}
<Pressable
onPress={() => router.back()}
style={styles.cancelButton}
accessibilityRole="button"
accessibilityLabel="Cancel QR scan"
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</Pressable>
</View>
</View>
</View>
);
}
const SCAN_AREA_SIZE = 250;
const CORNER_SIZE = 24;
const CORNER_WIDTH = 3;
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#000",
},
camera: {
flex: 1,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#f3f4f6",
padding: 24,
},
permissionText: {
fontSize: 16,
color: "#374151",
textAlign: "center",
marginBottom: 20,
},
permissionButton: {
backgroundColor: "#1e40af",
borderRadius: 8,
paddingVertical: 14,
paddingHorizontal: 24,
minHeight: 48,
alignItems: "center",
justifyContent: "center",
},
permissionButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
backLink: {
marginTop: 20,
minHeight: 44,
alignItems: "center",
justifyContent: "center",
},
backLinkText: {
fontSize: 14,
color: "#6b7280",
},
overlay: {
...StyleSheet.absoluteFillObject,
},
overlayTop: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
},
overlayMiddle: {
flexDirection: "row",
height: SCAN_AREA_SIZE,
},
overlaySide: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
},
scanArea: {
width: SCAN_AREA_SIZE,
height: SCAN_AREA_SIZE,
},
corner: {
position: "absolute",
width: CORNER_SIZE,
height: CORNER_SIZE,
},
cornerTopLeft: {
top: 0,
left: 0,
borderTopWidth: CORNER_WIDTH,
borderLeftWidth: CORNER_WIDTH,
borderColor: "#fff",
},
cornerTopRight: {
top: 0,
right: 0,
borderTopWidth: CORNER_WIDTH,
borderRightWidth: CORNER_WIDTH,
borderColor: "#fff",
},
cornerBottomLeft: {
bottom: 0,
left: 0,
borderBottomWidth: CORNER_WIDTH,
borderLeftWidth: CORNER_WIDTH,
borderColor: "#fff",
},
cornerBottomRight: {
bottom: 0,
right: 0,
borderBottomWidth: CORNER_WIDTH,
borderRightWidth: CORNER_WIDTH,
borderColor: "#fff",
},
overlayBottom: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
alignItems: "center",
paddingTop: 32,
},
statusContainer: {
flexDirection: "row",
alignItems: "center",
},
statusText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
marginLeft: 10,
},
instructionText: {
color: "#fff",
fontSize: 15,
textAlign: "center",
lineHeight: 22,
},
cancelButton: {
marginTop: 24,
paddingVertical: 12,
paddingHorizontal: 32,
borderRadius: 8,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.5)",
minHeight: 44,
alignItems: "center",
justifyContent: "center",
},
cancelButtonText: {
color: "#fff",
fontSize: 15,
fontWeight: "500",
},
});
+349 -53
View File
@@ -1,16 +1,22 @@
/**
* UploadScreen document upload via camera or file picker.
* UploadScreen document upload via camera, photo library, or file picker.
*
* Users can:
* 1. Take a photo of a document with the device camera.
* 2. Pick an existing file (PDF, image, Office document) from the Files app.
* 3. Receive files shared from other apps via the iOS Share Sheet / Android
* Share Intent (handled by the expo-sharing + deep-link integration).
* 2. Select an existing photo from the device's photo library.
* 3. Pick an existing file (PDF, image, Office document) from the Files app.
* 4. Receive files shared from other apps via the iOS Share Sheet / Android
* Share Intent (handled via ShareContext populated by the root layout).
*
* After a successful upload the screen polls the backend every 5 seconds to
* 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, { useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
@@ -21,7 +27,13 @@ import {
View,
} 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"]);
interface UploadItem {
id: string;
@@ -29,40 +41,238 @@ interface UploadItem {
status: "pending" | "uploading" | "done" | "error";
error?: string;
taskId?: string;
/** Sanitised filename returned by the server used to search for the record. */
originalFilename?: string;
/** Database ID of the FileRecord once it has been created by the worker. */
fileId?: number;
/** Actual server-side processing status (e.g. "pending", "processing", "completed"). */
serverStatus?: string;
/** Original file URI retained so the upload can be retried on failure. */
uri?: string;
/** MIME type of the original file. */
mimeType?: string;
}
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();
function updateItem(id: string, patch: Partial<UploadItem>) {
setUploads((prev) =>
prev.map((item) => (item.id === id ? { ...item, ...patch } : item))
);
}
// Keep a ref in sync so the polling interval can read current state without
// capturing a stale closure.
const uploadsRef = useRef<UploadItem[]>([]);
useEffect(() => {
uploadsRef.current = uploads;
}, [uploads]);
async function uploadFile(uri: string, filename: string, mimeType?: string) {
const id = `${Date.now()}-${filename}`;
setUploads((prev) => [
{ id, filename, status: "uploading" },
...prev,
]);
// 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) => {
// 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);
updateItem(id, { status: "done", taskId: resp.task_id });
const localUri = await ensureLocalUri(uri, filename);
const resp = await api.uploadFile(localUri, filename, mimeType);
if (resp.status === "duplicate" && resp.duplicate_of) {
// Server rejected the file as a known duplicate — mark as done and
// set the server-side status to "duplicate" so it appears as a
// terminal status and is not polled further.
setUploads((prev) =>
prev.map((item) =>
item.id === id
? {
...item,
status: "done",
fileId: resp.duplicate_of!.original_file_id,
originalFilename: resp.original_filename,
serverStatus: "duplicate",
}
: item
)
);
} else {
setUploads((prev) =>
prev.map((item) =>
item.id === id
? { ...item, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
: item
)
);
}
} catch (err: unknown) {
// Allow retrying this URI on failure.
uploadedUrisRef.current.delete(normUri);
const msg = err instanceof Error ? err.message : "Upload failed";
setUploads((prev) =>
prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item))
);
}
}, [ensureLocalUri]);
const retryUpload = useCallback(async (item: UploadItem) => {
if (!item.uri) return;
// Reset the item to "uploading" and clear previous error/server state.
setUploads((prev) =>
prev.map((u) =>
u.id === item.id
? { ...u, status: "uploading" as const, error: undefined, serverStatus: undefined, fileId: undefined, taskId: undefined, originalFilename: undefined }
: u
)
);
try {
const localUri = await ensureLocalUri(item.uri, item.filename);
const resp = await api.uploadFile(localUri, item.filename, item.mimeType);
if (resp.status === "duplicate" && resp.duplicate_of) {
setUploads((prev) =>
prev.map((u) =>
u.id === item.id
? {
...u,
status: "done",
fileId: resp.duplicate_of!.original_file_id,
originalFilename: resp.original_filename,
serverStatus: "duplicate",
}
: u
)
);
} else {
setUploads((prev) =>
prev.map((u) =>
u.id === item.id
? { ...u, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
: u
)
);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Upload failed";
updateItem(id, { status: "error", error: msg });
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
// ---------------------------------------------------------------------------
useEffect(() => {
const poll = async () => {
const current = uploadsRef.current;
for (const item of current) {
// Only poll items that were successfully uploaded and haven't reached a
// terminal status yet.
if (item.status !== "done") continue;
if (item.serverStatus && TERMINAL_STATUSES.has(item.serverStatus)) continue;
try {
if (item.fileId !== undefined) {
// We already know the file ID just refresh its status.
const ps = await api.getFileStatus(item.fileId);
setUploads((prev) =>
prev.map((u) => (u.id === item.id ? { ...u, serverStatus: ps.status } : u))
);
} else if (item.originalFilename) {
// Search for the file by name; it may not exist yet if the worker
// hasn't started.
const files = await api.listFiles(1, 5, item.originalFilename);
const found = files.find((f) => f.original_filename === item.originalFilename);
if (found) {
setUploads((prev) =>
prev.map((u) =>
u.id === item.id
? { ...u, fileId: found.id, serverStatus: found.processing_status.status }
: u
)
);
}
}
} catch (err) {
// Network errors are transient silently retry on the next tick.
console.debug("[StatusPoll] failed:", err);
}
}
};
const interval = setInterval(poll, 5000);
return () => clearInterval(interval);
}, []); // Intentionally empty poll() reads state via uploadsRef
// ---------------------------------------------------------------------------
// Handle files received from the iOS Share Sheet / Android Share Intent
// ---------------------------------------------------------------------------
useEffect(() => {
if (pendingFiles.length === 0 || !isAuthenticated) return;
const files = [...pendingFiles];
clearPendingFiles();
files.forEach((file) => {
void uploadFile(file.uri, file.filename, file.mimeType);
});
}, [pendingFiles, isAuthenticated, clearPendingFiles, uploadFile]);
async function handleCamera() {
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;
}
@@ -80,6 +290,34 @@ export default function UploadScreen() {
}
}
async function handlePhotoLibrary() {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") {
Alert.alert(
t("upload.photo_access_title"),
t("upload.photo_access_msg")
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
quality: 0.9,
allowsEditing: false,
allowsMultipleSelection: true,
});
if (!result.canceled && result.assets.length > 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()}_${i}.${ext}`;
await uploadFile(asset.uri, filename, asset.mimeType ?? "image/jpeg");
}
}
}
async function handleFilePicker() {
try {
const result = await DocumentPicker.getDocumentAsync({
@@ -94,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>
);
}
@@ -114,20 +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={t("upload.photo_label")}
>
<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}>File Picker</Text>
<Ionicons name="document-outline" size={28} color="#fff" style={styles.actionIcon} />
<Text style={styles.actionLabel}>{t("upload.files")}</Text>
</Pressable>
</View>
@@ -135,17 +383,13 @@ 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 or File Picker 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) => (
<UploadRow key={item.id} item={item} />
<UploadRow key={item.id} item={item} onRetry={retryUpload} />
))
)}
</ScrollView>
@@ -153,17 +397,49 @@ export default function UploadScreen() {
);
}
function UploadRow({ item }: { item: UploadItem }) {
const icons: Record<UploadItem["status"], string> = {
pending: "⏳",
uploading: "⬆️",
done: "✅",
error: "❌",
function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) {
// 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: 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;
}
const canRetry = item.status === "error" && !!item.uri;
function handleLongPress() {
if (!canRetry) return;
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) },
]);
}
return (
<View style={rowStyles.row}>
<Text style={rowStyles.icon}>{icons[item.status]}</Text>
<Pressable
onLongPress={handleLongPress}
onPress={canRetry ? () => onRetry(item) : undefined}
style={rowStyles.row}
accessibilityRole={canRetry ? "button" : "none"}
accessibilityLabel={canRetry ? `${t("common.retry")} ${item.filename}` : undefined}
accessibilityHint={canRetry ? "Tap or long-press to retry this upload" : undefined}
>
<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}
@@ -171,14 +447,32 @@ function UploadRow({ item }: { item: UploadItem }) {
{item.status === "uploading" && (
<ActivityIndicator size="small" color="#1e40af" />
)}
{item.status === "done" && (
<Text style={rowStyles.statusDone}>Queued for processing</Text>
{item.status === "done" && !item.serverStatus && (
<Text style={rowStyles.statusQueued}>{t("upload.status_queued")}</Text>
)}
{item.status === "done" && item.serverStatus && (
<Text
style={
item.serverStatus === "completed"
? rowStyles.statusDone
: item.serverStatus === "failed"
? rowStyles.statusError
: rowStyles.statusQueued
}
>
{serverStatusLabel(item.serverStatus)}
</Text>
)}
{item.status === "error" && (
<Text style={rowStyles.statusError}>{item.error}</Text>
<View>
<Text style={rowStyles.statusError}>{item.error}</Text>
{canRetry && (
<Text style={rowStyles.retryHint}>{t("upload.tap_retry")}</Text>
)}
</View>
)}
</View>
</View>
</Pressable>
);
}
@@ -198,8 +492,9 @@ const styles = StyleSheet.create({
minHeight: 80,
},
cameraButton: { backgroundColor: "#1e40af" },
photoLibraryButton: { backgroundColor: "#7c3aed" },
fileButton: { backgroundColor: "#059669" },
actionIcon: { fontSize: 28, marginBottom: 6 },
actionIcon: { marginBottom: 6 },
actionLabel: {
color: "#fff",
fontSize: 14,
@@ -211,7 +506,6 @@ const styles = StyleSheet.create({
alignItems: "center",
paddingTop: 60,
},
emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: {
fontSize: 16,
color: "#374151",
@@ -245,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,
@@ -254,5 +548,7 @@ const rowStyles = StyleSheet.create({
marginBottom: 4,
},
statusDone: { fontSize: 12, color: "#059669" },
statusQueued: { fontSize: 12, color: "#6b7280" },
statusError: { fontSize: 12, color: "#dc2626" },
retryHint: { fontSize: 12, color: "#1e40af", fontWeight: "600", marginTop: 4 },
});
+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,
},
});
+83 -10
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 {
@@ -35,27 +36,73 @@ export interface GenerateTokenResponse {
created_at: string;
}
export interface QRClaimResponse {
token: string;
token_id: number;
name: string;
owner_id: string;
created_at: string;
}
export interface DeviceRegistration {
push_token: string;
device_name?: string;
platform: "ios" | "android" | "web";
}
export interface ProcessingStatus {
status: string;
last_step: string | null;
has_errors: boolean;
total_steps: number;
}
export interface FileRecord {
id: number;
filename: string;
status: string;
created_at: string;
original_filename: string;
file_size: number | null;
content_type: string | null;
owner_id: string | null;
mime_type: string | null;
created_at: string;
processing_status: ProcessingStatus;
}
export interface UploadResponse {
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;
filename: 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;
};
}
// ---------------------------------------------------------------------------
@@ -151,11 +198,23 @@ class DocuElevateAPI {
});
}
/** Claim a QR login challenge and receive an API token. */
async claimQRChallenge(challengeToken: string, deviceName: string): Promise<QRClaimResponse> {
return this.request<QRClaimResponse>("POST", "/api/qr-auth/claim", {
body: { challenge_token: challengeToken, device_name: deviceName },
});
}
/** Return profile information for the authenticated user. */
async whoAmI(): Promise<WhoAmIResponse> {
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
// -------------------------------------------------------------------------
@@ -186,12 +245,26 @@ class DocuElevateAPI {
return this.request<UploadResponse>("POST", "/api/ui-upload", { formData });
}
/** List recently processed files. */
async listFiles(page = 1, pageSize = 20): Promise<FileRecord[]> {
return this.request<FileRecord[]>(
/** List recently processed files, optionally filtered by filename search. */
async listFiles(page = 1, pageSize = 20, search?: string): Promise<FileRecord[]> {
let url = `/api/files?page=${page}&per_page=${pageSize}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
const data = await this.request<{ files: FileRecord[]; pagination: unknown }>("GET", url);
return data.files;
}
/** Get the processing status for a single file by its ID. */
async getFileStatus(fileId: number): Promise<ProcessingStatus> {
const data = await this.request<{ processing_status: ProcessingStatus }>(
"GET",
`/api/files?page=${page}&page_size=${pageSize}`
`/api/files/${fileId}`
);
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}`);
}
}
+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;
}
+14 -4
View File
@@ -1,7 +1,9 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext"],
"lib": [
"ESNext"
],
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
@@ -9,10 +11,18 @@
"skipLibCheck": true,
"jsx": "react-native",
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
},
"baseUrl": "."
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
],
"extends": "expo/tsconfig.base"
}