feat(i18n-mobile): add bilingual support (Català / Castellano) for React Native app
Run Tests on Branches / Detect Changes (push) Successful in 13s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Successful in 1m40s
Run Tests on Branches / Frontend Mobile Tests (push) Successful in 1m37s
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped

Add lightweight i18n system matching the web app architecture.
Language persisted in AsyncStorage('ff-lang').

New files:
- src/i18n/locales/ca.js (~190 translation keys)
- src/i18n/locales/es.js (~190 translation keys)
- src/i18n/LanguageContext.jsx (Provider + AsyncStorage)
- src/i18n/useTranslation.js (hook)
- src/i18n/index.js (barrel export)

Modified 16 files:
- app/_layout.tsx: wrap with LanguageProvider, translate stack titles
- app/(tabs)/_layout.tsx: translate tab bar labels
- app/(tabs)/index.tsx: translate home screen
- app/(tabs)/search.tsx: translate search screen
- app/(tabs)/alerts.tsx: translate alerts screen
- app/(tabs)/profile.tsx: translate profile + add language selector (CA/ES)
- app/(tabs)/scan.tsx: translate scanner screen
- app/auth/login.tsx: translate login/register
- app/medicine/[id].tsx: translate medicine detail
- app/pharmacy/[id].tsx: translate pharmacy detail
- app/product/[source]/[id].tsx: translate product detail
- 5 components: MedicineCard, StockBadge, SearchBar, LoadingSpinner, BarcodeScanner

