diff --git a/app.json b/app.json index bd7e85d..7249f49 100644 --- a/app.json +++ b/app.json @@ -79,7 +79,8 @@ "microphonePermission": "OpenCode uses the microphone to capture your voice when using speech-to-text input.", "speechRecognitionPermission": "OpenCode uses speech recognition so you can dictate prompts to your AI coding agent hands-free." } - ] + ], + "expo-localization" ], "experiments": { "typedRoutes": true, diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index d6395e7..d936f37 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -11,6 +11,7 @@ import { Alert, } from "react-native" import { Ionicons } from "@expo/vector-icons" +import { useTranslation } from "react-i18next" import { useAuth } from "../../src/stores/auth" import { useSettings } from "../../src/stores/settings" import { @@ -22,6 +23,7 @@ import { import type { Category } from "../../src/lib/notifications" import { hasTelemetryConsent, setTelemetryConsent } from "../../src/lib/telemetry" import { PRIVACY_POLICY_URL } from "../../src/lib/links" +import type { LocalePreference } from "../../src/lib/i18n/locale-resolve" function SettingRow({ icon, @@ -70,9 +72,10 @@ function SettingSection({ title, children, isDark }: { title: string; children: export default function SettingsScreen() { const colorScheme = useColorScheme() const isDark = colorScheme === "dark" + const { t } = useTranslation() const { settings, hasBiometrics, updateSettings, lock } = useAuth() - const { notifications, setNotification } = useSettings() + const { notifications, setNotification, locale, setLocale } = useSettings() const [osGranted, setOsGranted] = useState(null) const [telemetryUpdating, setTelemetryUpdating] = useState(false) @@ -80,21 +83,21 @@ export default function SettingsScreen() { // We initialise local state from in-memory value; updates call setTelemetryConsent(). const [crashReporting, setCrashReporting] = useState(hasTelemetryConsent() ?? false) - const handleCrashReportingToggle = useCallback(async (value: boolean) => { - setTelemetryUpdating(true) - try { - await setTelemetryConsent(value) - setCrashReporting(value) - } catch { - setCrashReporting(hasTelemetryConsent() ?? false) - Alert.alert( - "Privacy Setting Not Saved", - "Crash reporting is off for this session, but your choice could not be saved. Please try again.", - ) - } finally { - setTelemetryUpdating(false) - } - }, []) + const handleCrashReportingToggle = useCallback( + async (value: boolean) => { + setTelemetryUpdating(true) + try { + await setTelemetryConsent(value) + setCrashReporting(value) + } catch { + setCrashReporting(hasTelemetryConsent() ?? false) + Alert.alert(t("settings.alerts.privacyNotSavedTitle"), t("settings.alerts.privacyNotSavedMessage")) + } finally { + setTelemetryUpdating(false) + } + }, + [t], + ) // Check OS permission state on first toggle attempt const handleToggle = useCallback( @@ -103,16 +106,13 @@ export default function SettingsScreen() { const ok = await setupNotifications() setOsGranted(ok) if (!ok) { - Alert.alert( - "Notifications Disabled", - "Enable notifications for OpenCode in your device settings to receive alerts.", - ) + Alert.alert(t("settings.alerts.notificationsDisabledTitle"), t("settings.alerts.notificationsDisabledMessage")) return } } setNotification(category, enabled) }, - [setNotification], + [setNotification, t], ) // Lazy-check OS permission for status display @@ -122,14 +122,31 @@ export default function SettingsScreen() { .catch(() => setOsGranted(false)) } + const localeLabels: Record = { + system: t("settings.language.system"), + en: t("settings.language.en"), + "zh-Hans": t("settings.language.zhHans"), + } + + const handleLanguagePress = useCallback(() => { + Alert.alert(t("settings.language.title"), undefined, [ + { text: localeLabels.system, onPress: () => setLocale("system") }, + { text: localeLabels.en, onPress: () => setLocale("en") }, + { text: localeLabels["zh-Hans"], onPress: () => setLocale("zh-Hans") }, + { text: t("common.cancel"), style: "cancel" }, + ]) + }, [t, setLocale, localeLabels]) + return ( - + } @@ -167,7 +184,7 @@ export default function SettingsScreen() { )} - + {categories.map((category) => { const meta = categoryMeta[category] return ( @@ -190,17 +207,17 @@ export default function SettingsScreen() { {osGranted === false && ( - Notifications are disabled at the system level. Enable them in Settings to receive alerts. + {t("settings.notifications.disabledNotice")} )} - + Linking.openURL(PRIVACY_POLICY_URL)} right={} /> - - + + } + /> + Linking.openURL("https://github.com/anomalyco/opencode")} right={} /> Linking.openURL("https://opencode.ai/docs")} right={} @@ -242,10 +267,8 @@ export default function SettingsScreen() { - OpenCode Mobile - - Connect to your AI coding assistant from anywhere - + {t("settings.footer.appName")} + {t("settings.footer.tagline")} ) diff --git a/app/_layout.tsx b/app/_layout.tsx index ef6e758..5cb9419 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -5,6 +5,8 @@ import { useColorScheme, View, ActivityIndicator } from "react-native" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { GestureHandlerRootView } from "react-native-gesture-handler" import { BottomSheetModalProvider } from "@gorhom/bottom-sheet" +import { I18nextProvider } from "react-i18next" +import i18n from "../src/lib/i18n/config" import { useAuth } from "../src/stores/auth" import { useConnections } from "../src/stores/connections" import { useEvents } from "../src/stores/events" @@ -107,6 +109,7 @@ function RootLayout() { return ( + @@ -162,6 +165,7 @@ function RootLayout() { setConsentState("decided") }} /> + ) } diff --git a/package-lock.json b/package-lock.json index c27d331..f1d88ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,14 +22,17 @@ "expo-image-picker": "17.0.10", "expo-linking": "~8.0.11", "expo-local-authentication": "~17.0.8", + "expo-localization": "~17.0.9", "expo-notifications": "~0.32.16", "expo-router": "~6.0.22", "expo-secure-store": "~15.0.8", "expo-speech-recognition": "3.0.1", "expo-status-bar": "~3.0.9", "expo-store-review": "~9.0.9", + "i18next": "^26.3.6", "posthog-react-native": "^4.57.0", "react": "19.1.0", + "react-i18next": "^17.0.10", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", "react-native-marked": "8.0.0", @@ -4921,6 +4924,19 @@ "expo": "*" } }, + "node_modules/expo-localization": { + "version": "17.0.9", + "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.9.tgz", + "integrity": "sha512-k5eYHr7iLMob3M8cHH6bgDrDRmqnZG8xKGLhpx8ST+LsFXmSgYpJ/t0VwWm0gz64C/K669XhObdG3D6QwCGqhw==", + "license": "MIT", + "dependencies": { + "rtl-detect": "^1.0.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.25", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz", @@ -6043,6 +6059,15 @@ ], "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -6085,6 +6110,34 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -8347,6 +8400,33 @@ "react": ">=17.0.0" } }, + "node_modules/react-i18next": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz", + "integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", @@ -8979,6 +9059,12 @@ "node": "*" } }, + "node_modules/rtl-detect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz", + "integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -10200,6 +10286,15 @@ "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", "license": "MIT" }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/package.json b/package.json index 0e14da1..198ef27 100644 --- a/package.json +++ b/package.json @@ -26,14 +26,17 @@ "expo-image-picker": "17.0.10", "expo-linking": "~8.0.11", "expo-local-authentication": "~17.0.8", + "expo-localization": "~17.0.9", "expo-notifications": "~0.32.16", "expo-router": "~6.0.22", "expo-secure-store": "~15.0.8", "expo-speech-recognition": "3.0.1", "expo-status-bar": "~3.0.9", "expo-store-review": "~9.0.9", + "i18next": "^26.3.6", "posthog-react-native": "^4.57.0", "react": "19.1.0", + "react-i18next": "^17.0.10", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", "react-native-marked": "8.0.0", diff --git a/src/lib/i18n/config.ts b/src/lib/i18n/config.ts new file mode 100644 index 0000000..b02b0cf --- /dev/null +++ b/src/lib/i18n/config.ts @@ -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 diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json new file mode 100644 index 0000000..07210e7 --- /dev/null +++ b/src/lib/i18n/en.json @@ -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." + } + } +} diff --git a/src/lib/i18n/locale-resolve.test.ts b/src/lib/i18n/locale-resolve.test.ts new file mode 100644 index 0000000..075e05e --- /dev/null +++ b/src/lib/i18n/locale-resolve.test.ts @@ -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) +}) diff --git a/src/lib/i18n/locale-resolve.ts b/src/lib/i18n/locale-resolve.ts new file mode 100644 index 0000000..2ace725 --- /dev/null +++ b/src/lib/i18n/locale-resolve.ts @@ -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 +} diff --git a/src/lib/i18n/zh-Hans.json b/src/lib/i18n/zh-Hans.json new file mode 100644 index 0000000..2604715 --- /dev/null +++ b/src/lib/i18n/zh-Hans.json @@ -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 启用通知以接收提醒。" + } + } +} diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 5ab86d9..8a57b3a 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -2,17 +2,21 @@ import { create } from "zustand" import * as SecureStore from "expo-secure-store" import { type Category, defaultPreferences } from "../lib/notifications" import { clampPageSize, mergeStoredSettings } from "../lib/settings-merge" +import { setAppLocale } from "../lib/i18n/config" +import type { LocalePreference } from "../lib/i18n/locale-resolve" const SETTINGS_KEY = "opencode_settings" interface Settings { pageSize: number notifications: Record + locale: LocalePreference } const DEFAULTS: Settings = { pageSize: 25, notifications: { ...defaultPreferences }, + locale: "system", } interface SettingsState extends Settings { @@ -20,10 +24,11 @@ interface SettingsState extends Settings { load: () => Promise setPageSize: (size: number) => Promise setNotification: (category: Category, enabled: boolean) => Promise + setLocale: (locale: LocalePreference) => Promise } function snapshot(get: () => SettingsState): Settings { - return { pageSize: get().pageSize, notifications: get().notifications } + return { pageSize: get().pageSize, notifications: get().notifications, locale: get().locale } } async function persist(settings: Settings) { @@ -39,7 +44,9 @@ export const useSettings = create((set, get) => ({ if (raw) { const parsed = JSON.parse(raw) as Partial // Merge stored settings with defaults so new fields/categories get their default - set({ ...mergeStoredSettings(DEFAULTS, parsed), loaded: true }) + const merged = mergeStoredSettings(DEFAULTS, parsed) + set({ ...merged, loaded: true }) + setAppLocale(merged.locale) return } set({ loaded: true }) @@ -56,4 +63,10 @@ export const useSettings = create((set, get) => ({ set({ notifications }) await persist({ ...snapshot(get), notifications }) }, + + setLocale: async (locale) => { + set({ locale }) + setAppLocale(locale) // applies immediately + await persist({ ...snapshot(get), locale }) + }, }))