From 3ef5e041117b807bdae8ce49e39f708287f0e738 Mon Sep 17 00:00:00 2001 From: mmy0302 Date: Thu, 23 Jul 2026 23:54:09 +0800 Subject: [PATCH 1/3] feat(i18n): add simplified and traditional Chinese language support Introduce limited i18n with English, zh-CN and zh-TW via i18next + react-i18next. Language persists in SQLite settings and can be switched in the Settings screen. Core screens (tabs, today, pantry, rescue, insights, settings, onboarding) now use translation keys. Jest setup with global react-i18next mock using English translations. --- mobile/app/(tabs)/_layout.tsx | 22 +- mobile/app/_layout.tsx | 78 ++++-- mobile/jest.config.js | 1 + mobile/jest.setup.ts | 30 +++ mobile/package-lock.json | 138 +++++++--- mobile/package.json | 3 + .../src/features/insights/InsightsScreen.tsx | 34 +-- .../features/onboarding/OnboardingScreen.tsx | 20 +- mobile/src/features/pantry/PantryScreen.tsx | 65 ++--- mobile/src/features/rescue/RescueScreen.tsx | 30 ++- .../src/features/settings/SettingsScreen.tsx | 80 ++++-- .../settings/__tests__/screen.test.tsx | 16 ++ mobile/src/features/today/TodayItemCard.tsx | 77 ++++-- mobile/src/features/today/TodayScreen.tsx | 40 +-- mobile/src/i18n/index.ts | 81 ++++++ mobile/src/i18n/locales/en.json | 242 ++++++++++++++++++ mobile/src/i18n/locales/zh-CN.json | 242 ++++++++++++++++++ mobile/src/i18n/locales/zh-TW.json | 242 ++++++++++++++++++ 18 files changed, 1226 insertions(+), 215 deletions(-) create mode 100644 mobile/jest.setup.ts create mode 100644 mobile/src/i18n/index.ts create mode 100644 mobile/src/i18n/locales/en.json create mode 100644 mobile/src/i18n/locales/zh-CN.json create mode 100644 mobile/src/i18n/locales/zh-TW.json diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index 74e8711..dd0adac 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -1,5 +1,6 @@ import { NativeTabs } from 'expo-router/unstable-native-tabs'; import { useWindowDimensions } from 'react-native'; +import { useTranslation } from 'react-i18next'; import { alpha, colors } from '@/theme'; @@ -7,6 +8,7 @@ export const tabLabelFontSize = (fontScale: number) => 12 / Math.max(fontScale, export default function TabsLayout() { const { fontScale } = useWindowDimensions(); + const { t } = useTranslation(); const fontSize = tabLabelFontSize(fontScale); const labelStyle = { default: { @@ -33,25 +35,25 @@ export default function TabsLayout() { labelStyle={labelStyle} labelVisibilityMode="labeled" tintColor={colors.action}> - + - Today + {t('tabs.today')} - + - Pantry + {t('tabs.pantry')} - + - Rescue + {t('tabs.rescue')} - + - Insights + {t('tabs.insights')} - + - Settings + {t('tabs.settings')} ); diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 84033b1..6088bd2 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -1,12 +1,16 @@ import { useNetworkState } from 'expo-network'; import { Redirect, Stack, ThemeProvider, usePathname } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import { I18nextProvider } from 'react-i18next'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { initializeNotifications } from '@/services/notifications'; import { AppErrorBoundary } from '@/application/AppErrorBoundary'; import { OfflineStatusBanner } from '@/application/OfflineStatusBanner'; +import { getFreshLoopDatabase } from '@/data/database/expo-sqlite'; +import { SettingsRepository } from '@/data/repositories/settings'; +import i18next, { initI18n } from '@/i18n'; import { alpha, colors, useFreshLoopFonts } from '@/theme'; export { AppErrorBoundary as ErrorBoundary }; @@ -33,41 +37,61 @@ export default function RootLayout() { useFreshLoopFonts(); const pathname = usePathname(); const networkState = useNetworkState(); + const [i18nReady, setI18nReady] = useState(false); useEffect(() => { void initializeNotifications().catch(() => undefined); }, []); + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const db = await getFreshLoopDatabase(); + const store = new SettingsRepository(db); + await initI18n(store); + } catch { + await initI18n(); + } + if (!cancelled) setI18nReady(true); + })(); + return () => { cancelled = true; }; + }, []); + if (pathname === '/explore') { return ; } return ( - - - - - - - - - - - - - - - - + + + + + {i18nReady ? ( + + ) : null} + + + + + + + + + + + + + ); } diff --git a/mobile/jest.config.js b/mobile/jest.config.js index 5d10984..2c88875 100644 --- a/mobile/jest.config.js +++ b/mobile/jest.config.js @@ -1,4 +1,5 @@ module.exports = { preset: 'jest-expo', testPathIgnorePatterns: ['/node_modules/', '/android/', '/ios/'], + setupFiles: ['./jest.setup.ts'], }; diff --git a/mobile/jest.setup.ts b/mobile/jest.setup.ts new file mode 100644 index 0000000..d895b96 --- /dev/null +++ b/mobile/jest.setup.ts @@ -0,0 +1,30 @@ +import i18next from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import en from './src/i18n/locales/en.json'; + +void i18next.use(initReactI18next).init({ + compatibilityJSON: 'v4', + resources: { en: { translation: en } }, + lng: 'en', + fallbackLng: 'en', + interpolation: { escapeValue: false }, + returnNull: false, + returnEmptyString: false, +}); + +// Global mock for react-i18next to avoid wrapping every test in I18nextProvider. +// useTranslation() will use the pre-initialised i18next instance above. +// Jest requires `mock` prefix for out-of-scope references in jest.mock factories. +const mockI18next = i18next; +jest.mock('react-i18next', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const actual = jest.requireActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, opts?: Record) => mockI18next.t(key, opts ?? {}), + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + }; +}); diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 0afaabe..a359df5 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -20,6 +20,7 @@ "expo-glass-effect": "~57.0.1", "expo-image": "~57.0.1", "expo-linking": "~57.0.3", + "expo-localization": "^57.0.1", "expo-network": "~57.0.1", "expo-notifications": "~57.0.5", "expo-router": "~57.0.6", @@ -29,9 +30,11 @@ "expo-symbols": "~57.0.1", "expo-system-ui": "~57.0.1", "expo-web-browser": "~57.0.1", + "i18next": "^26.3.6", "ky": "^2.0.2", "react": "19.2.3", "react-dom": "19.2.3", + "react-i18next": "^17.0.11", "react-native": "0.86.0", "react-native-gesture-handler": "~2.32.0", "react-native-reanimated": "4.5.0", @@ -4254,9 +4257,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4271,9 +4271,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4288,9 +4285,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4305,9 +4299,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4322,9 +4313,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4339,9 +4327,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4356,9 +4341,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4373,9 +4355,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4390,9 +4369,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4407,9 +4383,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7482,6 +7455,19 @@ "react-native": "*" } }, + "node_modules/expo-localization": { + "version": "57.0.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/expo-localization/-/expo-localization-57.0.1.tgz", + "integrity": "sha512-8Ffl4UTbOsQeGT0v5fxMbyPHyPMPnhSPDFQJa8p9rjJrthFoAtNi+fL6Ssmrvf1/7dmPq1mVY52MEt0TMEfgjA==", + "license": "MIT", + "dependencies": { + "rtl-detect": "^1.0.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "57.0.7", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.7.tgz", @@ -8255,6 +8241,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8694,6 +8681,15 @@ "license": "MIT", "peer": true }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -8781,6 +8777,34 @@ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://mirrors.cloud.tencent.com/npm/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/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -11064,9 +11088,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11087,9 +11108,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11110,9 +11128,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11133,9 +11148,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12903,6 +12915,33 @@ "react": ">=17.0.0" } }, + "node_modules/react-i18next": { + "version": "17.0.11", + "resolved": "https://mirrors.cloud.tencent.com/npm/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^4.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.7", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", @@ -13460,6 +13499,12 @@ "node": ">=4" } }, + "node_modules/rtl-detect": { + "version": "1.1.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/rtl-detect/-/rtl-detect-1.1.2.tgz", + "integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -14983,6 +15028,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/mobile/package.json b/mobile/package.json index 5b32c9e..a31faa9 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -19,6 +19,7 @@ "expo-glass-effect": "~57.0.1", "expo-image": "~57.0.1", "expo-linking": "~57.0.3", + "expo-localization": "^57.0.1", "expo-network": "~57.0.1", "expo-notifications": "~57.0.5", "expo-router": "~57.0.6", @@ -28,9 +29,11 @@ "expo-symbols": "~57.0.1", "expo-system-ui": "~57.0.1", "expo-web-browser": "~57.0.1", + "i18next": "^26.3.6", "ky": "^2.0.2", "react": "19.2.3", "react-dom": "19.2.3", + "react-i18next": "^17.0.11", "react-native": "0.86.0", "react-native-gesture-handler": "~2.32.0", "react-native-reanimated": "4.5.0", diff --git a/mobile/src/features/insights/InsightsScreen.tsx b/mobile/src/features/insights/InsightsScreen.tsx index cf884f1..c8e7fa8 100644 --- a/mobile/src/features/insights/InsightsScreen.tsx +++ b/mobile/src/features/insights/InsightsScreen.tsx @@ -1,5 +1,6 @@ import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; import { todayInHongKong } from '@/domain/dates/hong-kong-today'; @@ -40,6 +41,7 @@ export function InsightsScreen({ currentTime = currentDate, }: InsightsScreenProps) { const router = useRouter(); + const { t } = useTranslation(); const [state, setState] = useState({ stage: 'loading' }); const load = useCallback(async () => { @@ -62,19 +64,19 @@ export function InsightsScreen({ if (state.stage === 'loading') { return ( - - + + ); } if (state.stage === 'error') { return ( - + void load()} - retryLabel="Retry Insights" - title="Could not load Insights" + retryLabel={t('insights.error_retry')} + title={t('insights.error_title')} /> ); @@ -84,25 +86,23 @@ export function InsightsScreen({ const totalEvents = summary.usedCount + summary.discardedCount; return ( - - Recorded actions only. FreshLoop does not estimate waste prevented or convert outcomes into savings. - + title={t('insights.title')}> + {t('insights.description')} - - - + + + - Last seven days + {t('insights.last_seven_days')} {totalEvents === 0 ? ( - No outcomes recorded yet. The chart remains at zero until you record an action. + {t('insights.no_outcomes')} ) : null} -