diff --git a/.changeset/159-ten-locales.md b/.changeset/159-ten-locales.md new file mode 100644 index 0000000..4293524 --- /dev/null +++ b/.changeset/159-ten-locales.md @@ -0,0 +1,20 @@ +--- +"mobile": minor +--- + +Clause-②: no +Metadata-protocol: none + +The native screens (server picker, confirm step, loading and offline pages) +now ship in the same ten languages as the Console UI, under the same codes: +en, zh, ja, ko, de, fr, es, pt, ru, ar. The app picks the first of the phone's +preferred languages that it supports, with the Console's matching rule (a +region tag falls back to its base language: `pt-BR` → `pt`, `zh-Hans-CN` → +`zh`; Traditional Chinese → `zh`, as in the Console), else English. A +phone-language change is picked up when the app next comes to the foreground. +Under Arabic the native screens lay out right-to-left from the first render. +There is no in-app language picker: the phone setting decides, and a language +stored by an earlier in-app picker is no longer read. The QR scanner's strings +and the iOS camera permission prompt are localized in all ten languages too +(Traditional Chinese phones get the Simplified Chinese prompt, matching the +app's own strings). diff --git a/__tests__/components/RtlLayout.test.tsx b/__tests__/components/RtlLayout.test.tsx new file mode 100644 index 0000000..a51aec0 --- /dev/null +++ b/__tests__/components/RtlLayout.test.tsx @@ -0,0 +1,155 @@ +/** + * Native screens lay out right-to-left under `ar`, as the Console does, and + * left-to-right under every other shell language. The direction is Yoga's + * `direction` style on each screen root, so it applies on first render and + * follows a language change without a restart. + */ +import React from "react"; +import { act, render, screen } from "@testing-library/react-native"; +import { StyleSheet } from "react-native"; +// Types for the matchers jest.config registers through setupFilesAfterEnv. +import type {} from "@testing-library/react-native/extend-expect"; +import i18n from "~/lib/i18n"; +import { layoutDirection } from "~/lib/rtl"; + +jest.mock("expo-router", () => ({ + useRouter: () => ({ + dismissTo: jest.fn(), + push: jest.fn(), + replace: jest.fn(), + // Nothing behind the picker: with a saved server, "Back to " shows. + canGoBack: () => false, + }), + useLocalSearchParams: () => ({}), +})); + +jest.mock("react-native-safe-area-context", () => { + const { View } = require("react-native"); + return { SafeAreaView: View }; +}); + +import ServerConfigScreen from "~/app/server-config"; +import { OfflineFallback } from "~/components/shell/OfflineFallback"; +import { useServerStore } from "~/stores/server-store"; + +/** The direction on the outermost host view the screen renders. */ +function rootDirection(): unknown { + const tree = screen.toJSON(); + const root = Array.isArray(tree) ? tree[0] : tree; + return StyleSheet.flatten(root?.props.style)?.direction; +} + +const offline = ( + +); + +/** + * Cold-load cost, paid once outside any test's 5 s budget — the same warm-up as + * ServerConfigScreen.test.tsx: react-native's lazy component getters are + * required and transformed on the first render, which on a cold CI transform + * cache exceeded the per-test timeout. + */ +const COLD_LOAD_TIMEOUT_MS = 30_000; +beforeAll(() => { + render().unmount(); + render(offline).unmount(); +}, COLD_LOAD_TIMEOUT_MS); + +afterEach(async () => { + useServerStore.setState({ serverUrl: null }); + await act(async () => { + await i18n.changeLanguage("en"); + }); +}); + +describe("layoutDirection", () => { + it("is rtl for Arabic and ltr for the other nine shell languages", () => { + expect(layoutDirection("ar")).toBe("rtl"); + for (const code of ["en", "zh", "ja", "ko", "de", "fr", "es", "pt", "ru"]) { + expect(layoutDirection(code)).toBe("ltr"); + } + }); +}); + +describe("server picker", () => { + it("renders left-to-right under en", () => { + render(); + expect(rootDirection()).toBe("ltr"); + }); + + it("renders right-to-left, in Arabic, under ar", async () => { + await act(async () => { + await i18n.changeLanguage("ar"); + }); + render(); + expect(rootDirection()).toBe("rtl"); + expect(screen.getByText("الاتصال بالخادم")).toBeTruthy(); + }); + + it("flips direction live when the language changes while mounted", async () => { + render(); + expect(rootDirection()).toBe("ltr"); + await act(async () => { + await i18n.changeLanguage("ar"); + }); + expect(rootDirection()).toBe("rtl"); + await act(async () => { + await i18n.changeLanguage("ja"); + }); + expect(rootDirection()).toBe("ltr"); + expect(screen.getByText("サーバーに接続")).toBeTruthy(); + }); +}); + +describe("server picker controls under ar", () => { + it("keeps the scan button and the way-back button inside the right-to-left root, in Arabic", async () => { + useServerStore.setState({ serverUrl: "https://erp.example.com" }); + await act(async () => { + await i18n.changeLanguage("ar"); + }); + render(); + + expect(rootDirection()).toBe("rtl"); + const scan = screen.getByTestId("server-scan"); + expect(scan.props.accessibilityLabel).toBe("مسح رمز QR"); + expect(screen.getByTestId("server-back-to-saved")).toHaveTextContent( + "العودة إلى erp.example.com" + ); + // Neither control, nor anything between it and the root, pins a + // physical left/right or its own direction, so the root's rtl reaches it. + for (const id of ["server-scan", "server-back-to-saved"]) { + let node = screen.getByTestId(id).parent; + while (node) { + const style = StyleSheet.flatten(node.props.style) ?? {}; + if (style.direction !== undefined) { + expect({ id, direction: style.direction }).toEqual({ id, direction: "rtl" }); + } + for (const key of ["left", "right", "marginLeft", "marginRight", "paddingLeft", "paddingRight"]) { + expect({ id, key, value: (style as Record)[key] }).toEqual({ + id, + key, + value: undefined, + }); + } + node = node.parent; + } + } + }); +}); + +describe("offline page", () => { + it("renders right-to-left under ar and left-to-right under en", async () => { + render(offline); + expect(rootDirection()).toBe("ltr"); + await act(async () => { + await i18n.changeLanguage("ar"); + }); + expect(rootDirection()).toBe("rtl"); + expect(screen.getByText("أنت غير متصل")).toBeTruthy(); + }); +}); diff --git a/__tests__/lib/i18n.test.ts b/__tests__/lib/i18n.test.ts index 8e68762..8093758 100644 --- a/__tests__/lib/i18n.test.ts +++ b/__tests__/lib/i18n.test.ts @@ -1,18 +1,54 @@ +import { readdirSync, readFileSync } from "fs"; +import { join } from "path"; +import { AppState } from "react-native"; import i18n, { + DEFAULT_LANGUAGE, detectDeviceLanguage, + followDeviceLanguage, + followDeviceLanguageOnForeground, isRTL, + matchLanguageTag, + resolveLanguage, SUPPORTED_LANGUAGES, RTL_LANGUAGES, } from "~/lib/i18n"; +/** + * The Console UI's ten built-in catalogue codes, in its order (objectstack + * `ui/packages/i18n/src/locales/registry.ts`, `BUILT_IN_LANGUAGE_CODES`). + * The shell must match this list one-to-one. + */ +const CONSOLE_LANGUAGE_CODES = ["en", "zh", "ja", "ko", "de", "fr", "es", "pt", "ru", "ar"]; + +// jest.setup.ts mocks this module without a `default`, which is what +// `react-native`'s `AppState` getter reads; give it one for this file. +jest.mock("react-native/Libraries/AppState/AppState", () => { + const appState = { + currentState: "active", + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + }; + return { __esModule: true, default: appState, ...appState }; +}); + +// The module object `~/lib/i18n` calls `getLocales` on, so a spy reaches it. +const Localization = jest.requireMock("expo-localization") as typeof import("expo-localization"); + +const LOCALES_DIR = join(__dirname, "..", "..", "locales"); + +const tags = (...languageTags: string[]) => languageTags.map((languageTag) => ({ languageTag })); + +afterEach(async () => { + await i18n.changeLanguage("en"); +}); + describe("i18n configuration", () => { it("exports a configured i18next instance", () => { expect(i18n).toBeDefined(); expect(i18n.isInitialized).toBe(true); }); - it("defaults to English", () => { - // jest.setup.ts mocks getLocales to return en + it("starts in English when the phone is set to English", () => { + // jest.setup.ts mocks getLocales to return en-US expect(i18n.language).toBe("en"); }); @@ -20,19 +56,192 @@ describe("i18n configuration", () => { expect(i18n.t("common.ok")).toBe("OK"); }); - it("falls back to English for missing keys", () => { - // Switch to zh then ask for a key we know exists - i18n.changeLanguage("zh"); + it("translates through the active bundle", async () => { + await i18n.changeLanguage("zh"); expect(i18n.t("common.ok")).toBe("确定"); - i18n.changeLanguage("en"); // restore + await i18n.changeLanguage("ja"); + expect(i18n.t("serverConfirm.signIn.emailPassword")).toBe("メールアドレスとパスワード"); + }); +}); + +describe("SUPPORTED_LANGUAGES", () => { + it("is exactly the Console UI's ten locale codes, in the Console's order", () => { + expect(SUPPORTED_LANGUAGES.map((l) => l.code)).toEqual(CONSOLE_LANGUAGE_CODES); + }); + + it("names every language in its own language", () => { + for (const { label } of SUPPORTED_LANGUAGES) expect(label).not.toBe(""); + }); + + it("has one bundle per language and no other bundle", () => { + const bundles = readdirSync(LOCALES_DIR) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")) + .sort(); + expect(bundles).toEqual([...CONSOLE_LANGUAGE_CODES].sort()); + }); +}); + +describe("locale bundles", () => { + type Tree = { [key: string]: string | Tree }; + const read = (code: string): Tree => + JSON.parse(readFileSync(join(LOCALES_DIR, `${code}.json`), "utf8")) as Tree; + const flatten = (tree: Tree, prefix = ""): Record => + Object.entries(tree).reduce>((out, [key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + return typeof value === "string" + ? { ...out, [path]: value } + : { ...out, ...flatten(value, path) }; + }, {}); + const placeholders = (s: string) => (s.match(/\{\{\w+\}\}/g) ?? []).sort(); + + const en = flatten(read("en")); + + it.each(CONSOLE_LANGUAGE_CODES.filter((c) => c !== "en"))( + "%s carries every native string key en has, and no other", + (code) => { + const bundle = flatten(read(code)); + const missing = Object.keys(en).filter((k) => !(k in bundle)); + const extra = Object.keys(bundle).filter((k) => !(k in en)); + expect({ missing, extra }).toEqual({ missing: [], extra: [] }); + } + ); + + it.each(CONSOLE_LANGUAGE_CODES)("%s has no empty string and keeps every placeholder", (code) => { + const bundle = flatten(read(code)); + for (const [key, value] of Object.entries(bundle)) { + expect({ key, empty: value.trim() === "" }).toEqual({ key, empty: false }); + expect({ key, placeholders: placeholders(value) }).toEqual({ + key, + placeholders: placeholders(en[key] ?? ""), + }); + } + }); +}); + +describe("matchLanguageTag — the Console's exact-then-base rule", () => { + // The Console's own pins (region-locale-fallback-11326.test.tsx), ported. + it("prefers an exact match over the base language, and falls back to the base", () => { + const has = (code: string) => code === "pt" || code === "pt-BR"; + expect(matchLanguageTag("pt-BR", has)).toBe("pt-BR"); + expect(matchLanguageTag("pt-PT", has)).toBe("pt"); + expect(matchLanguageTag("pt", has)).toBe("pt"); + expect(matchLanguageTag("xx-YY", has)).toBeNull(); }); }); describe("detectDeviceLanguage", () => { - it("returns a supported language code", () => { - const lang = detectDeviceLanguage(); - const codes = SUPPORTED_LANGUAGES.map((l) => l.code) as readonly string[]; - expect(codes).toContain(lang); + it.each(CONSOLE_LANGUAGE_CODES)("resolves the bare code %s to itself", (code) => { + expect(detectDeviceLanguage(tags(code))).toBe(code); + }); + + it.each([ + ["en-US", "en"], + ["zh-CN", "zh"], + ["ja-JP", "ja"], + ["ko-KR", "ko"], + ["de-DE", "de"], + ["fr-FR", "fr"], + ["es-ES", "es"], + ["pt-PT", "pt"], + ["ru-RU", "ru"], + ["ar-SA", "ar"], + ])("resolves the region tag %s to %s", (tag, code) => { + expect(detectDeviceLanguage(tags(tag))).toBe(code); + }); + + it.each([ + ["zh-Hans-CN", "zh"], + ["pt-BR", "pt"], + ["es-MX", "es"], + ])("resolves %s to %s", (tag, code) => { + expect(detectDeviceLanguage(tags(tag))).toBe(code); + }); + + // The Console has no Traditional Chinese catalogue, and its rule resolves a + // `zh-*` tag to its base `zh` (registry.ts `matchLanguageTag`). The shell + // follows that rule rather than inventing a different one. + it.each(["zh-TW", "zh-HK", "zh-Hant", "zh-Hant-TW", "zh-Hant-HK"])( + "resolves Traditional Chinese %s to zh, as the Console does", + (tag) => { + expect(detectDeviceLanguage(tags(tag))).toBe("zh"); + } + ); + + it("falls through an unsupported first language to the next supported one", () => { + expect(detectDeviceLanguage(tags("he-IL", "ja-JP", "en-US"))).toBe("ja"); + expect(detectDeviceLanguage(tags("tlh", "it-IT", "pt-BR"))).toBe("pt"); + }); + + it("falls back to en when no preferred language is supported", () => { + expect(detectDeviceLanguage(tags("it-IT", "nl-NL"))).toBe(DEFAULT_LANGUAGE); + expect(DEFAULT_LANGUAGE).toBe("en"); + }); + + it("falls back to en for an empty locale list", () => { + expect(detectDeviceLanguage([])).toBe("en"); + }); + + it("uses languageCode when a locale has no languageTag", () => { + expect(detectDeviceLanguage([{ languageTag: "", languageCode: "ko" }])).toBe("ko"); + }); + + it("reads the phone's languages when none are passed", () => { + const spy = jest + .spyOn(Localization, "getLocales") + .mockReturnValue(tags("fr-CA") as ReturnType); + expect(detectDeviceLanguage()).toBe("fr"); + spy.mockRestore(); + }); + + it("falls back to en when reading the phone's languages throws", () => { + const spy = jest.spyOn(Localization, "getLocales").mockImplementation(() => { + throw new Error("native module unavailable"); + }); + expect(detectDeviceLanguage()).toBe("en"); + spy.mockRestore(); + }); +}); + +describe("resolveLanguage", () => { + it("skips empty entries", () => { + expect(resolveLanguage([null, undefined, "", "ru-RU"])).toBe("ru"); + }); +}); + +describe("following the phone language", () => { + it("switches the shell to the phone's current language", async () => { + await expect(followDeviceLanguage(tags("de-AT"))).resolves.toBe("de"); + expect(i18n.language).toBe("de"); + expect(i18n.t("shell.changeServer")).toBe("Anderen Server verwenden"); + }); + + it("re-reads the phone's languages when the app comes back to the foreground", async () => { + const addEventListener = AppState.addEventListener as jest.Mock; + const remove = jest.fn(); + addEventListener.mockReturnValueOnce({ remove }); + const spy = jest + .spyOn(Localization, "getLocales") + .mockReturnValue(tags("ar-EG") as ReturnType); + + const stop = followDeviceLanguageOnForeground(); + const [event, listener] = addEventListener.mock.calls.at(-1) as [ + string, + (state: string) => void, + ]; + expect(event).toBe("change"); + + // Going to the background changes nothing yet. + listener("background"); + expect(i18n.language).toBe("en"); + + listener("active"); + await new Promise((r) => setTimeout(r, 0)); + expect(i18n.language).toBe("ar"); + + stop(); + expect(remove).toHaveBeenCalled(); + spy.mockRestore(); }); }); @@ -45,23 +254,15 @@ describe("isRTL", () => { expect(isRTL("he")).toBe(true); }); - it("returns false for English", () => { - expect(isRTL("en")).toBe(false); + it("returns false for every other shell language", () => { + for (const code of CONSOLE_LANGUAGE_CODES.filter((c) => c !== "ar")) { + expect(isRTL(code)).toBe(false); + } }); }); describe("RTL_LANGUAGES", () => { - it("contains at least Arabic and Hebrew", () => { - expect(RTL_LANGUAGES.has("ar")).toBe(true); - expect(RTL_LANGUAGES.has("he")).toBe(true); - }); -}); - -describe("SUPPORTED_LANGUAGES", () => { - it("includes en, zh, and ar", () => { - const codes = SUPPORTED_LANGUAGES.map((l) => l.code); - expect(codes).toContain("en"); - expect(codes).toContain("zh"); - expect(codes).toContain("ar"); + it("matches the Console's RTL list (locales/rtl.ts)", () => { + expect([...RTL_LANGUAGES].sort()).toEqual(["ar", "fa", "he", "ur"]); }); }); diff --git a/app.config.ts b/app.config.ts index df99846..d242388 100644 --- a/app.config.ts +++ b/app.config.ts @@ -1,15 +1,34 @@ import { ExpoConfig, ConfigContext } from "expo/config"; import ar from "./locales/ar.json"; +import de from "./locales/de.json"; import en from "./locales/en.json"; +import es from "./locales/es.json"; +import fr from "./locales/fr.json"; +import ja from "./locales/ja.json"; +import ko from "./locales/ko.json"; +import pt from "./locales/pt.json"; +import ru from "./locales/ru.json"; import zh from "./locales/zh.json"; // System permission prompts, localized from the app's own bundles (key // `permissions.camera`) so one file holds each language's copy. iOS reads them // from `/InfoPlist.strings`; Android's camera prompt is the system's own -// text. Keys are iOS localization names (`zh` is Simplified Chinese). +// text. Keys are iOS localization names, written verbatim as `.lproj`: +// one per shell language (lib/i18n.ts). `zh` is Simplified Chinese, so it is +// `zh-Hans`; Traditional Chinese (`zh-Hant`) gets the same `zh` copy, because +// the shell resolves `zh-TW`/`zh-HK` to `zh` as the Console does. `pt` and `es` +// stay unqualified so every regional variant falls back to them. const PERMISSION_LOCALES: Record = { en, "zh-Hans": zh, + "zh-Hant": zh, + ja, + ko, + de, + fr, + es, + pt, + ru, ar, }; diff --git a/app/_layout.tsx b/app/_layout.tsx index 295012c..797fa5e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,6 +1,6 @@ import "../global.css"; -import i18n from "~/lib/i18n"; // Initialize i18next before any screen calls useTranslation() -import { syncRTL } from "~/lib/rtl"; +import i18n, { followDeviceLanguageOnForeground } from "~/lib/i18n"; // Initialize i18next before any screen calls useTranslation() +import { syncRTL, useLayoutDirection } from "~/lib/rtl"; import { useCallback, useEffect } from "react"; import { Stack, useRouter } from "expo-router"; @@ -35,8 +35,18 @@ export default function RootLayout() { void hydrate(); }, [hydrate]); + // The phone language is the only source: follow a change on next foreground, + // and keep the persisted native direction flag in step with the language. + const direction = useLayoutDirection(); useEffect(() => { syncRTL(i18n.language); + const onLanguageChanged = (lang: string) => syncRTL(lang); + i18n.on("languageChanged", onLanguageChanged); + const stopFollowing = followDeviceLanguageOnForeground(); + return () => { + stopFollowing(); + i18n.off("languageChanged", onLanguageChanged); + }; }, []); const openRoute = useCallback( @@ -46,7 +56,7 @@ export default function RootLayout() { usePushRouting(serverUrl, openRoute); return ( - + diff --git a/app/server-config.tsx b/app/server-config.tsx index fcfd873..466ef9c 100644 --- a/app/server-config.tsx +++ b/app/server-config.tsx @@ -22,6 +22,7 @@ import { type ProbeFailureKind, } from "~/lib/server-probe"; import { shellConfig } from "~/lib/shell/config"; +import { useLayoutDirection } from "~/lib/rtl"; import { parseUrl } from "~/lib/shell/console-url"; import { useThemeColors } from "~/lib/theme-colors"; import { useServerStore } from "~/stores/server-store"; @@ -68,6 +69,7 @@ export default function ServerConfigScreen() { const router = useRouter(); const { t } = useTranslation(); const colors = useThemeColors(); + const direction = useLayoutDirection(); const current = useServerStore((s) => s.serverUrl); const connect = useServerStore((s) => s.connect); const params = useLocalSearchParams<{ server?: string; n?: string }>(); @@ -148,7 +150,7 @@ export default function ServerConfigScreen() { if (probed) { return ( - + + diff --git a/docs/HYBRID-SHELL.md b/docs/HYBRID-SHELL.md index f17796d..6288c26 100644 --- a/docs/HYBRID-SHELL.md +++ b/docs/HYBRID-SHELL.md @@ -35,6 +35,8 @@ components/shell/ QrScanner.tsx full-screen camera QR scanner (reusable; `accept` decides) OfflineFallback.tsx native page for network / 5xx failures lib/ + i18n.ts shell strings in the Console's ten locales; follows the phone language + rtl.ts right-to-left layout for Arabic server-probe.ts anonymous probe of a server before it is saved server-url.ts persistence of the chosen server URL lib/shell/ @@ -57,6 +59,41 @@ and makes no authenticated API calls of its own** — its only requests are the anonymous server probe below. Sign-in, sign-up, 2FA and password reset are the Console's pages. +## Language + +The shell translates only its own native screens (server picker, confirm step, +loading and offline pages); everything inside the WebView is the Console's. + +- **The locale set mirrors the Console UI's.** The shell ships the same ten + locales under the same short codes as the Console's built-in catalogues + (objectstack `ui/packages/i18n/src/locales/`): `en`, `zh`, `ja`, `ko`, `de`, + `fr`, `es`, `pt`, `ru`, `ar`. A test pins `SUPPORTED_LANGUAGES` to that list, + and another fails when any bundle in `locales/` misses a key `en.json` has — + a new native string ships in all ten. +- **The phone language decides, with the Console's matching rule.** The shell + walks the phone's preferred languages in order and takes the first one that + resolves to a bundled locale, else `en`. A tag resolves exactly, else through + its base language: the Console's `matchLanguageTag` + (`ui/packages/i18n/src/locales/registry.ts`), ported as is. So `zh-Hans-CN` → + `zh`, `pt-BR` → `pt`, `es-MX` → `es`, and Traditional Chinese (`zh-TW`, + `zh-HK`, `zh-Hant`) → `zh`, because the Console has no Traditional Chinese + catalogue either. +- **No in-app language picker.** The phone setting is the only source for the + native screens. A phone-language change is picked up the next time the app + comes to the foreground. +- **Arabic lays out right-to-left.** Each native screen root carries Yoga's + `direction` style for the current language, so the switch applies on first + render and on a language change without a restart. `I18nManager.forceRTL` is + kept in step as well; what reads it (for example the navigation stack's + transition direction) follows on the next launch. +- **Once signed in, the user's Console locale wins inside the Console.** The + Console resolves its own language: the user's `sys_user.locale`, else the + request's `Accept-Language` (the WebView's own; the shell does not set it), else + the deployment default. So a user whose Console locale differs from the + phone sees the native screens in the phone's language and the Console in + their own. Built-in object and field labels exist in fewer locales than the + Console chrome; that gap is the platform's, not the shell's. + ## Server selection The picker (`app/server-config.tsx`) has two steps, and the server is saved @@ -131,7 +168,9 @@ server's Console and saves nothing. - **Camera permission.** The scanner asks on open. Refused for good, it offers the system Settings and "Enter address manually". The iOS prompt text is `permissions.camera` in each locale bundle, written by `app.config.ts` into - the `expo-camera` plugin and per-language `InfoPlist.strings`; the plugin is + the `expo-camera` plugin and per-language `InfoPlist.strings` for all ten + shell languages (`zh-Hans`, plus `zh-Hant` reusing the `zh` text, as the + shell's own strings do); the plugin is configured for the camera only (no microphone, no `RECORD_AUDIO`). `QrScanner` knows nothing about servers: the caller passes `accept`, the @@ -388,6 +427,9 @@ See [PUSH-NOTIFICATIONS.md](./PUSH-NOTIFICATIONS.md). under `__tests__/lib`. - The picker and its confirm step — component tests under `__tests__/components` (`ServerConfigScreen`, `ServerConfirm`). +- Locale list, phone-language matching and bundle completeness — + `__tests__/lib/i18n.test.ts`; right-to-left native screens under `ar` — + `__tests__/components/RtlLayout.test.tsx`. - The scanner and screen 6 — `__tests__/components/QrScanner.test.tsx`, with a mocked `expo-camera` (permission states, refused and accepted codes, torch). - The bootstrap and navigate scripts are executed against a fake `window` in diff --git a/lib/i18n.ts b/lib/i18n.ts index d1e28bd..8613653 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -1,82 +1,167 @@ /** * Internationalization configuration. * - * Integrates i18next + react-i18next + expo-localization for locale detection. - * Ships the shell's own strings (server picker, loading, offline page) in - * en, zh and ar. Everything inside the Console is translated by the Console. + * Integrates i18next + react-i18next + expo-localization. Ships the shell's own + * strings (server picker, confirm screen, loading, offline page) in the same + * ten locales, under the same short codes, as the Console UI + * (objectstack `ui/packages/i18n/src/locales/registry.ts`, + * `BUILT_IN_LANGUAGE_CODES`), so a phone language lands on the same locale on + * both sides. Everything inside the Console is translated by the Console. + * + * The phone setting is the only source: there is no in-app language picker for + * the native screens, and the shell follows a phone-language change the next + * time the app comes to the foreground ({@link followDeviceLanguageOnForeground}). */ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; -import { getLocales } from "expo-localization"; -import { createMMKV } from "react-native-mmkv"; +import { getLocales, type Locale } from "expo-localization"; +import { AppState, type AppStateStatus } from "react-native"; import en from "~/locales/en.json"; import zh from "~/locales/zh.json"; +import ja from "~/locales/ja.json"; +import ko from "~/locales/ko.json"; +import de from "~/locales/de.json"; +import fr from "~/locales/fr.json"; +import es from "~/locales/es.json"; +import pt from "~/locales/pt.json"; +import ru from "~/locales/ru.json"; import ar from "~/locales/ar.json"; -/** MMKV store holding a persisted language override, when one has been set. */ -const uiStorage = createMMKV({ id: "objectstack-ui" }); -export const LANGUAGE_KEY = "language"; - -/** Languages that use right-to-left layout */ +/** Languages that use right-to-left layout — the Console's list (`locales/rtl.ts`). */ export const RTL_LANGUAGES = new Set(["ar", "he", "fa", "ur"]); -/** All bundled languages with display names */ +/** + * All bundled languages with their native display names: the Console's ten + * codes in the Console's order, named as the Console's language menu names them + * (objectstack `ui/packages/app-shell/src/layout/LocaleSwitcher.tsx`). + */ export const SUPPORTED_LANGUAGES = [ { code: "en", label: "English" }, { code: "zh", label: "中文" }, + { code: "ja", label: "日本語" }, + { code: "ko", label: "한국어" }, + { code: "de", label: "Deutsch" }, + { code: "fr", label: "Français" }, + { code: "es", label: "Español" }, + { code: "pt", label: "Português" }, + { code: "ru", label: "Русский" }, { code: "ar", label: "العربية" }, ] as const; export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"]; +/** The language used when no preferred phone language is supported. */ +export const DEFAULT_LANGUAGE: SupportedLanguage = "en"; + +const SUPPORTED_CODES: ReadonlySet = new Set(SUPPORTED_LANGUAGES.map((l) => l.code)); + +function isSupportedLanguage(code: string): code is SupportedLanguage { + return SUPPORTED_CODES.has(code); +} + /** - * Detect the device's preferred language code. - * Falls back to `"en"` if detection fails or the locale is unsupported. + * Exact-then-base language-tag matching: `tag` itself when `has(tag)`, else its + * base language (`zh-CN` → `zh`) when `has(base)`, else `null`. + * + * A port of the Console's `matchLanguageTag` (objectstack + * `ui/packages/i18n/src/locales/registry.ts`), kept byte-for-byte in behaviour + * so a tag resolves to the same locale on both sides. Consequences that follow + * from the Console's rule, not from a choice made here: + * + * - Traditional Chinese (`zh-Hant`, `zh-TW`, `zh-HK`, `zh-Hant-TW`) has no + * catalogue of its own, so it resolves to `zh`, as in the Console. + * - The base is the text before the first `-`; case is not folded. */ -export function detectDeviceLanguage(): SupportedLanguage { - try { - const locales = getLocales(); - const deviceLang = locales?.[0]?.languageCode ?? "en"; - const supported = SUPPORTED_LANGUAGES.map((l) => l.code) as readonly string[]; - return (supported.includes(deviceLang) ? deviceLang : "en") as SupportedLanguage; - } catch { - return "en"; +export function matchLanguageTag(tag: string, has: (code: string) => boolean): string | null { + if (has(tag)) return tag; + const base = tag.split("-")[0]; + if (base && base !== tag && has(base)) return base; + return null; +} + +/** + * Pick the shell language from an ordered list of preferred language tags: the + * first tag that {@link matchLanguageTag} resolves to a bundled language, else + * {@link DEFAULT_LANGUAGE}. + */ +export function resolveLanguage(tags: readonly (string | null | undefined)[]): SupportedLanguage { + for (const tag of tags) { + if (!tag) continue; + const match = matchLanguageTag(tag, isSupportedLanguage); + if (match) return match as SupportedLanguage; } + return DEFAULT_LANGUAGE; } /** - * Initial language: the user's persisted choice wins over device detection, so - * a manual selection survives the reload that an RTL direction change triggers. + * Detect the shell language from the phone's ordered preferred languages + * (`expo-localization`'s `getLocales()`, in the order the user set them). + * Falls back to `"en"` if detection fails or no preferred language is bundled. */ -function loadInitialLanguage(): SupportedLanguage { +export function detectDeviceLanguage(locales?: readonly Partial[]): SupportedLanguage { try { - const stored = uiStorage.getString(LANGUAGE_KEY); - const supported = SUPPORTED_LANGUAGES.map((l) => l.code) as readonly string[]; - if (stored && supported.includes(stored)) return stored as SupportedLanguage; + const list = locales ?? getLocales(); + return resolveLanguage((list ?? []).map((l) => l?.languageTag || l?.languageCode)); } catch { - // fall through to device detection + return DEFAULT_LANGUAGE; } - return detectDeviceLanguage(); } -/** Check whether the current language is RTL */ +/** Check whether a language (default: the current one) is laid out right-to-left. */ export function isRTL(lang?: string): boolean { - const code = lang ?? i18n.language ?? "en"; + const code = lang ?? i18n.language ?? DEFAULT_LANGUAGE; return RTL_LANGUAGES.has(code); } -const resources = { en: { translation: en }, zh: { translation: zh }, ar: { translation: ar } }; +const resources = { + en: { translation: en }, + zh: { translation: zh }, + ja: { translation: ja }, + ko: { translation: ko }, + de: { translation: de }, + fr: { translation: fr }, + es: { translation: es }, + pt: { translation: pt }, + ru: { translation: ru }, + ar: { translation: ar }, +} as const satisfies Record; i18n.use(initReactI18next).init({ resources, - lng: loadInitialLanguage(), - fallbackLng: "en", + lng: detectDeviceLanguage(), + fallbackLng: DEFAULT_LANGUAGE, interpolation: { escapeValue: false }, compatibilityJSON: "v4", }); +/** + * Re-read the phone's languages and switch the shell to the result when it + * differs from the current language. Resolves to the language now in effect. + */ +export async function followDeviceLanguage( + locales?: readonly Partial[] +): Promise { + const next = detectDeviceLanguage(locales); + if (i18n.language !== next) await i18n.changeLanguage(next); + return next; +} + +/** + * Follow a phone-language change on the next foreground: every time the app + * becomes `active` again, {@link followDeviceLanguage} runs. Returns the + * unsubscribe function. + */ +export function followDeviceLanguageOnForeground(): () => void { + let previous: AppStateStatus = AppState.currentState; + const subscription = AppState.addEventListener("change", (next: AppStateStatus) => { + if (next === "active" && previous !== "active") void followDeviceLanguage(); + previous = next; + }); + return () => subscription.remove(); +} + /** * Count-aware translation that does NOT depend on `Intl.PluralRules`. * diff --git a/lib/rtl.ts b/lib/rtl.ts index ea0b16b..e5ea0c5 100644 --- a/lib/rtl.ts +++ b/lib/rtl.ts @@ -1,6 +1,31 @@ import { I18nManager, Platform } from "react-native"; +import { useTranslation } from "react-i18next"; import { isRTL } from "./i18n"; +/** Yoga's `direction` style value for a screen root. */ +export type LayoutDirection = "ltr" | "rtl"; + +/** The layout direction of a language: `rtl` for Arabic (and the other RTL codes), else `ltr`. */ +export function layoutDirection(lang?: string): LayoutDirection { + return isRTL(lang) ? "rtl" : "ltr"; +} + +/** + * The layout direction of the current shell language, re-evaluated when the + * language changes. + * + * Native screens put it on their root view as `style={{ direction }}`. Yoga + * flips `flex-direction: row`, `start`/`end` insets and natural text alignment + * for that subtree at once — unlike `I18nManager.forceRTL` ({@link syncRTL}), + * which only takes effect after the next launch. So a phone switched to Arabic + * gets right-to-left native screens on the first launch, and a language change + * picked up on foreground flips them without a restart. + */ +export function useLayoutDirection(): LayoutDirection { + const { i18n } = useTranslation(); + return layoutDirection(i18n.language); +} + /** * Apply the writing direction for a language. * diff --git a/locales/de.json b/locales/de.json new file mode 100644 index 0000000..de97090 --- /dev/null +++ b/locales/de.json @@ -0,0 +1,80 @@ +{ + "common": { + "ok": "OK", + "cancel": "Abbrechen", + "retry": "Erneut versuchen", + "error": "Fehler" + }, + "errorBoundary": { + "title": "Etwas ist schiefgelaufen", + "message": "Ein unerwarteter Fehler ist aufgetreten.", + "retry": "Erneut versuchen" + }, + "serverConfig": { + "title": "Mit Server verbinden", + "subtitle": "Geben Sie die Adresse Ihres ObjectOS-Servers ein.", + "label": "Server-URL", + "hint": "Beispiel: https://erp.example.com", + "connect": "Weiter", + "scan": "QR-Code scannen", + "backTo": "Zurück zu {{server}}", + "where": { + "title": "Wo finde ich die Adresse?", + "step1": "Öffnen Sie ObjectOS auf einem Computer im Browser und melden Sie sich an.", + "step2": "Kopieren Sie die Adresse aus der Adressleiste. Sie können den ganzen Link einfügen – die App behält nur den Serverteil.", + "step3": "Unsicher? Fragen Sie Ihren Administrator." + }, + "vpnHint": "Für einen Server im Firmennetz muss dieses Telefon eventuell zuerst mit dem Firmen-VPN verbunden sein.", + "error": { + "empty": "Bitte geben Sie eine Server-URL ein.", + "invalid": "Das sieht nicht nach einer Serveradresse aus.", + "insecure": "Verwenden Sie eine https://-Adresse – unverschlüsseltes http ist nur für lokale Server erlaubt.", + "unreachable": "Der Server ist nicht erreichbar. Prüfen Sie die Adresse und Ihr Netzwerk – ein Firmenserver benötigt eventuell VPN.", + "tls": "Das Zertifikat des Servers ist ungültig, daher ist die Verbindung nicht sicher. Bitten Sie Ihren Administrator, es zu beheben.", + "timeout": "Der Server hat zu lange nicht geantwortet. Prüfen Sie Ihr Netzwerk oder VPN und versuchen Sie es erneut.", + "notObjectOS": "Dies ist kein ObjectOS-Server. Verwenden Sie die Adresse, mit der Sie ObjectOS im Browser öffnen.", + "serverError": "Der Server hat mit einem Fehler geantwortet (HTTP {{status}}). Versuchen Sie es später erneut oder fragen Sie Ihren Administrator.", + "unknown": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut." + } + }, + "serverConfirm": { + "title": "Ist das Ihr Server?", + "secure": "Sichere Verbindung (HTTPS)", + "signInLabel": "Anmelden mit", + "signIn": { + "sso": "Single Sign-On (SSO)", + "emailPassword": "E-Mail und Passwort" + }, + "versionLabel": "Plattformversion", + "confirm": "Verbinden und anmelden", + "reject": "Nicht dieser Server" + }, + "shell": { + "loading": "Wird geladen…", + "offlineTitle": "Sie sind offline", + "offlineMessage": "Prüfen Sie Ihre Verbindung. Die Seite lädt automatisch neu, sobald Sie wieder online sind.", + "serverErrorTitle": "Der Server antwortet nicht", + "serverErrorMessage": "Der Server hat mit Fehler {{code}} geantwortet. Versuchen Sie es gleich noch einmal.", + "retry": "Erneut versuchen", + "changeServer": "Anderen Server verwenden" + }, + "scanner": { + "hint": "Halten Sie den QR-Code in den Rahmen", + "connectNote": "Enthält nur die Serveradresse, keine Kontodaten", + "manual": "Adresse manuell eingeben", + "notConnectCode": "Das ist kein Verbindungs-QR-Code", + "close": "Schließen", + "torchOn": "Taschenlampe einschalten", + "torchOff": "Taschenlampe ausschalten", + "permission": { + "title": "Kamerazugriff erforderlich", + "body": "Erlauben Sie den Kamerazugriff, um den QR-Code zu scannen.", + "blocked": "Der Kamerazugriff für ObjectOS ist deaktiviert. Aktivieren Sie ihn in den Einstellungen oder geben Sie die Adresse manuell ein.", + "allow": "Kamera erlauben", + "settings": "Einstellungen öffnen" + } + }, + "permissions": { + "camera": "ObjectOS verwendet die Kamera, um den QR-Code zu scannen, der die App mit Ihrem Server verbindet." + } +} diff --git a/locales/es.json b/locales/es.json new file mode 100644 index 0000000..d6e6d6f --- /dev/null +++ b/locales/es.json @@ -0,0 +1,80 @@ +{ + "common": { + "ok": "Aceptar", + "cancel": "Cancelar", + "retry": "Reintentar", + "error": "Error" + }, + "errorBoundary": { + "title": "Algo salió mal", + "message": "Ha ocurrido un error inesperado.", + "retry": "Intentar de nuevo" + }, + "serverConfig": { + "title": "Conectar al servidor", + "subtitle": "Introduzca la dirección de su servidor ObjectOS.", + "label": "URL del servidor", + "hint": "Ejemplo: https://erp.example.com", + "connect": "Continuar", + "scan": "Escanear código QR", + "backTo": "Volver a {{server}}", + "where": { + "title": "¿Dónde encuentro la dirección?", + "step1": "En un ordenador, abra ObjectOS en el navegador e inicie sesión.", + "step2": "Copie la dirección de la barra de direcciones. Puede pegar el enlace completo: la aplicación solo conserva la parte del servidor.", + "step3": "¿No está seguro? Consulte a su administrador." + }, + "vpnHint": "Un servidor de la red de la empresa puede requerir que este teléfono se conecte antes a la VPN de la empresa.", + "error": { + "empty": "Introduzca la URL del servidor.", + "invalid": "Eso no parece una dirección de servidor.", + "insecure": "Use una dirección https://: http sin cifrar solo se permite para servidores locales.", + "unreachable": "No se pudo conectar con el servidor. Compruebe la dirección y su red: un servidor de empresa puede requerir VPN.", + "tls": "El certificado del servidor no es válido, por lo que la conexión no es segura. Pida a su administrador que lo corrija.", + "timeout": "El servidor tardó demasiado en responder. Compruebe su red o VPN e inténtelo de nuevo.", + "notObjectOS": "Esto no es un servidor ObjectOS. Use la dirección con la que abre ObjectOS en un navegador.", + "serverError": "El servidor respondió con un error (HTTP {{status}}). Inténtelo más tarde o consulte a su administrador.", + "unknown": "Algo salió mal. Inténtelo de nuevo." + } + }, + "serverConfirm": { + "title": "¿Es este su servidor?", + "secure": "Conexión segura (HTTPS)", + "signInLabel": "Iniciar sesión con", + "signIn": { + "sso": "Inicio de sesión único (SSO)", + "emailPassword": "Correo electrónico y contraseña" + }, + "versionLabel": "Versión de la plataforma", + "confirm": "Conectar e iniciar sesión", + "reject": "No es este servidor" + }, + "shell": { + "loading": "Cargando…", + "offlineTitle": "Sin conexión", + "offlineMessage": "Compruebe su conexión. La página se recargará sola cuando vuelva a estar en línea.", + "serverErrorTitle": "El servidor no responde", + "serverErrorMessage": "El servidor respondió con el error {{code}}. Inténtelo de nuevo en un momento.", + "retry": "Intentar de nuevo", + "changeServer": "Usar otro servidor" + }, + "scanner": { + "hint": "Coloque el código QR dentro del marco", + "connectNote": "Solo contiene la dirección del servidor, sin datos de la cuenta", + "manual": "Introducir la dirección manualmente", + "notConnectCode": "Este no es un código QR de conexión", + "close": "Cerrar", + "torchOn": "Encender la linterna", + "torchOff": "Apagar la linterna", + "permission": { + "title": "Se necesita acceso a la cámara", + "body": "Permita el acceso a la cámara para escanear el código QR.", + "blocked": "El acceso a la cámara está desactivado para ObjectOS. Actívelo en Configuración o introduzca la dirección manualmente.", + "allow": "Permitir la cámara", + "settings": "Abrir Configuración" + } + }, + "permissions": { + "camera": "ObjectOS usa la cámara para escanear el código QR que lo conecta a su servidor." + } +} diff --git a/locales/fr.json b/locales/fr.json new file mode 100644 index 0000000..d1650ca --- /dev/null +++ b/locales/fr.json @@ -0,0 +1,80 @@ +{ + "common": { + "ok": "OK", + "cancel": "Annuler", + "retry": "Réessayer", + "error": "Erreur" + }, + "errorBoundary": { + "title": "Quelque chose s'est mal passé", + "message": "Une erreur inattendue s'est produite.", + "retry": "Réessayer" + }, + "serverConfig": { + "title": "Se connecter à un serveur", + "subtitle": "Saisissez l'adresse de votre serveur ObjectOS.", + "label": "URL du serveur", + "hint": "Exemple : https://erp.example.com", + "connect": "Continuer", + "scan": "Scanner le code QR", + "backTo": "Retour à {{server}}", + "where": { + "title": "Où trouver l'adresse ?", + "step1": "Sur un ordinateur, ouvrez ObjectOS dans votre navigateur et connectez-vous.", + "step2": "Copiez l'adresse dans la barre d'adresse. Vous pouvez coller le lien complet : l'application n'en garde que la partie serveur.", + "step3": "Vous hésitez ? Demandez à votre administrateur." + }, + "vpnHint": "Pour un serveur du réseau de l'entreprise, ce téléphone devra peut-être d'abord se connecter au VPN de l'entreprise.", + "error": { + "empty": "Veuillez saisir l'URL du serveur.", + "invalid": "Cela ne ressemble pas à une adresse de serveur.", + "insecure": "Utilisez une adresse https:// : le http simple n'est autorisé que pour les serveurs locaux.", + "unreachable": "Impossible de joindre le serveur. Vérifiez l'adresse et votre réseau : un serveur d'entreprise peut nécessiter un VPN.", + "tls": "Le certificat du serveur n'est pas valide : la connexion n'est donc pas sûre. Demandez à votre administrateur de le corriger.", + "timeout": "Le serveur a mis trop de temps à répondre. Vérifiez votre réseau ou votre VPN, puis réessayez.", + "notObjectOS": "Ce n'est pas un serveur ObjectOS. Utilisez l'adresse avec laquelle vous ouvrez ObjectOS dans un navigateur.", + "serverError": "Le serveur a répondu par une erreur (HTTP {{status}}). Réessayez plus tard ou contactez votre administrateur.", + "unknown": "Une erreur est survenue. Veuillez réessayer." + } + }, + "serverConfirm": { + "title": "Est-ce bien votre serveur ?", + "secure": "Connexion sécurisée (HTTPS)", + "signInLabel": "Se connecter avec", + "signIn": { + "sso": "Authentification unique (SSO)", + "emailPassword": "E-mail et mot de passe" + }, + "versionLabel": "Version de la plateforme", + "confirm": "Utiliser ce serveur et se connecter", + "reject": "Ce n'est pas ce serveur" + }, + "shell": { + "loading": "Chargement…", + "offlineTitle": "Vous êtes hors ligne", + "offlineMessage": "Vérifiez votre connexion. La page se recharge d'elle-même dès que vous êtes de nouveau en ligne.", + "serverErrorTitle": "Le serveur ne répond pas", + "serverErrorMessage": "Le serveur a répondu avec l'erreur {{code}}. Réessayez dans un instant.", + "retry": "Réessayer", + "changeServer": "Utiliser un autre serveur" + }, + "scanner": { + "hint": "Placez le code QR dans le cadre", + "connectNote": "Contient uniquement l'adresse du serveur, aucune information de compte", + "manual": "Saisir l'adresse manuellement", + "notConnectCode": "Ce n'est pas un code QR de connexion", + "close": "Fermer", + "torchOn": "Allumer la lampe torche", + "torchOff": "Éteindre la lampe torche", + "permission": { + "title": "Accès à l'appareil photo requis", + "body": "Autorisez l'accès à l'appareil photo pour scanner le code QR.", + "blocked": "L'accès à l'appareil photo est désactivé pour ObjectOS. Activez-le dans les Paramètres ou saisissez l'adresse manuellement.", + "allow": "Autoriser l'appareil photo", + "settings": "Ouvrir les Paramètres" + } + }, + "permissions": { + "camera": "ObjectOS utilise l'appareil photo pour scanner le code QR qui le connecte à votre serveur." + } +} diff --git a/locales/ja.json b/locales/ja.json new file mode 100644 index 0000000..5d9af6b --- /dev/null +++ b/locales/ja.json @@ -0,0 +1,80 @@ +{ + "common": { + "ok": "OK", + "cancel": "キャンセル", + "retry": "再試行", + "error": "エラー" + }, + "errorBoundary": { + "title": "問題が発生しました", + "message": "予期しないエラーが発生しました。", + "retry": "再試行" + }, + "serverConfig": { + "title": "サーバーに接続", + "subtitle": "ObjectOS サーバーのアドレスを入力してください。", + "label": "サーバー URL", + "hint": "例: https://erp.example.com", + "connect": "続行", + "scan": "QR コードをスキャン", + "backTo": "{{server}} に戻る", + "where": { + "title": "アドレスはどこで確認できますか?", + "step1": "パソコンのブラウザで ObjectOS を開き、サインインします。", + "step2": "アドレスバーのアドレスをコピーします。リンク全体を貼り付けても、アプリはサーバーの部分だけを使います。", + "step3": "わからない場合は管理者に確認してください。" + }, + "vpnHint": "社内ネットワークのサーバーでは、先にこの端末を社内 VPN に接続する必要がある場合があります。", + "error": { + "empty": "サーバー URL を入力してください。", + "invalid": "サーバーのアドレスではないようです。", + "insecure": "https:// のアドレスを使用してください。http はローカルサーバーでのみ使用できます。", + "unreachable": "サーバーに接続できません。アドレスとネットワークを確認してください。社内サーバーには VPN が必要な場合があります。", + "tls": "サーバーの証明書が無効なため、安全に接続できません。管理者に修正を依頼してください。", + "timeout": "サーバーの応答に時間がかかりすぎています。ネットワークまたは VPN を確認して、もう一度お試しください。", + "notObjectOS": "ObjectOS サーバーではありません。ブラウザで ObjectOS を開くときのアドレスを使用してください。", + "serverError": "サーバーがエラーを返しました(HTTP {{status}})。しばらくしてからもう一度お試しいただくか、管理者に確認してください。", + "unknown": "問題が発生しました。もう一度お試しください。" + } + }, + "serverConfirm": { + "title": "このサーバーで合っていますか?", + "secure": "安全な接続(HTTPS)", + "signInLabel": "サインイン方法", + "signIn": { + "sso": "シングルサインオン(SSO)", + "emailPassword": "メールアドレスとパスワード" + }, + "versionLabel": "プラットフォームのバージョン", + "confirm": "接続してサインイン", + "reject": "このサーバーではない" + }, + "shell": { + "loading": "読み込み中…", + "offlineTitle": "オフラインです", + "offlineMessage": "接続を確認してください。オンラインに戻るとページが自動的に再読み込みされます。", + "serverErrorTitle": "サーバーが応答しません", + "serverErrorMessage": "サーバーがエラー {{code}} を返しました。しばらくしてからもう一度お試しください。", + "retry": "再試行", + "changeServer": "別のサーバーを使用" + }, + "scanner": { + "hint": "QR コードを枠内に合わせてください", + "connectNote": "サーバーのアドレスのみが含まれ、アカウント情報は含まれません", + "manual": "アドレスを手動で入力", + "notConnectCode": "接続用の QR コードではありません", + "close": "閉じる", + "torchOn": "ライトをオン", + "torchOff": "ライトをオフ", + "permission": { + "title": "カメラへのアクセスが必要です", + "body": "QR コードをスキャンするには、カメラへのアクセスを許可してください。", + "blocked": "ObjectOS のカメラへのアクセスがオフになっています。設定でオンにするか、アドレスを手動で入力してください。", + "allow": "カメラを許可", + "settings": "設定を開く" + } + }, + "permissions": { + "camera": "ObjectOS は、サーバーに接続するための QR コードをスキャンするためにカメラを使用します。" + } +} diff --git a/locales/ko.json b/locales/ko.json new file mode 100644 index 0000000..6f25506 --- /dev/null +++ b/locales/ko.json @@ -0,0 +1,80 @@ +{ + "common": { + "ok": "확인", + "cancel": "취소", + "retry": "다시 시도", + "error": "오류" + }, + "errorBoundary": { + "title": "문제가 발생했습니다", + "message": "예기치 않은 오류가 발생했습니다.", + "retry": "다시 시도" + }, + "serverConfig": { + "title": "서버에 연결", + "subtitle": "ObjectOS 서버 주소를 입력하세요.", + "label": "서버 URL", + "hint": "예: https://erp.example.com", + "connect": "계속", + "scan": "QR 코드 스캔", + "backTo": "{{server}}(으)로 돌아가기", + "where": { + "title": "주소는 어디에서 찾나요?", + "step1": "컴퓨터의 브라우저에서 ObjectOS를 열고 로그인하세요.", + "step2": "주소 표시줄의 주소를 복사하세요. 전체 링크를 붙여넣어도 앱은 서버 부분만 사용합니다.", + "step3": "잘 모르겠다면 관리자에게 문의하세요." + }, + "vpnHint": "회사 네트워크의 서버라면 먼저 이 휴대폰을 회사 VPN에 연결해야 할 수 있습니다.", + "error": { + "empty": "서버 URL을 입력하세요.", + "invalid": "서버 주소가 아닌 것 같습니다.", + "insecure": "https:// 주소를 사용하세요. http는 로컬 서버에서만 허용됩니다.", + "unreachable": "서버에 연결할 수 없습니다. 주소와 네트워크를 확인하세요. 회사 서버는 VPN이 필요할 수 있습니다.", + "tls": "서버 인증서가 유효하지 않아 안전하게 연결할 수 없습니다. 관리자에게 수정을 요청하세요.", + "timeout": "서버 응답 시간이 너무 오래 걸립니다. 네트워크 또는 VPN을 확인한 후 다시 시도하세요.", + "notObjectOS": "ObjectOS 서버가 아닙니다. 브라우저에서 ObjectOS를 열 때 사용하는 주소를 입력하세요.", + "serverError": "서버에서 오류를 반환했습니다(HTTP {{status}}). 나중에 다시 시도하거나 관리자에게 문의하세요.", + "unknown": "문제가 발생했습니다. 다시 시도해 주세요." + } + }, + "serverConfirm": { + "title": "이 서버가 맞나요?", + "secure": "보안 연결(HTTPS)", + "signInLabel": "로그인 방법", + "signIn": { + "sso": "싱글 사인온(SSO)", + "emailPassword": "이메일 및 비밀번호" + }, + "versionLabel": "플랫폼 버전", + "confirm": "연결하고 로그인", + "reject": "이 서버가 아닙니다" + }, + "shell": { + "loading": "로딩 중…", + "offlineTitle": "오프라인 상태입니다", + "offlineMessage": "연결을 확인하세요. 다시 온라인 상태가 되면 페이지가 자동으로 새로고침됩니다.", + "serverErrorTitle": "서버가 응답하지 않습니다", + "serverErrorMessage": "서버에서 오류 {{code}}을(를) 반환했습니다. 잠시 후 다시 시도하세요.", + "retry": "다시 시도", + "changeServer": "다른 서버 사용" + }, + "scanner": { + "hint": "QR 코드를 프레임 안에 맞춰 주세요", + "connectNote": "서버 주소만 포함되며 계정 정보는 포함되지 않습니다", + "manual": "주소 직접 입력", + "notConnectCode": "연결용 QR 코드가 아닙니다", + "close": "닫기", + "torchOn": "손전등 켜기", + "torchOff": "손전등 끄기", + "permission": { + "title": "카메라 접근 권한이 필요합니다", + "body": "QR 코드를 스캔하려면 카메라 접근을 허용하세요.", + "blocked": "ObjectOS의 카메라 접근이 꺼져 있습니다. 설정에서 켜거나 주소를 직접 입력하세요.", + "allow": "카메라 허용", + "settings": "설정 열기" + } + }, + "permissions": { + "camera": "ObjectOS는 서버에 연결하기 위한 QR 코드를 스캔하는 데 카메라를 사용합니다." + } +} diff --git a/locales/pt.json b/locales/pt.json new file mode 100644 index 0000000..a3d6ec0 --- /dev/null +++ b/locales/pt.json @@ -0,0 +1,80 @@ +{ + "common": { + "ok": "OK", + "cancel": "Cancelar", + "retry": "Tentar novamente", + "error": "Erro" + }, + "errorBoundary": { + "title": "Algo deu errado", + "message": "Ocorreu um erro inesperado.", + "retry": "Tentar novamente" + }, + "serverConfig": { + "title": "Conectar ao servidor", + "subtitle": "Digite o endereço do seu servidor ObjectOS.", + "label": "URL do servidor", + "hint": "Exemplo: https://erp.example.com", + "connect": "Continuar", + "scan": "Escanear código QR", + "backTo": "Voltar para {{server}}", + "where": { + "title": "Onde encontro o endereço?", + "step1": "Em um computador, abra o ObjectOS no navegador e entre.", + "step2": "Copie o endereço da barra de endereços. Você pode colar o link inteiro: o app mantém só a parte do servidor.", + "step3": "Não tem certeza? Pergunte ao seu administrador." + }, + "vpnHint": "Um servidor na rede da empresa pode exigir que este celular esteja conectado à VPN da empresa primeiro.", + "error": { + "empty": "Digite a URL do servidor.", + "invalid": "Isso não parece um endereço de servidor.", + "insecure": "Use um endereço https://: http sem criptografia só é permitido para servidores locais.", + "unreachable": "Não foi possível acessar o servidor. Verifique o endereço e sua rede: um servidor da empresa pode exigir VPN.", + "tls": "O certificado do servidor é inválido, então a conexão não é segura. Peça ao seu administrador para corrigi-lo.", + "timeout": "O servidor demorou demais para responder. Verifique sua rede ou VPN e tente novamente.", + "notObjectOS": "Este não é um servidor ObjectOS. Use o endereço com que você abre o ObjectOS no navegador.", + "serverError": "O servidor respondeu com um erro (HTTP {{status}}). Tente novamente mais tarde ou pergunte ao seu administrador.", + "unknown": "Algo deu errado. Tente novamente." + } + }, + "serverConfirm": { + "title": "Este é o seu servidor?", + "secure": "Conexão segura (HTTPS)", + "signInLabel": "Entrar com", + "signIn": { + "sso": "Login único (SSO)", + "emailPassword": "E-mail e senha" + }, + "versionLabel": "Versão da plataforma", + "confirm": "Conectar e entrar", + "reject": "Não é este servidor" + }, + "shell": { + "loading": "Carregando…", + "offlineTitle": "Você está offline", + "offlineMessage": "Verifique sua conexão. A página recarrega sozinha quando você voltar a ficar online.", + "serverErrorTitle": "O servidor não está respondendo", + "serverErrorMessage": "O servidor respondeu com o erro {{code}}. Tente novamente em instantes.", + "retry": "Tentar novamente", + "changeServer": "Usar outro servidor" + }, + "scanner": { + "hint": "Posicione o código QR dentro da moldura", + "connectNote": "Contém apenas o endereço do servidor, sem dados da conta", + "manual": "Digitar o endereço manualmente", + "notConnectCode": "Este não é um código QR de conexão", + "close": "Fechar", + "torchOn": "Ligar a lanterna", + "torchOff": "Desligar a lanterna", + "permission": { + "title": "Acesso à câmera necessário", + "body": "Permita o acesso à câmera para escanear o código QR.", + "blocked": "O acesso à câmera está desativado para o ObjectOS. Ative-o em Configurações ou digite o endereço manualmente.", + "allow": "Permitir câmera", + "settings": "Abrir Configurações" + } + }, + "permissions": { + "camera": "O ObjectOS usa a câmera para escanear o código QR que o conecta ao seu servidor." + } +} diff --git a/locales/ru.json b/locales/ru.json new file mode 100644 index 0000000..4392d9a --- /dev/null +++ b/locales/ru.json @@ -0,0 +1,80 @@ +{ + "common": { + "ok": "ОК", + "cancel": "Отмена", + "retry": "Повторить", + "error": "Ошибка" + }, + "errorBoundary": { + "title": "Что-то пошло не так", + "message": "Произошла непредвиденная ошибка.", + "retry": "Попробовать снова" + }, + "serverConfig": { + "title": "Подключение к серверу", + "subtitle": "Введите адрес вашего сервера ObjectOS.", + "label": "URL сервера", + "hint": "Например: https://erp.example.com", + "connect": "Продолжить", + "scan": "Сканировать QR-код", + "backTo": "Вернуться к {{server}}", + "where": { + "title": "Где найти адрес?", + "step1": "На компьютере откройте ObjectOS в браузере и войдите.", + "step2": "Скопируйте адрес из адресной строки. Можно вставить ссылку целиком — приложение оставит только адрес сервера.", + "step3": "Не уверены? Обратитесь к администратору." + }, + "vpnHint": "Для сервера в корпоративной сети телефону может понадобиться сначала подключиться к корпоративному VPN.", + "error": { + "empty": "Введите URL сервера.", + "invalid": "Это не похоже на адрес сервера.", + "insecure": "Используйте адрес https:// — незащищённый http разрешён только для локальных серверов.", + "unreachable": "Не удалось связаться с сервером. Проверьте адрес и сеть — для корпоративного сервера может понадобиться VPN.", + "tls": "Сертификат сервера недействителен, поэтому соединение небезопасно. Попросите администратора исправить это.", + "timeout": "Сервер слишком долго не отвечал. Проверьте сеть или VPN и попробуйте ещё раз.", + "notObjectOS": "Это не сервер ObjectOS. Используйте адрес, по которому вы открываете ObjectOS в браузере.", + "serverError": "Сервер вернул ошибку (HTTP {{status}}). Попробуйте позже или обратитесь к администратору.", + "unknown": "Что-то пошло не так. Попробуйте ещё раз." + } + }, + "serverConfirm": { + "title": "Это ваш сервер?", + "secure": "Защищённое соединение (HTTPS)", + "signInLabel": "Способы входа", + "signIn": { + "sso": "Единый вход (SSO)", + "emailPassword": "Электронная почта и пароль" + }, + "versionLabel": "Версия платформы", + "confirm": "Подключиться и войти", + "reject": "Это не тот сервер" + }, + "shell": { + "loading": "Загрузка…", + "offlineTitle": "Нет подключения к сети", + "offlineMessage": "Проверьте подключение. Страница перезагрузится сама, когда сеть появится.", + "serverErrorTitle": "Сервер не отвечает", + "serverErrorMessage": "Сервер вернул ошибку {{code}}. Попробуйте ещё раз чуть позже.", + "retry": "Попробовать снова", + "changeServer": "Выбрать другой сервер" + }, + "scanner": { + "hint": "Поместите QR-код в рамку", + "connectNote": "Содержит только адрес сервера, без данных учётной записи", + "manual": "Ввести адрес вручную", + "notConnectCode": "Это не QR-код для подключения", + "close": "Закрыть", + "torchOn": "Включить фонарик", + "torchOff": "Выключить фонарик", + "permission": { + "title": "Нужен доступ к камере", + "body": "Разрешите доступ к камере, чтобы отсканировать QR-код.", + "blocked": "Доступ к камере для ObjectOS отключён. Включите его в настройках или введите адрес вручную.", + "allow": "Разрешить доступ к камере", + "settings": "Открыть настройки" + } + }, + "permissions": { + "camera": "ObjectOS использует камеру, чтобы отсканировать QR-код для подключения к вашему серверу." + } +}