- Add expo-localization, i18next, react-i18next (versions aligned with Expo 54 / RN 0.81) - src/lib/i18n/locale-resolve.ts: pure locale-resolution helpers (system tag -> supported catalog, with en fallback), unit-tested via node --test with no RN imports - src/lib/i18n/config.ts: i18next init wired to expo-localization device detection, en.json + zh-Hans.json catalogs - Persist a locale preference (system | en | zh-Hans) in the settings zustand store, applied immediately via i18next.changeLanguage - Wire I18nextProvider in app/_layout.tsx - Localize the Settings screen (~28 strings) as the reference pattern for extracting user-facing strings, with a language picker row and reviewed Simplified Chinese translations Other screens (session/[id], connection/*, index, chat components) are deferred follow-up — issue #68 stays open for that work. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
35
src/lib/i18n/config.ts
Normal file
35
src/lib/i18n/config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// i18next setup. Kept separate from locale-resolve.ts (the pure, testable
|
||||
// piece) because this module pulls in expo-localization + i18next, which
|
||||
// have no resolver outside Metro/RN — the same split used for buildAuth()
|
||||
// in auth.ts and the settings store's clamp/merge helpers.
|
||||
import i18next from "i18next"
|
||||
import { initReactI18next } from "react-i18next"
|
||||
import * as Localization from "expo-localization"
|
||||
import en from "./en.json"
|
||||
import zhHans from "./zh-Hans.json"
|
||||
import { resolveLocale, FALLBACK_LOCALE, type LocalePreference } from "./locale-resolve"
|
||||
|
||||
const resources = {
|
||||
en: { translation: en },
|
||||
"zh-Hans": { translation: zhHans },
|
||||
}
|
||||
|
||||
/** Device locale tags in priority order, e.g. ["zh-Hans-CN", "en-US"]. */
|
||||
export function deviceLocaleTags(): string[] {
|
||||
return Localization.getLocales().map((locale) => locale.languageTag)
|
||||
}
|
||||
|
||||
i18next.use(initReactI18next).init({
|
||||
resources,
|
||||
lng: resolveLocale("system", deviceLocaleTags()),
|
||||
fallbackLng: FALLBACK_LOCALE,
|
||||
interpolation: { escapeValue: false }, // React already escapes output
|
||||
})
|
||||
|
||||
/** Apply a locale preference immediately. Persisting it is the caller's job. */
|
||||
export function setAppLocale(preference: LocalePreference): void {
|
||||
const resolved = resolveLocale(preference, deviceLocaleTags())
|
||||
if (i18next.language !== resolved) i18next.changeLanguage(resolved)
|
||||
}
|
||||
|
||||
export default i18next
|
||||
69
src/lib/i18n/en.json
Normal file
69
src/lib/i18n/en.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"settings": {
|
||||
"sections": {
|
||||
"security": "Security",
|
||||
"notifications": "Notifications",
|
||||
"privacy": "Privacy",
|
||||
"about": "About"
|
||||
},
|
||||
"security": {
|
||||
"biometricOpen": {
|
||||
"label": "Require Biometric to Open",
|
||||
"descriptionEnabled": "Use Face ID or Touch ID to unlock the app",
|
||||
"descriptionUnavailable": "Biometric authentication not available"
|
||||
},
|
||||
"biometricSend": {
|
||||
"label": "Require Biometric to Send",
|
||||
"description": "Authenticate before sending messages"
|
||||
},
|
||||
"lockNow": {
|
||||
"label": "Lock App Now",
|
||||
"description": "Require authentication to reopen"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"disabledNotice": "Notifications are disabled at the system level. Enable them in Settings to receive alerts."
|
||||
},
|
||||
"privacy": {
|
||||
"crashReporting": {
|
||||
"label": "Crash Reporting",
|
||||
"description": "Share anonymous crash reports via Sentry to help improve OpenCode. No code or prompts are included."
|
||||
},
|
||||
"privacyPolicy": {
|
||||
"label": "Privacy Policy",
|
||||
"description": "What data we collect and how"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"version": "Version",
|
||||
"github": {
|
||||
"label": "GitHub",
|
||||
"description": "View source code"
|
||||
},
|
||||
"docs": {
|
||||
"label": "Documentation",
|
||||
"description": "Learn how to use OpenCode"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"title": "Choose Language",
|
||||
"system": "System Default",
|
||||
"en": "English",
|
||||
"zhHans": "Simplified Chinese"
|
||||
},
|
||||
"footer": {
|
||||
"appName": "OpenCode Mobile",
|
||||
"tagline": "Connect to your AI coding assistant from anywhere"
|
||||
},
|
||||
"alerts": {
|
||||
"privacyNotSavedTitle": "Privacy Setting Not Saved",
|
||||
"privacyNotSavedMessage": "Crash reporting is off for this session, but your choice could not be saved. Please try again.",
|
||||
"notificationsDisabledTitle": "Notifications Disabled",
|
||||
"notificationsDisabledMessage": "Enable notifications for OpenCode in your device settings to receive alerts."
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/lib/i18n/locale-resolve.test.ts
Normal file
38
src/lib/i18n/locale-resolve.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { matchSupportedLocale, resolveLocale, FALLBACK_LOCALE } from "./locale-resolve.ts"
|
||||
|
||||
test("matchSupportedLocale maps zh variants to zh-Hans", () => {
|
||||
assert.equal(matchSupportedLocale("zh"), "zh-Hans")
|
||||
assert.equal(matchSupportedLocale("zh-CN"), "zh-Hans")
|
||||
assert.equal(matchSupportedLocale("zh-Hans"), "zh-Hans")
|
||||
assert.equal(matchSupportedLocale("zh-Hans-CN"), "zh-Hans")
|
||||
assert.equal(matchSupportedLocale("zh-Hant-TW"), "zh-Hans") // no zh-Hant catalog yet — closest match
|
||||
})
|
||||
|
||||
test("matchSupportedLocale maps en variants to en", () => {
|
||||
assert.equal(matchSupportedLocale("en"), "en")
|
||||
assert.equal(matchSupportedLocale("en-US"), "en")
|
||||
assert.equal(matchSupportedLocale("EN-GB"), "en") // case-insensitive
|
||||
})
|
||||
|
||||
test("matchSupportedLocale returns null for unsupported languages", () => {
|
||||
assert.equal(matchSupportedLocale("fr-FR"), null)
|
||||
assert.equal(matchSupportedLocale("ja"), null)
|
||||
assert.equal(matchSupportedLocale("es-ES"), null)
|
||||
})
|
||||
|
||||
test("resolveLocale: explicit preference always wins over device tags", () => {
|
||||
assert.equal(resolveLocale("en", ["zh-Hans-CN"]), "en")
|
||||
assert.equal(resolveLocale("zh-Hans", ["en-US"]), "zh-Hans")
|
||||
})
|
||||
|
||||
test("resolveLocale: system preference picks first supported device tag", () => {
|
||||
assert.equal(resolveLocale("system", ["fr-FR", "zh-Hans-CN", "en-US"]), "zh-Hans")
|
||||
assert.equal(resolveLocale("system", ["en-US", "zh-Hans-CN"]), "en")
|
||||
})
|
||||
|
||||
test("resolveLocale: system preference falls back when nothing matches", () => {
|
||||
assert.equal(resolveLocale("system", ["fr-FR", "ja-JP"]), FALLBACK_LOCALE)
|
||||
assert.equal(resolveLocale("system", []), FALLBACK_LOCALE)
|
||||
})
|
||||
43
src/lib/i18n/locale-resolve.ts
Normal file
43
src/lib/i18n/locale-resolve.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Pure locale-resolution logic, kept free of expo-localization/i18next imports
|
||||
// so it's unit-testable with plain `node --test` (same split as
|
||||
// settings-merge.ts / store-review-policy.ts).
|
||||
|
||||
export const SUPPORTED_LOCALES = ["en", "zh-Hans"] as const
|
||||
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
|
||||
|
||||
export const FALLBACK_LOCALE: SupportedLocale = "en"
|
||||
|
||||
// User-facing preference: "system" defers to the device's locale list.
|
||||
export type LocalePreference = "system" | SupportedLocale
|
||||
|
||||
/**
|
||||
* Map one device locale tag (e.g. "zh-Hans-CN", "zh-CN", "en-US", "fr-FR") to
|
||||
* one of our supported catalogs, matching by language subtag since we don't
|
||||
* ship per-region variants. Returns null if we have no catalog for it.
|
||||
*/
|
||||
export function matchSupportedLocale(tag: string): SupportedLocale | null {
|
||||
const lower = tag.toLowerCase()
|
||||
if (lower.startsWith("zh")) return "zh-Hans"
|
||||
if (lower.startsWith("en")) return "en"
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective app locale from a user preference plus the device's
|
||||
* ranked list of locale tags (as returned by expo-localization's
|
||||
* `getLocales().map(l => l.languageTag)`).
|
||||
* - An explicit preference (anything but "system") always wins.
|
||||
* - "system" walks the device list in priority order and uses the first tag
|
||||
* we have a catalog for.
|
||||
* - If nothing matches (or the list is empty), falls back to FALLBACK_LOCALE.
|
||||
*/
|
||||
export function resolveLocale(preference: LocalePreference, deviceTags: readonly string[]): SupportedLocale {
|
||||
if (preference !== "system") return preference
|
||||
|
||||
for (const tag of deviceTags) {
|
||||
const match = matchSupportedLocale(tag)
|
||||
if (match) return match
|
||||
}
|
||||
|
||||
return FALLBACK_LOCALE
|
||||
}
|
||||
69
src/lib/i18n/zh-Hans.json
Normal file
69
src/lib/i18n/zh-Hans.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"common": {
|
||||
"cancel": "取消"
|
||||
},
|
||||
"settings": {
|
||||
"sections": {
|
||||
"security": "安全",
|
||||
"notifications": "通知",
|
||||
"privacy": "隐私",
|
||||
"about": "关于"
|
||||
},
|
||||
"security": {
|
||||
"biometricOpen": {
|
||||
"label": "解锁时需要生物识别",
|
||||
"descriptionEnabled": "使用 Face ID 或 Touch ID 解锁应用",
|
||||
"descriptionUnavailable": "生物识别功能不可用"
|
||||
},
|
||||
"biometricSend": {
|
||||
"label": "发送消息时需要生物识别",
|
||||
"description": "发送消息前需要验证身份"
|
||||
},
|
||||
"lockNow": {
|
||||
"label": "立即锁定应用",
|
||||
"description": "重新打开时需要验证身份"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"disabledNotice": "通知已在系统层面关闭。请在系统设置中启用通知以接收提醒。"
|
||||
},
|
||||
"privacy": {
|
||||
"crashReporting": {
|
||||
"label": "崩溃报告",
|
||||
"description": "通过 Sentry 分享匿名崩溃报告,帮助改进 OpenCode。报告不包含代码或提示词内容。"
|
||||
},
|
||||
"privacyPolicy": {
|
||||
"label": "隐私政策",
|
||||
"description": "我们收集哪些数据及收集方式"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"version": "版本",
|
||||
"github": {
|
||||
"label": "GitHub",
|
||||
"description": "查看源代码"
|
||||
},
|
||||
"docs": {
|
||||
"label": "文档",
|
||||
"description": "了解如何使用 OpenCode"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"label": "语言",
|
||||
"title": "选择语言",
|
||||
"system": "跟随系统",
|
||||
"en": "English",
|
||||
"zhHans": "简体中文"
|
||||
},
|
||||
"footer": {
|
||||
"appName": "OpenCode Mobile",
|
||||
"tagline": "随时随地连接您的 AI 编程助手"
|
||||
},
|
||||
"alerts": {
|
||||
"privacyNotSavedTitle": "隐私设置未保存",
|
||||
"privacyNotSavedMessage": "本次会话已关闭崩溃报告,但您的选择未能保存。请重试。",
|
||||
"notificationsDisabledTitle": "通知已关闭",
|
||||
"notificationsDisabledMessage": "请在设备设置中为 OpenCode 启用通知以接收提醒。"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user