From 445d6299497243b1215b097bf7bff75496a46e46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:04:01 +0000 Subject: [PATCH 1/3] Initial plan From 0e6a4c5084b3704653965f0091c36b9c78b8ad60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:12:05 +0000 Subject: [PATCH 2/3] chore(mobile): upgrade ESLint to v9 with flat config and fix expo-localization version Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- mobile/eslint.config.js | 1 + mobile/package-lock.json | 549 ++++++++------------------------------- mobile/package.json | 6 +- 3 files changed, 111 insertions(+), 445 deletions(-) create mode 100644 mobile/eslint.config.js diff --git a/mobile/eslint.config.js b/mobile/eslint.config.js new file mode 100644 index 00000000..7d4d4459 --- /dev/null +++ b/mobile/eslint.config.js @@ -0,0 +1 @@ +module.exports = require("eslint-config-expo/flat"); diff --git a/mobile/package-lock.json b/mobile/package-lock.json index d353d775..fb89fda2 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -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", diff --git a/mobile/package.json b/mobile/package.json index 4064adf0..6037583d 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -8,7 +8,7 @@ "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", - "lint": "eslint src --ext .ts,.tsx", + "lint": "eslint src", "type-check": "tsc --noEmit", "build:ios": "eas build --platform ios", "build:android": "eas build --platform android", @@ -36,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" }, From 3b5ca04ebc8b6dd20f877bc4797e364e0997d840 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:08:08 +0000 Subject: [PATCH 3/3] fix(mobile): wire i18n reactivity, translate all screens, sync language with server - Add LocaleProvider + useLocale() hook with AsyncStorage persistence to mobile i18n - Replace all hardcoded English strings in every screen with t() calls - Add missing profile.settings/language keys to all 5 translation files (en/de/es/fr/it) - Wrap app root in LocaleProvider; apply server preferred_language on login in AuthGuard - Tab labels and header titles now re-render on language switch - ProfileScreen: use useLocale() context, sync language to server via POST /api/i18n/language - Backend: add preferred_language field to GET /api/mobile/whoami response - Mobile API: add preferred_language to WhoAmIResponse type + setServerLanguage() method - Tests: add test_whoami_returns_preferred_language and test_whoami_no_profile_preferred_language_is_null - Docs: update MobileApp.md with language sync priority and whoami response format Language priority: server preference > AsyncStorage > device locale > English fallback Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/api/mobile.py | 2 + docs/MobileApp.md | 29 +++++-- mobile/app/(tabs)/_layout.tsx | 25 +++--- mobile/app/_layout.tsx | 26 ++++-- mobile/src/i18n/de.json | 4 +- mobile/src/i18n/en.json | 4 +- mobile/src/i18n/es.json | 4 +- mobile/src/i18n/fr.json | 4 +- mobile/src/i18n/index.ts | 111 +++++++++++++++++++++++- mobile/src/i18n/it.json | 4 +- mobile/src/screens/FileDetailScreen.tsx | 29 ++++--- mobile/src/screens/FilesScreen.tsx | 17 ++-- mobile/src/screens/LoginScreen.tsx | 53 +++++------ mobile/src/screens/ProfileScreen.tsx | 93 +++++++++++--------- mobile/src/screens/UploadScreen.tsx | 60 ++++++------- mobile/src/screens/WelcomeScreen.tsx | 69 ++++++++------- mobile/src/services/api.ts | 6 ++ tests/test_api_mobile.py | 36 ++++++++ 18 files changed, 392 insertions(+), 184 deletions(-) diff --git a/app/api/mobile.py b/app/api/mobile.py index c9163f00..5305c5d2 100644 --- a/app/api/mobile.py +++ b/app/api/mobile.py @@ -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, } diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 1a577fd4..07ff077b 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -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. diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index eb2c93dd..7068f4d2 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -11,10 +11,15 @@ import { Ionicons } from "@expo/vector-icons"; import React from "react"; import { usePushNotifications } from "../../src/hooks/usePushNotifications"; import { useAuth } from "../../src/context/AuthContext"; +import { useLocale, t } from "../../src/i18n"; export default function TabLayout() { const { isAuthenticated } = useAuth(); usePushNotifications(isAuthenticated); + // Subscribe to language changes so tab labels re-render when the language + // is switched. The `lang` variable is intentionally unused – its only + // purpose is to make this component a consumer of LocaleContext. + useLocale(); return ( ( ), @@ -48,23 +53,23 @@ export default function TabLayout() { ( ), - headerTitle: "My Documents", + headerTitle: t("files.title"), }} /> ( ), - headerTitle: "Profile", + headerTitle: t("tabs.profile"), }} /> {/* File detail screen – hidden from tab bar, accessed via navigation */} @@ -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"), }} /> diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index d2396c07..0feda68f 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -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 ( - - - - - + + + + + + + ); } diff --git a/mobile/src/i18n/de.json b/mobile/src/i18n/de.json index 5a107e4c..79589bf5 100644 --- a/mobile/src/i18n/de.json +++ b/mobile/src/i18n/de.json @@ -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", diff --git a/mobile/src/i18n/en.json b/mobile/src/i18n/en.json index 342f26bd..137503c3 100644 --- a/mobile/src/i18n/en.json +++ b/mobile/src/i18n/en.json @@ -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", diff --git a/mobile/src/i18n/es.json b/mobile/src/i18n/es.json index 57216302..eb434f34 100644 --- a/mobile/src/i18n/es.json +++ b/mobile/src/i18n/es.json @@ -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", diff --git a/mobile/src/i18n/fr.json b/mobile/src/i18n/fr.json index 2dce9a8f..3760d4d5 100644 --- a/mobile/src/i18n/fr.json +++ b/mobile/src/i18n/fr.json @@ -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é", diff --git a/mobile/src/i18n/index.ts b/mobile/src/i18n/index.ts index 6260ebb5..84bc1504 100644 --- a/mobile/src/i18n/index.ts +++ b/mobile/src/i18n/index.ts @@ -6,9 +6,23 @@ * locales. * * Supported languages: English, German, Spanish, French, Italian. + * + * ## React integration + * + * Wrap the app root in `` and call `useLocale()` in any + * component that renders translated strings. `useLocale()` returns the + * active language code and a `setLang` setter that: + * 1. Updates the in-memory `currentLanguage` variable (so `t()` picks it up) + * 2. Triggers a React re-render of every consumer + * 3. Persists the choice to AsyncStorage (survives app restarts) + * + * Language priority on startup: + * server preference (from /api/mobile/whoami) > AsyncStorage > device locale > "en" */ +import AsyncStorage from "@react-native-async-storage/async-storage"; import { getLocales } from "expo-localization"; +import React from "react"; import de from "./de.json"; import en from "./en.json"; @@ -28,6 +42,8 @@ const translations: Record = { en, de, es, fr, it }; // Locale detection // --------------------------------------------------------------------------- +const LANG_STORAGE_KEY = "@docuelevate:language"; + /** Resolve the best-matching language code from the device locale list. */ function detectLanguage(): string { try { @@ -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; +} + +const LocaleContext = React.createContext({ + lang: currentLanguage, + // Default setter used outside of a provider – updates in-memory only. + setLang: async (code: string) => { + setLanguage(code); + }, +}); + +/** + * Wrap the app root in `LocaleProvider` to enable reactive language switching. + * + * On mount it reads the persisted language from AsyncStorage so the user's + * choice survives app restarts. The server-preferred language is applied + * externally (see `AuthGuard` in `app/_layout.tsx`) after the profile is + * fetched from `/api/mobile/whoami`. + */ +export function LocaleProvider({ children }: { children: React.ReactNode }): React.ReactElement { + const [lang, setLangState] = React.useState(currentLanguage); + + // Restore the persisted language preference once on app start. + React.useEffect(() => { + AsyncStorage.getItem(LANG_STORAGE_KEY) + .then((saved) => { + if (saved && isLanguageSupported(saved)) { + setLanguage(saved); + setLangState(saved); + } + }) + .catch(() => { + // Ignore read errors – fall back to device-detected language. + }); + }, []); + + const setLang = React.useCallback(async (code: string): Promise => { + if (!isLanguageSupported(code)) return; + setLanguage(code); + setLangState(code); + try { + await AsyncStorage.setItem(LANG_STORAGE_KEY, code); + } catch { + // Ignore write errors – the in-memory change is still applied. + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // setLangState is a React state setter – its identity is guaranteed stable + + const value = React.useMemo(() => ({ lang, setLang }), [lang, setLang]); + + return React.createElement(LocaleContext.Provider, { value }, children); +} + +/** + * Hook that subscribes to language changes. + * + * Any component calling `useLocale()` re-renders automatically when the + * language changes. Call `t()` freely inside the component body – the + * re-render will pick up the new translations. + * + * ```tsx + * function MyScreen() { + * const { lang, setLang } = useLocale(); // subscribes to changes + * return {t("common.loading")}; + * } + * ``` + */ +export function useLocale(): LocaleContextValue { + return React.useContext(LocaleContext); +} diff --git a/mobile/src/i18n/it.json b/mobile/src/i18n/it.json index a3211a5c..71fc3699 100644 --- a/mobile/src/i18n/it.json +++ b/mobile/src/i18n/it.json @@ -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", diff --git a/mobile/src/screens/FileDetailScreen.tsx b/mobile/src/screens/FileDetailScreen.tsx index d0afce5b..8d7cad20 100644 --- a/mobile/src/screens/FileDetailScreen.tsx +++ b/mobile/src/screens/FileDetailScreen.tsx @@ -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(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 ( - {error ?? "File not found"} + {error ?? t("file_detail.file_not_found")} - Retry + {t("common.retry")} router.back()}> - ← Back + {t("common.back")} ); @@ -152,10 +155,10 @@ export default function FileDetailScreen() { style={styles.backRow} onPress={() => router.back()} accessibilityRole="button" - accessibilityLabel="Go back" + accessibilityLabel={t("file_detail.back")} > - Back to Files + {t("file_detail.back")} {/* File info card */} @@ -178,20 +181,20 @@ export default function FileDetailScreen() { - - - - - - + + + + + + {/* Processing logs */} - Processing Log + {t("file_detail.processing_log")} {detail.logs.length === 0 ? ( - No processing logs yet. + {t("file_detail.no_logs")} ) : ( detail.logs.map((log, idx) => { const icon = logStepIcon(log.status); diff --git a/mobile/src/screens/FilesScreen.tsx b/mobile/src/screens/FilesScreen.tsx index dc0602d3..d40b1c65 100644 --- a/mobile/src/screens/FilesScreen.tsx +++ b/mobile/src/screens/FilesScreen.tsx @@ -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(null); const [searchQuery, setSearchQuery] = useState(""); const searchTimeoutRef = useRef | null>(null); + // Subscribe to language changes so translated strings re-render. + useLocale(); const fetchFiles = useCallback( async (pageNum: number, replace: boolean, search?: string) => { @@ -150,7 +153,7 @@ export default function FilesScreen() { {error} - Retry + {t("common.retry")} ); @@ -163,21 +166,21 @@ export default function FilesScreen() { {searchQuery.length > 0 && ( @@ -199,12 +202,10 @@ export default function FilesScreen() { - {searchQuery ? "No documents match your search." : "No documents yet."} + {searchQuery ? t("files.search_empty") : t("files.empty_title")} - {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")} } diff --git a/mobile/src/screens/LoginScreen.tsx b/mobile/src/screens/LoginScreen.tsx index 31a35784..e92a5596 100644 --- a/mobile/src/screens/LoginScreen.tsx +++ b/mobile/src/screens/LoginScreen.tsx @@ -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() { /> DocuElevate - Intelligent Document Processing + {t("welcome.tagline")} - Server URL + {t("login.server_url")} {loading ? ( ) : ( - Sign in with SSO + {t("login.sign_in_sso")} )} - or + {t("login.or")} @@ -148,26 +151,24 @@ export default function LoginScreen() { }} disabled={loading || qrLoading} accessibilityRole="button" - accessibilityLabel="Sign in with QR code" + accessibilityLabel={t("login.scan_qr")} > {qrLoading ? ( ) : ( - 📱 Scan QR Code to Login + {t("login.scan_qr")} )} - - Sign in via SSO or scan a QR code from the web app. - + {t("login.hint")} router.back()} accessibilityRole="button" - accessibilityLabel="Back to welcome screen" + accessibilityLabel={t("login.back")} style={styles.backLink} > - ← Back + {t("login.back")} {/* Legal links – accessible pre-login for GDPR / Apple compliance */} @@ -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} > - Privacy Policy + {t("legal.privacy_policy")} · - Terms + {t("legal.terms")} · - Imprint + {t("legal.imprint")} diff --git a/mobile/src/screens/ProfileScreen.tsx b/mobile/src/screens/ProfileScreen.tsx index 6c4bb866..3990e1df 100644 --- a/mobile/src/screens/ProfileScreen.tsx +++ b/mobile/src/screens/ProfileScreen.tsx @@ -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 ( - Not signed in + {t("profile.not_signed_in")} ); } @@ -102,20 +112,20 @@ export default function ProfileScreen() { )} {user.display_name ?? user.owner_id} {user.email && {user.email}} - {user.is_admin && Admin} + {user.is_admin && {t("profile.admin")}} {/* Server info */} - Connection + {t("profile.connection")} - Server + {t("profile.server")} {effectiveBaseUrl} - User ID + {t("profile.user_id")} {user.owner_id} @@ -124,31 +134,28 @@ export default function ProfileScreen() { {/* Settings */} - Settings - Language + {t("profile.settings")} + {t("profile.language")} - {languages.map((lang) => ( + {languages.map((l) => ( { - 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 }} > - {lang.label} + {l.label} ))} @@ -157,32 +164,32 @@ export default function ProfileScreen() { {/* Legal & Privacy */} - Legal + {t("profile.legal")} - Privacy Policy + {t("profile.privacy_policy")} - Terms of Service + {t("profile.terms_of_service")} - Imprint + {t("profile.imprint")} @@ -193,9 +200,9 @@ export default function ProfileScreen() { style={styles.signOutButton} onPress={handleSignOut} accessibilityRole="button" - accessibilityLabel="Sign out" + accessibilityLabel={t("profile.sign_out")} > - Sign out + {t("profile.sign_out")} @@ -205,9 +212,9 @@ export default function ProfileScreen() { style={styles.deleteAccountButton} onPress={handleDeleteAccount} accessibilityRole="button" - accessibilityLabel="Delete account" + accessibilityLabel={t("profile.delete_account")} > - Delete Account + {t("profile.delete_account")} diff --git a/mobile/src/screens/UploadScreen.tsx b/mobile/src/screens/UploadScreen.tsx index 4a99023f..daa00a01 100644 --- a/mobile/src/screens/UploadScreen.tsx +++ b/mobile/src/screens/UploadScreen.tsx @@ -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([]); + // 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 ( - Please sign in to upload documents. + {t("upload.sign_in_required")} ); } @@ -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")} > - Camera + {t("upload.camera")} - Photos + {t("upload.photos")} - Files + {t("upload.files")} @@ -381,12 +384,8 @@ export default function UploadScreen() { {uploads.length === 0 ? ( - - Tap Camera, Photos, or Files to upload a document. - - - You can also share files from other apps directly to DocuElevate. - + {t("upload.empty_title")} + {t("upload.empty_hint")} ) : ( uploads.map((item) => ( @@ -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 = { 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 = { - 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} > @@ -446,7 +448,7 @@ function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: Upload )} {item.status === "done" && !item.serverStatus && ( - Queued for processing… + {t("upload.status_queued")} )} {item.status === "done" && item.serverStatus && ( {item.error} {canRetry && ( - Tap to retry + {t("upload.tap_retry")} )} )} diff --git a/mobile/src/screens/WelcomeScreen.tsx b/mobile/src/screens/WelcomeScreen.tsx index b56a91c0..8f3b8127 100644 --- a/mobile/src/screens/WelcomeScreen.tsx +++ b/mobile/src/screens/WelcomeScreen.tsx @@ -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 ( DocuElevate - Intelligent Document Processing - - Ingest documents, run OCR, extract metadata with AI, and route files - to your cloud storage — all in one seamless pipeline. - + {t("welcome.tagline")} + {t("welcome.description")} {/* Feature highlights */} - {FEATURES.map((feature) => ( + {features.map((feature) => ( {feature.icon} @@ -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")} > - Get Started + {t("welcome.get_started")} - - Connect to your self-hosted or cloud DocuElevate server. - + {t("welcome.hint")} {/* Legal links – accessible pre-login for GDPR / Apple compliance */} Linking.openURL("https://app.docuelevate.org/privacy")} accessibilityRole="link" - accessibilityLabel="Privacy Policy" + accessibilityLabel={t("legal.privacy_policy")} style={styles.legalLinkButton} > - Privacy Policy + {t("legal.privacy_policy")} · Linking.openURL("https://app.docuelevate.org/terms")} accessibilityRole="link" - accessibilityLabel="Terms of Service" + accessibilityLabel={t("legal.terms")} style={styles.legalLinkButton} > - Terms + {t("legal.terms")} · Linking.openURL("https://app.docuelevate.org/imprint")} accessibilityRole="link" - accessibilityLabel="Imprint" + accessibilityLabel={t("legal.imprint")} style={styles.legalLinkButton} > - Imprint + {t("legal.imprint")} diff --git a/mobile/src/services/api.ts b/mobile/src/services/api.ts index 1e0b41ea..27861f85 100644 --- a/mobile/src/services/api.ts +++ b/mobile/src/services/api.ts @@ -26,6 +26,7 @@ export interface WhoAmIResponse { email: string | null; avatar_url: string | null; is_admin: boolean; + preferred_language: string | null; } export interface GenerateTokenResponse { @@ -209,6 +210,11 @@ class DocuElevateAPI { return this.request("GET", "/api/mobile/whoami"); } + /** Sync the user's preferred UI language to the server. */ + async setServerLanguage(lang: string): Promise { + await this.request("POST", "/api/i18n/language", { body: { language: lang } }); + } + // ------------------------------------------------------------------------- // Push notifications // ------------------------------------------------------------------------- diff --git a/tests/test_api_mobile.py b/tests/test_api_mobile.py index 6069694e..5fcd7229 100644 --- a/tests/test_api_mobile.py +++ b/tests/test_api_mobile.py @@ -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)