Language selector in Profile screen (globe icon + CA/ES toggle).
No routes changed. No API calls affected.
This commit is contained in:
Antoni Nuñez Romeu
2026-07-17 10:22:14 +02:00
parent d12c575fcf
commit ee958d4525
21 changed files with 771 additions and 198 deletions
+25 -23
View File
@@ -22,6 +22,7 @@ import {
saveBiometricCredentials,
getBiometricUsername,
} from '../../services/biometrics';
import { useTranslation } from '../../src/i18n';
type Tab = 'login' | 'register';
@@ -29,6 +30,7 @@ export default function LoginScreen() {
const router = useRouter();
const { login } = useAuthStore();
const { colors } = useThemeContext();
const { t } = useTranslation();
const [tab, setTab] = useState<Tab>('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -64,17 +66,17 @@ export default function LoginScreen() {
const handleSubmit = async () => {
if (!username.trim() || !password) {
Alert.alert('Error', 'Por favor completa todos los campos');
Alert.alert(t('login.alert.error'), t('login.alert.fillFields'));
return;
}
if (isRegister) {
if (password.length < 8) {
Alert.alert('Error', 'La contraseña debe tener al menos 8 caracteres');
Alert.alert(t('login.alert.error'), t('login.alert.passwordLength'));
return;
}
if (password !== confirmPassword) {
Alert.alert('Error', 'Las contraseñas no coinciden');
Alert.alert(t('login.alert.error'), t('login.alert.passwordMismatch'));
return;
}
}
@@ -83,8 +85,8 @@ export default function LoginScreen() {
try {
if (isRegister) {
await register(username.trim(), password);
Alert.alert('Éxito', 'Cuenta creada correctamente', [
{ text: 'OK', onPress: () => setTab('login') },
Alert.alert(t('login.alert.success'), t('login.alert.accountCreated'), [
{ text: t('login.alert.ok'), onPress: () => setTab('login') },
]);
} else {
await login(username.trim(), password);
@@ -95,8 +97,8 @@ export default function LoginScreen() {
}
} catch (error) {
Alert.alert(
'Error',
isRegister ? 'No se pudo crear la cuenta' : 'Credenciales incorrectas'
t('login.alert.error'),
isRegister ? t('login.alert.createError') : t('login.alert.wrongCredentials')
);
} finally {
setIsLoading(false);
@@ -105,7 +107,7 @@ export default function LoginScreen() {
const handleBiometricLogin = async () => {
if (!biometricUsername) {
Alert.alert('Error', 'No hay credenciales biométricas guardadas');
Alert.alert(t('login.alert.error'), t('login.alert.noBiometrics'));
return;
}
@@ -116,10 +118,10 @@ export default function LoginScreen() {
await login(biometricUsername, '');
router.replace('/(tabs)');
} else {
Alert.alert('Error', 'Autenticación biométrica fallida');
Alert.alert(t('login.alert.error'), t('login.alert.biometricFailed'));
}
} catch (error) {
Alert.alert('Error', 'Error en la autenticación biométrica');
Alert.alert(t('login.alert.error'), t('login.alert.biometricError'));
} finally {
setIsLoading(false);
}
@@ -158,7 +160,7 @@ export default function LoginScreen() {
!isRegister && styles.tabTextActive,
]}
>
Iniciar Sesión
{t('login.tab.login')}
</Text>
</TouchableOpacity>
<TouchableOpacity
@@ -176,7 +178,7 @@ export default function LoginScreen() {
isRegister && styles.tabTextActive,
]}
>
Crear Cuenta
{t('login.tab.register')}
</Text>
</TouchableOpacity>
</View>
@@ -185,12 +187,12 @@ export default function LoginScreen() {
<View style={[styles.card, { backgroundColor: colors.card }, shadows.card]}>
{/* Header */}
<Text style={[styles.title, { color: colors.text }]}>
{isRegister ? 'Crea tu cuenta' : 'Bienvenido de nuevo'}
{isRegister ? t('login.title.register') : t('login.title.login')}
</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
{isRegister
? 'Guarda tu dirección y recibe notificaciones cuando lleguen medicamentos.'
: 'Inicia sesión para gestionar tu perfil y notificaciones.'}
? t('login.subtitle.register')
: t('login.subtitle.login')}
</Text>
{/* Username */}
@@ -200,7 +202,7 @@ export default function LoginScreen() {
style={[styles.input, { color: colors.text }]}
value={username}
onChangeText={setUsername}
placeholder="Usuario"
placeholder={t('login.username')}
placeholderTextColor={colors.textSecondary}
autoCapitalize="none"
autoCorrect={false}
@@ -215,7 +217,7 @@ export default function LoginScreen() {
style={[styles.input, { color: colors.text }]}
value={password}
onChangeText={setPassword}
placeholder="Contraseña"
placeholder={t('login.password')}
placeholderTextColor={colors.textSecondary}
secureTextEntry
autoComplete={isRegister ? 'new-password' : 'current-password'}
@@ -230,7 +232,7 @@ export default function LoginScreen() {
style={[styles.input, { color: colors.text }]}
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="Confirmar contraseña"
placeholder={t('login.confirmPassword')}
placeholderTextColor={colors.textSecondary}
secureTextEntry
autoComplete="new-password"
@@ -242,10 +244,10 @@ export default function LoginScreen() {
{isRegister && (
<View style={styles.hints}>
<Text style={[styles.hint, { color: colors.textSecondary }]}>
<Ionicons name="information-circle-outline" size={14} color={colors.textSecondary} /> 3-32 caracteres para el usuario
<Ionicons name="information-circle-outline" size={14} color={colors.textSecondary} /> {t('login.hint.username')}
</Text>
<Text style={[styles.hint, { color: colors.textSecondary }]}>
<Ionicons name="information-circle-outline" size={14} color={colors.textSecondary} /> Mínimo 8 caracteres para la contraseña
<Ionicons name="information-circle-outline" size={14} color={colors.textSecondary} /> {t('login.hint.password')}
</Text>
</View>
)}
@@ -260,7 +262,7 @@ export default function LoginScreen() {
<Ionicons name="hourglass" size={20} color={colors.onPrimaryContainer} />
) : (
<Text style={[styles.buttonText, { color: colors.onPrimaryContainer }]}>
{isRegister ? 'Crear Cuenta' : 'Iniciar Sesión'}
{isRegister ? t('login.tab.register') : t('login.tab.login')}
</Text>
)}
</TouchableOpacity>
@@ -273,7 +275,7 @@ export default function LoginScreen() {
disabled={isLoading}
>
<Ionicons name="finger-print" size={22} color={colors.primary} />
<Text style={[styles.biometricText, { color: colors.primary }]}>Iniciar con biometría</Text>
<Text style={[styles.biometricText, { color: colors.primary }]}>{t('login.biometric')}</Text>
</TouchableOpacity>
)}
</View>
@@ -284,7 +286,7 @@ export default function LoginScreen() {
onPress={() => setTab(isRegister ? 'login' : 'register')}
>
<Text style={[styles.linkText, { color: colors.primary }]}>
{isRegister ? '¿Ya tienes cuenta? Inicia sesión' : '¿No tienes cuenta? Regístrate'}
{isRegister ? t('login.link.toLogin') : t('login.link.toRegister')}
</Text>
</TouchableOpacity>
</View>