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} -