feat(i18n): add i18n infrastructure + Simplified Chinese, localize Settings screen (refs #68) (#97)

- 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:
Den
2026-07-17 02:47:18 -07:00
committed by GitHub
parent f4e1a9a757
commit baf73058fd
11 changed files with 441 additions and 48 deletions

View File

@@ -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<boolean | null>(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<boolean>(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<LocalePreference, string> = {
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 (
<ScrollView style={[styles.container, isDark && styles.containerDark]} contentContainerStyle={styles.content}>
<SettingSection title="Security" isDark={isDark}>
<SettingSection title={t("settings.sections.security")} isDark={isDark}>
<SettingRow
icon="finger-print"
label="Require Biometric to Open"
label={t("settings.security.biometricOpen.label")}
description={
hasBiometrics ? "Use Face ID or Touch ID to unlock the app" : "Biometric authentication not available"
hasBiometrics
? t("settings.security.biometricOpen.descriptionEnabled")
: t("settings.security.biometricOpen.descriptionUnavailable")
}
isDark={isDark}
right={
@@ -143,8 +160,8 @@ export default function SettingsScreen() {
/>
<SettingRow
icon="lock-closed"
label="Require Biometric to Send"
description="Authenticate before sending messages"
label={t("settings.security.biometricSend.label")}
description={t("settings.security.biometricSend.description")}
isDark={isDark}
right={
<Switch
@@ -158,8 +175,8 @@ export default function SettingsScreen() {
{settings.requireBiometric && (
<SettingRow
icon="exit"
label="Lock App Now"
description="Require authentication to reopen"
label={t("settings.security.lockNow.label")}
description={t("settings.security.lockNow.description")}
isDark={isDark}
onPress={lock}
right={<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />}
@@ -167,7 +184,7 @@ export default function SettingsScreen() {
)}
</SettingSection>
<SettingSection title="Notifications" isDark={isDark}>
<SettingSection title={t("settings.sections.notifications")} isDark={isDark}>
{categories.map((category) => {
const meta = categoryMeta[category]
return (
@@ -190,17 +207,17 @@ export default function SettingsScreen() {
{osGranted === false && (
<View style={[styles.settingRow, isDark && styles.settingRowDark]}>
<Text style={[styles.settingDescription, { color: "#ef4444", paddingLeft: 48 }]}>
Notifications are disabled at the system level. Enable them in Settings to receive alerts.
{t("settings.notifications.disabledNotice")}
</Text>
</View>
)}
</SettingSection>
<SettingSection title="Privacy" isDark={isDark}>
<SettingSection title={t("settings.sections.privacy")} isDark={isDark}>
<SettingRow
icon="shield-checkmark"
label="Crash Reporting"
description="Share anonymous crash reports via Sentry to help improve OpenCode. No code or prompts are included."
label={t("settings.privacy.crashReporting.label")}
description={t("settings.privacy.crashReporting.description")}
isDark={isDark}
right={
<Switch
@@ -213,28 +230,36 @@ export default function SettingsScreen() {
/>
<SettingRow
icon="document-text"
label="Privacy Policy"
description="What data we collect and how"
label={t("settings.privacy.privacyPolicy.label")}
description={t("settings.privacy.privacyPolicy.description")}
isDark={isDark}
onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
/>
</SettingSection>
<SettingSection title="About" isDark={isDark}>
<SettingRow icon="information-circle" label="Version" description="1.0.0" isDark={isDark} />
<SettingSection title={t("settings.sections.about")} isDark={isDark}>
<SettingRow
icon="language"
label={t("settings.language.label")}
description={localeLabels[locale]}
isDark={isDark}
onPress={handleLanguagePress}
right={<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />}
/>
<SettingRow icon="information-circle" label={t("settings.about.version")} description="1.0.0" isDark={isDark} />
<SettingRow
icon="logo-github"
label="GitHub"
description="View source code"
label={t("settings.about.github.label")}
description={t("settings.about.github.description")}
isDark={isDark}
onPress={() => Linking.openURL("https://github.com/anomalyco/opencode")}
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
/>
<SettingRow
icon="document-text"
label="Documentation"
description="Learn how to use OpenCode"
label={t("settings.about.docs.label")}
description={t("settings.about.docs.description")}
isDark={isDark}
onPress={() => Linking.openURL("https://opencode.ai/docs")}
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
@@ -242,10 +267,8 @@ export default function SettingsScreen() {
</SettingSection>
<View style={styles.footer}>
<Text style={[styles.footerText, isDark && styles.metaDark]}>OpenCode Mobile</Text>
<Text style={[styles.footerText, isDark && styles.metaDark]}>
Connect to your AI coding assistant from anywhere
</Text>
<Text style={[styles.footerText, isDark && styles.metaDark]}>{t("settings.footer.appName")}</Text>
<Text style={[styles.footerText, isDark && styles.metaDark]}>{t("settings.footer.tagline")}</Text>
</View>
</ScrollView>
)

View File

@@ -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 (
<ErrorBoundary>
<I18nextProvider i18n={i18n}>
<GestureHandlerRootView style={{ flex: 1 }}>
<BottomSheetModalProvider>
<QueryClientProvider client={queryClient}>
@@ -162,6 +165,7 @@ function RootLayout() {
setConsentState("decided")
}}
/>
</I18nextProvider>
</ErrorBoundary>
)
}