feat: add english support and language switcher

merge-requests/10/head
Torpenn 2024-10-14 00:20:52 +02:00
parent f5b9fd0bf5
commit c14c197dd9
13 changed files with 348 additions and 38 deletions

View File

@ -1,20 +1,25 @@
import React, { useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { Text } from '@/components/shared/Themed'; import { Text } from '@/components/shared/Themed';
import styled from '@emotion/native'; import styled from '@emotion/native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '@/app/RootStackParamList';
import { loadUserSettings, saveUserSettings } from '@/components/shared/business/AsyncStorage'; import { loadUserSettings, saveUserSettings } from '@/components/shared/business/AsyncStorage';
import { Switch } from 'react-native'; import { Switch } from 'react-native';
import { i18n } from './i18n/i18n';
import { LanguageContext } from '@/app/shared/providers/LanguageProvider';
import DropDownPicker from "react-native-dropdown-picker";
import { VerticalSpacer } from '@/components/shared/Spacers'; import { VerticalSpacer } from '@/components/shared/Spacers';
import Button from '@/components/shared/Button';
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'Dashboard'>; const t = i18n.scoped('settings');
export default function Dashboard() { export default function Dashboard() {
const navigation = useNavigation<NavigationProp>();
const [soundEnabled, setSoundEnabled] = useState<boolean>(false); const [soundEnabled, setSoundEnabled] = useState<boolean>(false);
const { userChangeLanguage } = useContext(LanguageContext)
const [languageOpen, setLanguageOpen] = useState(false);
const [languageValue, setLanguageValue] = useState(i18n.localI18n.locale);
const [language, setLanguage] = useState([
{ label: t('french'), value: "fr" },
{ label: t('english'), value: "en" },
]);
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@ -40,20 +45,34 @@ export default function Dashboard() {
return ( return (
<Container> <Container>
<Title>Preferences</Title>
<Button label="Retour" onPress={() => navigation.navigate('Dashboard')} />
<VerticalSpacer heightUnits={8} />
<Row> <Row>
<Text>Son activé ?</Text> <Text>{t('soundActivate')}</Text>
<Switch <Switch
onValueChange={() => setSoundEnabled(previousState => !previousState)} onValueChange={() => setSoundEnabled(previousState => !previousState)}
value={soundEnabled} value={soundEnabled}
/> />
</Row> </Row>
<VerticalSpacer heightUnits={5} />
<Text>{t('language')}</Text>
<VerticalSpacer heightUnits={2} />
<Row>
<DropDownPicker
open={languageOpen}
value={languageValue}
items={language}
setOpen={setLanguageOpen}
setValue={setLanguageValue}
setItems={setLanguage}
placeholder={languageValue ?? ''}
onChangeValue={(value: any) => {
const formattedValue = value === 'fr' ? 'fr' : 'en'
userChangeLanguage(formattedValue)
}}
/>
</Row>
</Container> </Container>
); );
} }

View File

@ -6,6 +6,8 @@ import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen'; import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react'; import { useEffect } from 'react';
import 'react-native-reanimated'; import 'react-native-reanimated';
import { i18n } from './i18n/i18n';
import { LanguageProvider } from '@/app/shared/providers/LanguageProvider';
export { export {
// Catch any errors thrown by the Layout component. // Catch any errors thrown by the Layout component.
@ -46,12 +48,14 @@ export default function RootLayout() {
function RootLayoutNav() { function RootLayoutNav() {
return ( return (
<ThemeProvider theme={theme}> <LanguageProvider>
<Stack initialRouteName="Dashboard"> <ThemeProvider theme={theme}>
<Stack.Screen name="Dashboard" options={{ headerShown: false }} /> <Stack initialRouteName="Dashboard">
<Stack.Screen name="Timer" options={{ headerShown: false }} /> <Stack.Screen name="Dashboard" options={{ headerShown: false }} />
<Stack.Screen name="Setting" options={{ headerShown: false }} /> <Stack.Screen name="Timer" options={{ headerShown: false }} />
</Stack> <Stack.Screen name="Settings" options={{ headerShown: true, title: i18n.t('settings.title') }} />
</ThemeProvider> </Stack>
</ThemeProvider>
</LanguageProvider>
); );
} }

View File

@ -11,9 +11,12 @@ import { VerticalSpacer } from '@/components/shared/Spacers';
import { TimerPickerModal } from 'react-native-timer-picker'; import { TimerPickerModal } from 'react-native-timer-picker';
import Button from '@/components/shared/Button'; import Button from '@/components/shared/Button';
import NumberSelector from '@/components/shared/NumberSelector'; import NumberSelector from '@/components/shared/NumberSelector';
import { i18n } from '@/app/i18n/i18n';
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'Timer'>; type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'Timer'>;
const t = i18n.scoped('dashboard');
export default function Dashboard() { export default function Dashboard() {
const navigation = useNavigation<NavigationProp>(); const navigation = useNavigation<NavigationProp>();
@ -27,21 +30,21 @@ export default function Dashboard() {
return ( return (
<Container> <Container>
<Title>Boxons</Title> <Title>{i18n.t('appTitle')}</Title>
<VerticalSpacer heightUnits={8} /> <VerticalSpacer heightUnits={8} />
<CardContainer> <CardContainer>
<Card backgroundColor="black"> <Card backgroundColor="black">
<CardTextContainer> <CardTextContainer>
<CustomText>Reps.</CustomText> <CustomText>{t('repetition')}</CustomText>
<NumberSelector reps={reps} setReps={setReps} /> <NumberSelector reps={reps} setReps={setReps} />
</CardTextContainer> </CardTextContainer>
</Card> </Card>
<Card backgroundColor="red" onPress={() => setShowWorkTimePicker(true)}> <Card backgroundColor="red" onPress={() => setShowWorkTimePicker(true)}>
<CardTextContainer> <CardTextContainer>
<CustomText>Fight</CustomText> <CustomText>{t('fight')}</CustomText>
<CustomText>{formatTime(workTime)}</CustomText> <CustomText>{formatTime(workTime)}</CustomText>
</CardTextContainer> </CardTextContainer>
</Card> </Card>
@ -54,7 +57,7 @@ export default function Dashboard() {
setWorkTime(pickedDuration.minutes * 60 + pickedDuration.seconds); setWorkTime(pickedDuration.minutes * 60 + pickedDuration.seconds);
setShowWorkTimePicker(false); setShowWorkTimePicker(false);
}} }}
modalTitle="Set Round Time" modalTitle={t('setWorkTime')}
onCancel={() => setShowWorkTimePicker(false)} onCancel={() => setShowWorkTimePicker(false)}
closeOnOverlayPress closeOnOverlayPress
styles={{ styles={{
@ -67,7 +70,7 @@ export default function Dashboard() {
<Card backgroundColor="green" onPress={() => setShowRestTimePicker(true)}> <Card backgroundColor="green" onPress={() => setShowRestTimePicker(true)}>
<CardTextContainer> <CardTextContainer>
<CustomText>Repos</CustomText> <CustomText>{t('rest')}</CustomText>
<CustomText>{formatTime(restTime)}</CustomText> <CustomText>{formatTime(restTime)}</CustomText>
</CardTextContainer> </CardTextContainer>
</Card> </Card>
@ -80,7 +83,7 @@ export default function Dashboard() {
setRestTime(pickedDuration.minutes * 60 + pickedDuration.seconds); setRestTime(pickedDuration.minutes * 60 + pickedDuration.seconds);
setShowRestTimePicker(false); setShowRestTimePicker(false);
}} }}
modalTitle="Set Rest Time" modalTitle={t('setRestTime')}
onCancel={() => setShowRestTimePicker(false)} onCancel={() => setShowRestTimePicker(false)}
closeOnOverlayPress closeOnOverlayPress
styles={{ styles={{
@ -93,15 +96,15 @@ export default function Dashboard() {
</CardContainer> </CardContainer>
<VerticalSpacer heightUnits={5} /> <VerticalSpacer heightUnits={5} />
<Text>Temps total de travail : {totalWorkTime}</Text> <Text>{t('totalTime')}: {totalWorkTime}</Text>
<VerticalSpacer heightUnits={5} /> <VerticalSpacer heightUnits={5} />
<Button label="Commencer" onPress={() => navigation.navigate('Timer', { reps, restTime, workTime})} /> <Button label={t('begin')} onPress={() => navigation.navigate('Timer', { reps, restTime, workTime})} />
<VerticalSpacer heightUnits={5} /> <VerticalSpacer heightUnits={5} />
<Button label="Préférences" onPress={() => navigation.navigate('Settings')} /> <Button label={t('settings')} onPress={() => navigation.navigate('Settings')} />
</Container> </Container>
); );
} }

35
app/i18n/i18n.ts Normal file
View File

@ -0,0 +1,35 @@
import { I18n, Scope, TranslateOptions } from "i18n-js";
import { translations } from './translations';
import AsyncStorage from '@react-native-async-storage/async-storage';
const localI18n = new I18n(translations);
localI18n.locale = 'fr';
localI18n.defaultLocale = 'fr';
AsyncStorage.getItem('storedLanguage').then(data => {
if (data === null) {
localI18n.locale = 'fr'
} else {
localI18n.locale = data
}
}).catch((error) => console.log(error))
localI18n.enableFallback = true
type ScopedTranslationFunction = (
scope: Scope,
options?: Omit<TranslateOptions, 'scope'>
) => string;
const scoped = (key: string): ScopedTranslationFunction => {
return (scope, options) => localI18n.t(scope, { ...options, scope: key });
};
const t: ScopedTranslationFunction = (scope, options?) => localI18n.t(scope, options)
export const i18n = { ...localI18n, localI18n, t, scoped };

View File

@ -0,0 +1,33 @@
export const en = {
appTitle: 'Boxons',
back: 'Back',
dashboard: {
repetition: 'Reps',
fight: 'Fight time',
rest: 'Rest',
totalTime: 'Total time',
begin: 'Start',
settings: 'Settings',
setWorkTime: 'Set fight time',
setRestTime: 'Set rest time',
},
settings: {
title: 'Settings',
soundActivate: 'Activate sounds',
language: 'Language',
french: 'French',
english: 'English'
},
timer: {
timerContent: {
fight: 'Fight',
rest: 'Rest',
stop: 'Stop',
start: 'Start',
},
finishContent: {
finish: 'Finished',
restart: 'Restart',
}
}
}

View File

@ -0,0 +1,33 @@
export const fr = {
appTitle: 'Boxons',
back: 'Retour',
dashboard: {
repetition: 'Reps.',
fight: 'Temps du combat',
rest: 'Repos',
totalTime: 'Temps total',
begin: 'Commencer',
settings: 'Paramètres',
setWorkTime: 'Définir le temps de combat',
setRestTime: 'Définir le temps de repos',
},
settings: {
title: 'Paramètres',
soundActivate: 'Activer les sons',
language: 'Langue',
french: 'Français',
english: 'Anglais'
},
timer: {
timerContent: {
fight: 'Combat',
rest: 'Repos',
stop: 'Arrêter',
start: 'Commencer',
},
finishContent: {
finish: 'Terminé',
restart: 'Recommencer'
}
}
}

View File

@ -0,0 +1,4 @@
import { fr } from './fr';
import { en } from './en';
export const translations = { fr, en };

View File

@ -0,0 +1,43 @@
import React, { useState, useEffect, createContext } from 'react'
import AsyncStorage from '@react-native-async-storage/async-storage';
import { i18n } from '@/app/i18n/i18n';
export const LanguageContext = createContext({ userChangeLanguage: async (lang: string) => { } })
interface LanguageProviderProps {
children: React.ReactNode;
}
const LanguageProviderComponent: React.FC<LanguageProviderProps> = ({ children }) => {
const [_language, setLanguage] = useState('fr')
// In the beginning of App, check if user has selected language before.
// If not, use system default language
useEffect(() => {
i18n.localI18n.locale = 'fr'
AsyncStorage.getItem('storedLanguage').then(data => {
if (data === null) {
i18n.localI18n.locale = 'fr'
}
else {
i18n.localI18n.locale = data
setLanguage(data)
}
}).catch((error) => console.log(error))
}, [])
const userChangeLanguage = async (language: string) => {
i18n.localI18n.locale = language
await AsyncStorage.setItem('storedLanguage', language)
setLanguage(language)
}
return (
<LanguageContext.Provider value={{ userChangeLanguage }}>
{children}
</LanguageContext.Provider>)
}
export const LanguageProvider = LanguageProviderComponent;

View File

@ -0,0 +1,90 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { i18n } from '@/app/i18n/i18n'; // Assure-toi que ce chemin est correct
import { LanguageProvider, LanguageContext } from '../LanguageProvider';
jest.mock('@react-native-async-storage/async-storage', () => ({
getItem: jest.fn(),
setItem: jest.fn(),
}));
jest.mock('@/app/i18n/i18n', () => ({
i18n: {
localI18n: {
locale: 'fr',
},
},
}));
describe('LanguageProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should initialize with default language as "fr" if no language is stored', async () => {
AsyncStorage.getItem.mockResolvedValueOnce(null);
const { getByText } = render(
<LanguageProvider>
<LanguageContext.Consumer>
{({ userChangeLanguage }) => (
<>{i18n.localI18n.locale}</> // Affiche la langue courante
)}
</LanguageContext.Consumer>
</LanguageProvider>
);
await waitFor(() => {
expect(i18n.localI18n.locale).toBe('fr');
});
});
test('should load stored language from AsyncStorage if available', async () => {
AsyncStorage.getItem.mockResolvedValueOnce('en');
const { getByText } = render(
<LanguageProvider>
<LanguageContext.Consumer>
{({ userChangeLanguage }) => (
<>{i18n.localI18n.locale}</>
)}
</LanguageContext.Consumer>
</LanguageProvider>
);
await waitFor(() => {
expect(i18n.localI18n.locale).toBe('en');
});
});
test('should allow user to change language and save it to AsyncStorage', async () => {
AsyncStorage.getItem.mockResolvedValueOnce(null);
const { getByTestId } = render(
<LanguageProvider>
<LanguageContext.Consumer>
{({ userChangeLanguage }) => (
<>
<>{i18n.localI18n.locale}</> {/* Affiche la langue courante */}
<button
onPress={() => userChangeLanguage('en')}
testID="change-language-button"
>
Change to English
</button>
</>
)}
</LanguageContext.Consumer>
</LanguageProvider>
);
const button = getByTestId('change-language-button');
button.props.onPress();
await waitFor(() => {
expect(i18n.localI18n.locale).toBe('en');
expect(AsyncStorage.setItem).toHaveBeenCalledWith('storedLanguage', 'en');
});
});
});

View File

@ -6,6 +6,7 @@ import { NativeStackNavigationProp } from 'react-native-screens/lib/typescript/n
import Button from '@/components/shared/Button'; import Button from '@/components/shared/Button';
import { HorizontalSpacer, VerticalSpacer } from '@/components/shared/Spacers'; import { HorizontalSpacer, VerticalSpacer } from '@/components/shared/Spacers';
import { RootStackParamList } from '@/app/RootStackParamList'; import { RootStackParamList } from '@/app/RootStackParamList';
import { i18n } from '@/app/i18n/i18n';
type FinishContentProps = { type FinishContentProps = {
handleStart: () => void; handleStart: () => void;
@ -13,6 +14,8 @@ type FinishContentProps = {
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'Dashboard'>; type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'Dashboard'>;
const t = i18n.scoped('timer.finishContent');
export default function FinishContent({ export default function FinishContent({
handleStart handleStart
}: FinishContentProps) { }: FinishContentProps) {
@ -20,16 +23,16 @@ export default function FinishContent({
return ( return (
<> <>
<Time>FIN</Time> <Time>{t('finish')}</Time>
<VerticalSpacer heightUnits={8} /> <VerticalSpacer heightUnits={8} />
<Row> <Row>
<Button label="Recommencer" onPress={handleStart} /> <Button label={t('restart')} onPress={handleStart} />
<HorizontalSpacer widthUnits={3} /> <HorizontalSpacer widthUnits={3} />
<Button label="Retour" onPress={() => navigation.navigate('Dashboard')} /> <Button label={i18n.t('back')} onPress={() => navigation.navigate('Dashboard')} />
</Row> </Row>
</> </>
); );
@ -41,7 +44,7 @@ const Row = styled(View)(() => ({
})); }));
const Time = styled(Text)(({ theme }) => ({ const Time = styled(Text)(({ theme }) => ({
fontSize: 100, fontSize: 70,
fontWeight: 'bold', fontWeight: 'bold',
color: theme.colors.white color: theme.colors.white
})); }));

View File

@ -7,6 +7,7 @@ import { TimerBgColor } from '@/components/useCases/timer/business/type';
import { useNavigation } from 'expo-router'; import { useNavigation } from 'expo-router';
import { NativeStackNavigationProp } from 'react-native-screens/lib/typescript/native-stack/types'; import { NativeStackNavigationProp } from 'react-native-screens/lib/typescript/native-stack/types';
import { RootStackParamList } from '@/app/RootStackParamList'; import { RootStackParamList } from '@/app/RootStackParamList';
import { i18n } from '@/app/i18n/i18n';
interface TimerContentProps { interface TimerContentProps {
isWorkPhase: boolean; isWorkPhase: boolean;
@ -21,6 +22,8 @@ interface TimerContentProps {
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'Dashboard'>; type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'Dashboard'>;
const t = i18n.scoped('timer.timerContent');
export default function TimerContent({ export default function TimerContent({
isWorkPhase, isWorkPhase,
timeLeft, timeLeft,
@ -35,7 +38,7 @@ export default function TimerContent({
return ( return (
<> <>
<Title>{isWorkPhase ? 'Fight' : 'Repos'}</Title> <Title>{isWorkPhase ? t('fight') : t('rest')}</Title>
<VerticalSpacer heightUnits={8} /> <VerticalSpacer heightUnits={8} />
@ -51,14 +54,14 @@ export default function TimerContent({
<ButtonContainer bgColor={bgColor}> <ButtonContainer bgColor={bgColor}>
{isRunning ? ( {isRunning ? (
<Button label="Stop" onPress={handleStop} /> <Button label={t('stop')} onPress={handleStop} />
) : ( ) : (
<Button label="Start" onPress={handleContine} /> <Button label={t('start')} onPress={handleContine} />
)} )}
<VerticalSpacer heightUnits={3} /> <VerticalSpacer heightUnits={3} />
<Button label="Retour" onPress={() => navigation.navigate('Dashboard')} /> <Button label={i18n.t('back')} onPress={() => navigation.navigate('Dashboard')} />
</ButtonContainer> </ButtonContainer>
</> </>
); );

38
package-lock.json generated
View File

@ -26,9 +26,11 @@
"expo-system-ui": "~3.0.7", "expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.27", "expo-updates": "~0.25.27",
"expo-web-browser": "~13.0.3", "expo-web-browser": "~13.0.3",
"i18n-js": "^4.4.3",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-native": "0.74.5", "react-native": "0.74.5",
"react-native-dropdown-picker": "^5.4.6",
"react-native-linear-gradient": "^2.8.3", "react-native-linear-gradient": "^2.8.3",
"react-native-reanimated": "~3.10.1", "react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.5", "react-native-safe-area-context": "4.10.5",
@ -9322,6 +9324,15 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/bignumber.js": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz",
"integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/bl": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@ -13579,6 +13590,17 @@
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/i18n-js": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/i18n-js/-/i18n-js-4.4.3.tgz",
"integrity": "sha512-QIIyvJ+wOKdigL4BlgwiFFrpoXeGdlC8EYgori64YSWm1mnhNYYjIfRu5wETFrmiNP2fyD6xIjVG8dlzaiQr/A==",
"license": "MIT",
"dependencies": {
"bignumber.js": "*",
"lodash": "*",
"make-plural": "*"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@ -17756,6 +17778,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/make-plural": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.4.0.tgz",
"integrity": "sha512-4/gC9KVNTV6pvYg2gFeQYTW3mWaoJt7WZE5vrp1KnQDgW92JtYZnzmZT81oj/dUTqAIu0ufI2x3dkgu3bB1tYg==",
"license": "Unicode-DFS-2016"
},
"node_modules/makeerror": { "node_modules/makeerror": {
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@ -19850,6 +19878,16 @@
} }
} }
}, },
"node_modules/react-native-dropdown-picker": {
"version": "5.4.6",
"resolved": "https://registry.npmjs.org/react-native-dropdown-picker/-/react-native-dropdown-picker-5.4.6.tgz",
"integrity": "sha512-T1XBHbE++M6aRU3wFYw3MvcOuabhWZ29RK/Ivdls2r1ZkZ62iEBZknLUPeVLMX3x6iUxj4Zgr3X2DGlEGXeHsA==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-helmet-async": { "node_modules/react-native-helmet-async": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-native-helmet-async/-/react-native-helmet-async-2.0.4.tgz", "resolved": "https://registry.npmjs.org/react-native-helmet-async/-/react-native-helmet-async-2.0.4.tgz",

View File

@ -42,9 +42,11 @@
"expo-system-ui": "~3.0.7", "expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.27", "expo-updates": "~0.25.27",
"expo-web-browser": "~13.0.3", "expo-web-browser": "~13.0.3",
"i18n-js": "^4.4.3",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-native": "0.74.5", "react-native": "0.74.5",
"react-native-dropdown-picker": "^5.4.6",
"react-native-linear-gradient": "^2.8.3", "react-native-linear-gradient": "^2.8.3",
"react-native-reanimated": "~3.10.1", "react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.5", "react-native-safe-area-context": "4.10.5",