Merge pull request #773 from christianlouis/copilot/update-deprecated-npm-packages
fix(mobile): wire i18n reactivity, translate all screens, sync language preference with server
This commit is contained in:
@@ -120,6 +120,7 @@ class WhoAmIResponse(BaseModel):
|
||||
email: str | None
|
||||
avatar_url: str | None
|
||||
is_admin: bool
|
||||
preferred_language: str | None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -357,4 +358,5 @@ async def whoami(
|
||||
"email": email,
|
||||
"avatar_url": avatar_url,
|
||||
"is_admin": is_admin,
|
||||
"preferred_language": profile.preferred_language if profile else None,
|
||||
}
|
||||
|
||||
+22
-7
@@ -285,10 +285,19 @@ The mobile app supports five languages with automatic device-locale detection:
|
||||
|
||||
### How it works
|
||||
|
||||
1. On app launch, `expo-localization` detects the device's preferred language
|
||||
2. If the device language matches a supported locale, that language is used automatically
|
||||
3. If no match is found, English is used as the fallback
|
||||
4. Users can manually switch languages from the **Profile** tab → **Settings** → **Language**
|
||||
Language priority (highest to lowest):
|
||||
|
||||
1. **Server preference** — `preferred_language` returned by `GET /api/mobile/whoami` on login or app resume. Allows a language set on the desktop web interface to propagate to mobile automatically.
|
||||
2. **AsyncStorage** — the last language explicitly selected on the device, used as an offline fallback when the server is unreachable.
|
||||
3. **Device locale** — detected via `expo-localization` on first launch.
|
||||
4. **English** — final fallback when none of the above match a supported locale.
|
||||
|
||||
When a user selects a language on mobile the choice is:
|
||||
- Applied immediately to all screens (via `LocaleContext`)
|
||||
- Persisted locally to AsyncStorage
|
||||
- Synced to the server via `POST /api/i18n/language` (fire-and-forget), so the next desktop login reflects the same preference.
|
||||
|
||||
> **Note**: If the server's preferred language is not supported by the mobile app (e.g. a locale added to the web frontend but not yet translated for mobile), the mobile app falls back to the next priority in the list above.
|
||||
|
||||
### Adding a new language
|
||||
|
||||
@@ -316,7 +325,8 @@ The backend exposes a dedicated `/api/mobile/` namespace:
|
||||
| `POST` | `/api/mobile/register-device` | Bearer | Register Expo push token |
|
||||
| `GET` | `/api/mobile/devices` | Bearer | List registered devices |
|
||||
| `DELETE` | `/api/mobile/devices/{id}` | Bearer | Deactivate a device |
|
||||
| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile |
|
||||
| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile (includes `preferred_language`) |
|
||||
| `POST` | `/api/i18n/language` | Bearer | Sync language preference to server |
|
||||
|
||||
All other API endpoints (file upload, file listing, etc.) work with Bearer token authentication.
|
||||
|
||||
@@ -360,7 +370,7 @@ Re-registering the same token is safe (idempotent).
|
||||
|
||||
### GET /api/mobile/whoami
|
||||
|
||||
Returns the current user's profile.
|
||||
Returns the current user's profile, including the server-stored language preference.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
@@ -369,10 +379,15 @@ Returns the current user's profile.
|
||||
"display_name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"avatar_url": "https://www.gravatar.com/avatar/...",
|
||||
"is_admin": false
|
||||
"is_admin": false,
|
||||
"preferred_language": "de"
|
||||
}
|
||||
```
|
||||
|
||||
`preferred_language` is `null` when no preference has been saved. The mobile
|
||||
app applies this value on login / app resume, falling back to AsyncStorage and
|
||||
then the device locale when it is `null` or unsupported.
|
||||
|
||||
## Configuration
|
||||
|
||||
No server-side configuration is required to enable the mobile app. The Expo push notification routing does not need FCM or APNs credentials on the server.
|
||||
|
||||
@@ -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,23 @@ 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 */}
|
||||
@@ -72,8 +77,8 @@ export default function TabLayout() {
|
||||
name="file-detail"
|
||||
options={{
|
||||
href: null,
|
||||
title: "File Details",
|
||||
headerTitle: "File Details",
|
||||
title: t("file_detail.title"),
|
||||
headerTitle: t("file_detail.title"),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
+20
-6
@@ -23,6 +23,7 @@ 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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -100,11 +101,22 @@ function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string; mim
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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.
|
||||
@@ -162,11 +174,13 @@ function AuthGuard() {
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<ShareProvider>
|
||||
<AuthProvider>
|
||||
<AuthGuard />
|
||||
</AuthProvider>
|
||||
</ShareProvider>
|
||||
<LocaleProvider>
|
||||
<ShareProvider>
|
||||
<AuthProvider>
|
||||
<AuthGuard />
|
||||
</AuthProvider>
|
||||
</ShareProvider>
|
||||
</LocaleProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require("eslint-config-expo/flat");
|
||||
Generated
+107
-442
@@ -27,7 +27,7 @@
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-localization": "~16.1.0",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-notifications": "~0.32.16",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
@@ -45,7 +45,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
@@ -1634,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": {
|
||||
@@ -2314,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",
|
||||
@@ -2344,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",
|
||||
@@ -2668,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",
|
||||
@@ -5767,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",
|
||||
@@ -6069,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": {
|
||||
@@ -6261,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",
|
||||
@@ -6587,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": {
|
||||
@@ -6597,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"
|
||||
@@ -6616,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"
|
||||
@@ -7080,9 +6852,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-localization": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-16.1.6.tgz",
|
||||
"integrity": "sha512-v4HwNzs8QvyKHwl40MvETNEKr77v1o9/eVC8WCBY++DIlBAvonHyJe2R9CfqpZbC4Tlpl7XV+07nLXc8O5PQsA==",
|
||||
"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"
|
||||
@@ -7349,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",
|
||||
@@ -7442,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": {
|
||||
@@ -7526,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"
|
||||
},
|
||||
@@ -7841,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"
|
||||
@@ -7891,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",
|
||||
@@ -8532,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",
|
||||
@@ -10905,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",
|
||||
@@ -11498,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",
|
||||
@@ -11552,30 +11261,6 @@
|
||||
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"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/safe-array-concat": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||
@@ -12503,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",
|
||||
@@ -12671,19 +12349,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-array-buffer": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint": "eslint src",
|
||||
"type-check": "tsc --noEmit",
|
||||
"build:ios": "eas build --platform ios",
|
||||
"build:android": "eas build --platform android",
|
||||
@@ -36,7 +36,7 @@
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-localization": "~16.1.0",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-notifications": "~0.32.16",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
@@ -54,7 +54,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
|
||||
@@ -99,7 +99,9 @@
|
||||
"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"
|
||||
"admin": "Admin",
|
||||
"settings": "Einstellungen",
|
||||
"language": "Sprache"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Datenschutz",
|
||||
|
||||
@@ -99,7 +99,9 @@
|
||||
"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"
|
||||
"admin": "Admin",
|
||||
"settings": "Settings",
|
||||
"language": "Language"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Privacy Policy",
|
||||
|
||||
@@ -99,7 +99,9 @@
|
||||
"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"
|
||||
"admin": "Admin",
|
||||
"settings": "Configuración",
|
||||
"language": "Idioma"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Privacidad",
|
||||
|
||||
@@ -99,7 +99,9 @@
|
||||
"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"
|
||||
"admin": "Admin",
|
||||
"settings": "Paramètres",
|
||||
"language": "Langue"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Confidentialité",
|
||||
|
||||
+109
-2
@@ -6,9 +6,23 @@
|
||||
* 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";
|
||||
@@ -28,6 +42,8 @@ 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 {
|
||||
@@ -46,7 +62,7 @@ function detectLanguage(): string {
|
||||
let currentLanguage: string = detectLanguage();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// Plain-function public API (framework-agnostic)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -82,13 +98,22 @@ export function getLanguage(): string {
|
||||
return currentLanguage;
|
||||
}
|
||||
|
||||
/** Override the language manually (e.g. from user settings). */
|
||||
/**
|
||||
* 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 [
|
||||
@@ -99,3 +124,85 @@ export function getSupportedLanguages(): { code: string; label: string }[] {
|
||||
{ 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);
|
||||
}
|
||||
|
||||
@@ -99,7 +99,9 @@
|
||||
"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"
|
||||
"admin": "Admin",
|
||||
"settings": "Impostazioni",
|
||||
"language": "Lingua"
|
||||
},
|
||||
"legal": {
|
||||
"privacy_policy": "Privacy",
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "react-native";
|
||||
import type { FileDetail } from "../services/api";
|
||||
import api from "../services/api";
|
||||
import { useLocale, t } from "../i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -88,6 +89,8 @@ export default function FileDetailScreen() {
|
||||
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);
|
||||
|
||||
@@ -127,12 +130,12 @@ export default function FileDetailScreen() {
|
||||
if (error || !detail) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.errorText}>{error ?? "File not found"}</Text>
|
||||
<Text style={styles.errorText}>{error ?? t("file_detail.file_not_found")}</Text>
|
||||
<Pressable style={styles.retryButton} onPress={handleRefresh}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
<Text style={styles.retryText}>{t("common.retry")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<Text style={styles.backButtonText}>← Back</Text>
|
||||
<Text style={styles.backButtonText}>{t("common.back")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -152,10 +155,10 @@ export default function FileDetailScreen() {
|
||||
style={styles.backRow}
|
||||
onPress={() => router.back()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Go back"
|
||||
accessibilityLabel={t("file_detail.back")}
|
||||
>
|
||||
<Ionicons name="arrow-back" size={20} color="#1e40af" />
|
||||
<Text style={styles.backLabel}>Back to Files</Text>
|
||||
<Text style={styles.backLabel}>{t("file_detail.back")}</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* File info card */}
|
||||
@@ -178,20 +181,20 @@ export default function FileDetailScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.metaGrid}>
|
||||
<MetaRow label="File Size" value={formatBytes(file.file_size)} />
|
||||
<MetaRow label="MIME Type" value={file.mime_type ?? "–"} />
|
||||
<MetaRow label="Uploaded" value={formatDateTime(file.created_at)} />
|
||||
<MetaRow label="File Hash" value={file.filehash ? `${file.filehash.slice(0, 24)}…` : "–"} />
|
||||
<MetaRow label="Last Step" value={status.last_step ?? "–"} />
|
||||
<MetaRow label="Total Steps" value={String(status.total_steps)} />
|
||||
<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}>Processing Log</Text>
|
||||
<Text style={styles.sectionTitle}>{t("file_detail.processing_log")}</Text>
|
||||
{detail.logs.length === 0 ? (
|
||||
<Text style={styles.emptyLog}>No processing logs yet.</Text>
|
||||
<Text style={styles.emptyLog}>{t("file_detail.no_logs")}</Text>
|
||||
) : (
|
||||
detail.logs.map((log, idx) => {
|
||||
const icon = logStepIcon(log.status);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} 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 "–";
|
||||
@@ -58,6 +59,8 @@ export default function FilesScreen() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Subscribe to language changes so translated strings re-render.
|
||||
useLocale();
|
||||
|
||||
const fetchFiles = useCallback(
|
||||
async (pageNum: number, replace: boolean, search?: string) => {
|
||||
@@ -150,7 +153,7 @@ export default function FilesScreen() {
|
||||
<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>
|
||||
);
|
||||
@@ -163,21 +166,21 @@ export default function FilesScreen() {
|
||||
<Ionicons name="search-outline" size={18} color="#9ca3af" style={styles.searchIcon} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Search documents…"
|
||||
placeholder={t("files.search_placeholder")}
|
||||
placeholderTextColor="#9ca3af"
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
accessibilityLabel="Search documents"
|
||||
accessibilityLabel={t("common.search")}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable
|
||||
onPress={handleClearSearch}
|
||||
style={styles.clearButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Clear search"
|
||||
accessibilityLabel={t("common.clear_search")}
|
||||
>
|
||||
<Ionicons name="close-circle" size={18} color="#9ca3af" />
|
||||
</Pressable>
|
||||
@@ -199,12 +202,10 @@ export default function FilesScreen() {
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="folder-open-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
|
||||
<Text style={styles.emptyText}>
|
||||
{searchQuery ? "No documents match your search." : "No documents yet."}
|
||||
{searchQuery ? t("files.search_empty") : t("files.empty_title")}
|
||||
</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
{searchQuery
|
||||
? "Try a different search term."
|
||||
: "Upload a document from the Upload tab to get started."}
|
||||
{searchQuery ? t("files.search_empty_hint") : t("files.empty_hint")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useLocale, t } from "../i18n";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { signIn, signInWithQR } = useAuth();
|
||||
@@ -31,6 +32,8 @@ export default function LoginScreen() {
|
||||
const [serverUrl, setServerUrl] = useState("https://app.docuelevate.org");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
// Subscribe to language changes so translated strings re-render.
|
||||
useLocale();
|
||||
|
||||
// Handle incoming deep links for QR login (docuelevate://qr-login?token=...&server=...)
|
||||
const handleDeepLink = useCallback(
|
||||
@@ -46,8 +49,8 @@ export default function LoginScreen() {
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "QR login failed";
|
||||
Alert.alert("QR Login Failed", message);
|
||||
const message = err instanceof Error ? err.message : t("login.qr_login_failed");
|
||||
Alert.alert(t("login.qr_login_failed"), message);
|
||||
} finally {
|
||||
setQrLoading(false);
|
||||
}
|
||||
@@ -70,11 +73,11 @@ export default function LoginScreen() {
|
||||
async function handleSignIn() {
|
||||
const url = serverUrl.trim();
|
||||
if (!url) {
|
||||
Alert.alert("Server URL required", "Please enter the URL of your DocuElevate server.");
|
||||
Alert.alert(t("login.server_url_required"), t("login.server_url_required_msg"));
|
||||
return;
|
||||
}
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
Alert.alert("Invalid URL", "The server URL must start with http:// or https://");
|
||||
Alert.alert(t("login.invalid_url"), t("login.invalid_url_msg"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,8 +85,8 @@ export default function LoginScreen() {
|
||||
try {
|
||||
await signIn(url);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Sign-in failed";
|
||||
Alert.alert("Sign-in failed", message);
|
||||
const message = err instanceof Error ? err.message : t("login.sign_in_failed");
|
||||
Alert.alert(t("login.sign_in_failed"), message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -104,12 +107,12 @@ export default function LoginScreen() {
|
||||
/>
|
||||
<Text style={styles.logoText}>DocuElevate</Text>
|
||||
</View>
|
||||
<Text style={styles.tagline}>Intelligent Document Processing</Text>
|
||||
<Text style={styles.tagline}>{t("welcome.tagline")}</Text>
|
||||
|
||||
<Text style={styles.label}>Server URL</Text>
|
||||
<Text style={styles.label}>{t("login.server_url")}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="https://your-docuelevate-server.com"
|
||||
placeholder={t("login.server_url_placeholder")}
|
||||
placeholderTextColor="#9ca3af"
|
||||
value={serverUrl}
|
||||
onChangeText={setServerUrl}
|
||||
@@ -118,7 +121,7 @@ export default function LoginScreen() {
|
||||
keyboardType="url"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={handleSignIn}
|
||||
accessibilityLabel="Server URL"
|
||||
accessibilityLabel={t("login.server_url")}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
@@ -126,18 +129,18 @@ export default function LoginScreen() {
|
||||
onPress={handleSignIn}
|
||||
disabled={loading || qrLoading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Sign in with SSO"
|
||||
accessibilityLabel={t("login.sign_in_sso")}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign in with SSO</Text>
|
||||
<Text style={styles.buttonText}>{t("login.sign_in_sso")}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.dividerRow}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Text style={styles.dividerText}>or</Text>
|
||||
<Text style={styles.dividerText}>{t("login.or")}</Text>
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
@@ -148,26 +151,24 @@ export default function LoginScreen() {
|
||||
}}
|
||||
disabled={loading || qrLoading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Sign in with QR code"
|
||||
accessibilityLabel={t("login.scan_qr")}
|
||||
>
|
||||
{qrLoading ? (
|
||||
<ActivityIndicator color="#1e40af" />
|
||||
) : (
|
||||
<Text style={styles.qrButtonText}>📱 Scan QR Code to Login</Text>
|
||||
<Text style={styles.qrButtonText}>{t("login.scan_qr")}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.hint}>
|
||||
Sign in via SSO or scan a QR code from the web app.
|
||||
</Text>
|
||||
<Text style={styles.hint}>{t("login.hint")}</Text>
|
||||
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back to welcome screen"
|
||||
accessibilityLabel={t("login.back")}
|
||||
style={styles.backLink}
|
||||
>
|
||||
<Text style={styles.backLinkText}>← Back</Text>
|
||||
<Text style={styles.backLinkText}>{t("login.back")}</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Legal links – accessible pre-login for GDPR / Apple compliance */}
|
||||
@@ -178,10 +179,10 @@ export default function LoginScreen() {
|
||||
Linking.openURL(`${base.replace(/\/$/, "")}/privacy`);
|
||||
}}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Privacy Policy"
|
||||
accessibilityLabel={t("legal.privacy_policy")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Privacy Policy</Text>
|
||||
<Text style={styles.legalLinkText}>{t("legal.privacy_policy")}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.legalSeparator}>·</Text>
|
||||
<Pressable
|
||||
@@ -190,10 +191,10 @@ export default function LoginScreen() {
|
||||
Linking.openURL(`${base.replace(/\/$/, "")}/terms`);
|
||||
}}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Terms of Service"
|
||||
accessibilityLabel={t("legal.terms")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Terms</Text>
|
||||
<Text style={styles.legalLinkText}>{t("legal.terms")}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.legalSeparator}>·</Text>
|
||||
<Pressable
|
||||
@@ -202,10 +203,10 @@ export default function LoginScreen() {
|
||||
Linking.openURL(`${base.replace(/\/$/, "")}/imprint`);
|
||||
}}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Imprint"
|
||||
accessibilityLabel={t("legal.imprint")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Imprint</Text>
|
||||
<Text style={styles.legalLinkText}>{t("legal.imprint")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import Constants from "expo-constants";
|
||||
import * as Linking from "expo-linking";
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
@@ -15,23 +15,33 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { getLanguage, getSupportedLanguages, setLanguage } from "../i18n";
|
||||
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 [selectedLanguage, setSelectedLanguage] = useState(getLanguage());
|
||||
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,
|
||||
},
|
||||
@@ -40,16 +50,16 @@ export default function ProfileScreen() {
|
||||
|
||||
function handleDeleteAccount() {
|
||||
Alert.alert(
|
||||
"Delete Account",
|
||||
"This will permanently delete your account and all associated data. This action cannot be undone.",
|
||||
t("profile.delete_account_title"),
|
||||
t("profile.delete_account_msg"),
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: "Delete Account",
|
||||
text: t("profile.delete_account"),
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
Linking.openURL(`${effectiveBaseUrl}/account/delete`).catch(() => {
|
||||
Alert.alert("Error", "Could not open the account deletion page. Please try again.");
|
||||
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.delete_account") }));
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -59,26 +69,26 @@ export default function ProfileScreen() {
|
||||
|
||||
function openPrivacyPolicy() {
|
||||
Linking.openURL(`${effectiveBaseUrl}/privacy`).catch(() => {
|
||||
Alert.alert("Error", "Could not open the privacy policy. Please try again.");
|
||||
Alert.alert(t("common.error"), t("profile.could_not_open", { page: t("profile.privacy_policy") }));
|
||||
});
|
||||
}
|
||||
|
||||
function openTermsOfService() {
|
||||
Linking.openURL(`${effectiveBaseUrl}/terms`).catch(() => {
|
||||
Alert.alert("Error", "Could not open the terms of service. Please try again.");
|
||||
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("Error", "Could not open the imprint page. Please try again.");
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -102,20 +112,20 @@ 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}>
|
||||
{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>
|
||||
@@ -124,31 +134,28 @@ export default function ProfileScreen() {
|
||||
|
||||
{/* Settings */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Settings</Text>
|
||||
<Text style={styles.settingLabel}>Language</Text>
|
||||
<Text style={styles.sectionTitle}>{t("profile.settings")}</Text>
|
||||
<Text style={styles.settingLabel}>{t("profile.language")}</Text>
|
||||
<View style={styles.languageGrid}>
|
||||
{languages.map((lang) => (
|
||||
{languages.map((l) => (
|
||||
<Pressable
|
||||
key={lang.code}
|
||||
key={l.code}
|
||||
style={[
|
||||
styles.languageChip,
|
||||
selectedLanguage === lang.code && styles.languageChipActive,
|
||||
lang === l.code && styles.languageChipActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
setLanguage(lang.code);
|
||||
setSelectedLanguage(lang.code);
|
||||
}}
|
||||
onPress={() => handleLanguageSelect(l.code)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Set language to ${lang.label}`}
|
||||
accessibilityState={{ selected: selectedLanguage === lang.code }}
|
||||
accessibilityLabel={`Set language to ${l.label}`}
|
||||
accessibilityState={{ selected: lang === l.code }}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.languageChipText,
|
||||
selectedLanguage === lang.code && styles.languageChipTextActive,
|
||||
lang === l.code && styles.languageChipTextActive,
|
||||
]}
|
||||
>
|
||||
{lang.label}
|
||||
{l.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
@@ -157,32 +164,32 @@ export default function ProfileScreen() {
|
||||
|
||||
{/* Legal & Privacy */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Legal</Text>
|
||||
<Text style={styles.sectionTitle}>{t("profile.legal")}</Text>
|
||||
<Pressable
|
||||
style={styles.linkRow}
|
||||
onPress={openPrivacyPolicy}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Privacy Policy"
|
||||
accessibilityLabel={t("profile.privacy_policy")}
|
||||
>
|
||||
<Text style={styles.linkText}>Privacy Policy</Text>
|
||||
<Text style={styles.linkText}>{t("profile.privacy_policy")}</Text>
|
||||
<Text style={styles.linkChevron}>›</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.linkRow}
|
||||
onPress={openTermsOfService}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Terms of Service"
|
||||
accessibilityLabel={t("profile.terms_of_service")}
|
||||
>
|
||||
<Text style={styles.linkText}>Terms of Service</Text>
|
||||
<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="Imprint"
|
||||
accessibilityLabel={t("profile.imprint")}
|
||||
>
|
||||
<Text style={styles.linkText}>Imprint</Text>
|
||||
<Text style={styles.linkText}>{t("profile.imprint")}</Text>
|
||||
<Text style={styles.linkChevron}>›</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -193,9 +200,9 @@ export default function ProfileScreen() {
|
||||
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>
|
||||
|
||||
@@ -205,9 +212,9 @@ export default function ProfileScreen() {
|
||||
style={styles.deleteAccountButton}
|
||||
onPress={handleDeleteAccount}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Delete account"
|
||||
accessibilityLabel={t("profile.delete_account")}
|
||||
>
|
||||
<Text style={styles.deleteAccountText}>Delete Account</Text>
|
||||
<Text style={styles.deleteAccountText}>{t("profile.delete_account")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ 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"]);
|
||||
@@ -56,6 +57,8 @@ export default function UploadScreen() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { pendingFiles, clearPendingFiles } = useShare();
|
||||
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
||||
// Subscribe to language changes so translated strings re-render.
|
||||
useLocale();
|
||||
|
||||
// Keep a ref in sync so the polling interval can read current state without
|
||||
// capturing a stale closure.
|
||||
@@ -268,8 +271,8 @@ export default function UploadScreen() {
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (status !== "granted") {
|
||||
Alert.alert(
|
||||
"Camera access required",
|
||||
"Please grant camera access in Settings to capture documents."
|
||||
t("upload.camera_access_title"),
|
||||
t("upload.camera_access_msg")
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -291,8 +294,8 @@ export default function UploadScreen() {
|
||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (status !== "granted") {
|
||||
Alert.alert(
|
||||
"Photo library access required",
|
||||
"Please grant photo library access in Settings to select images."
|
||||
t("upload.photo_access_title"),
|
||||
t("upload.photo_access_msg")
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -329,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>
|
||||
);
|
||||
}
|
||||
@@ -349,30 +352,30 @@ export default function UploadScreen() {
|
||||
style={[styles.actionButton, styles.cameraButton]}
|
||||
onPress={handleCamera}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Capture document with camera"
|
||||
accessibilityLabel={t("upload.capture_label")}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={28} color="#fff" style={styles.actionIcon} />
|
||||
<Text style={styles.actionLabel}>Camera</Text>
|
||||
<Text style={styles.actionLabel}>{t("upload.camera")}</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.actionButton, styles.photoLibraryButton]}
|
||||
onPress={handlePhotoLibrary}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select photo from library"
|
||||
accessibilityLabel={t("upload.photo_label")}
|
||||
>
|
||||
<Ionicons name="images-outline" size={28} color="#fff" style={styles.actionIcon} />
|
||||
<Text style={styles.actionLabel}>Photos</Text>
|
||||
<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")}
|
||||
>
|
||||
<Ionicons name="document-outline" size={28} color="#fff" style={styles.actionIcon} />
|
||||
<Text style={styles.actionLabel}>Files</Text>
|
||||
<Text style={styles.actionLabel}>{t("upload.files")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -381,12 +384,8 @@ export default function UploadScreen() {
|
||||
{uploads.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="cloud-upload-outline" size={48} color="#9ca3af" style={{ marginBottom: 12 }} />
|
||||
<Text style={styles.emptyText}>
|
||||
Tap Camera, Photos, or Files to upload a document.
|
||||
</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
You can also share files from other apps directly to DocuElevate.
|
||||
</Text>
|
||||
<Text style={styles.emptyText}>{t("upload.empty_title")}</Text>
|
||||
<Text style={styles.emptyHint}>{t("upload.empty_hint")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
uploads.map((item) => (
|
||||
@@ -399,6 +398,9 @@ export default function UploadScreen() {
|
||||
}
|
||||
|
||||
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" },
|
||||
@@ -409,11 +411,11 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
|
||||
/** Human-readable label for the server-side processing status. */
|
||||
function serverStatusLabel(s: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
pending: "Queued for processing…",
|
||||
processing: "Processing…",
|
||||
completed: "Processed",
|
||||
failed: "Processing failed",
|
||||
duplicate: "Duplicate – already processed",
|
||||
pending: t("upload.status_queued"),
|
||||
processing: t("upload.status_processing"),
|
||||
completed: t("upload.status_completed"),
|
||||
failed: t("upload.status_failed"),
|
||||
duplicate: t("upload.status_duplicate"),
|
||||
};
|
||||
return labels[s] ?? s;
|
||||
}
|
||||
@@ -422,9 +424,9 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
|
||||
|
||||
function handleLongPress() {
|
||||
if (!canRetry) return;
|
||||
Alert.alert("Retry Upload", `Do you want to retry uploading "${item.filename}"?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Retry", onPress: () => onRetry(item) },
|
||||
Alert.alert(t("upload.retry_title"), t("upload.retry_msg", { filename: item.filename }), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: t("common.retry"), onPress: () => onRetry(item) },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -434,7 +436,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
|
||||
onPress={canRetry ? () => onRetry(item) : undefined}
|
||||
style={rowStyles.row}
|
||||
accessibilityRole={canRetry ? "button" : "none"}
|
||||
accessibilityLabel={canRetry ? `Retry uploading ${item.filename}` : undefined}
|
||||
accessibilityLabel={canRetry ? `${t("common.retry")} ${item.filename}` : undefined}
|
||||
accessibilityHint={canRetry ? "Tap or long-press to retry this upload" : undefined}
|
||||
>
|
||||
<Ionicons name={uploadIconProps[item.status].name} size={22} color={uploadIconProps[item.status].color} style={rowStyles.icon} />
|
||||
@@ -446,7 +448,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
|
||||
<ActivityIndicator size="small" color="#1e40af" />
|
||||
)}
|
||||
{item.status === "done" && !item.serverStatus && (
|
||||
<Text style={rowStyles.statusQueued}>Queued for processing…</Text>
|
||||
<Text style={rowStyles.statusQueued}>{t("upload.status_queued")}</Text>
|
||||
)}
|
||||
{item.status === "done" && item.serverStatus && (
|
||||
<Text
|
||||
@@ -465,7 +467,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload
|
||||
<View>
|
||||
<Text style={rowStyles.statusError}>{item.error}</Text>
|
||||
{canRetry && (
|
||||
<Text style={rowStyles.retryHint}>Tap to retry</Text>
|
||||
<Text style={rowStyles.retryHint}>{t("upload.tap_retry")}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -17,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
|
||||
@@ -55,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}>
|
||||
@@ -80,42 +81,40 @@ 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="Privacy Policy"
|
||||
accessibilityLabel={t("legal.privacy_policy")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Privacy Policy</Text>
|
||||
<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="Terms of Service"
|
||||
accessibilityLabel={t("legal.terms")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Terms</Text>
|
||||
<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="Imprint"
|
||||
accessibilityLabel={t("legal.imprint")}
|
||||
style={styles.legalLinkButton}
|
||||
>
|
||||
<Text style={styles.legalLinkText}>Imprint</Text>
|
||||
<Text style={styles.legalLinkText}>{t("legal.imprint")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface WhoAmIResponse {
|
||||
email: string | null;
|
||||
avatar_url: string | null;
|
||||
is_admin: boolean;
|
||||
preferred_language: string | null;
|
||||
}
|
||||
|
||||
export interface GenerateTokenResponse {
|
||||
@@ -209,6 +210,11 @@ class DocuElevateAPI {
|
||||
return this.request<WhoAmIResponse>("GET", "/api/mobile/whoami");
|
||||
}
|
||||
|
||||
/** Sync the user's preferred UI language to the server. */
|
||||
async setServerLanguage(lang: string): Promise<void> {
|
||||
await this.request("POST", "/api/i18n/language", { body: { language: lang } });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Push notifications
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -467,6 +467,42 @@ class TestWhoAmI:
|
||||
assert data["email"] == _OWNER
|
||||
assert data["avatar_url"] is not None # Gravatar URL
|
||||
assert data["is_admin"] is False
|
||||
assert data["preferred_language"] is None # not set yet
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_whoami_returns_preferred_language(self, mob_engine, mob_session):
|
||||
"""preferred_language from UserProfile is included in the whoami response."""
|
||||
from app.main import app
|
||||
from app.models import UserProfile
|
||||
|
||||
profile = UserProfile(
|
||||
user_id=_OWNER,
|
||||
display_name="Bob Test",
|
||||
preferred_language="de",
|
||||
)
|
||||
mob_session.add(profile)
|
||||
mob_session.commit()
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.get("/api/mobile/whoami")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["preferred_language"] == "de"
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_whoami_no_profile_preferred_language_is_null(self, mob_engine):
|
||||
"""preferred_language is null when no UserProfile exists."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.get("/api/mobile/whoami")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["preferred_language"] is None
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user