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
+19 -17
View File
@@ -6,6 +6,7 @@ import { useThemeContext } from '../../components/ThemeProvider';
import { useAuth } from '../../hooks/useAuth';
import { spacing, borderRadius, shadows } from '../../constants/theme';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import { useTranslation } from '../../src/i18n';
import api from '../../services/api';
const TABLET_MIN_WIDTH = 768;
@@ -22,6 +23,7 @@ interface NotificationItem {
}
export default function AlertsScreen() {
const { t } = useTranslation();
const router = useRouter();
const { width } = useWindowDimensions();
const isTablet = width >= TABLET_MIN_WIDTH;
@@ -54,7 +56,7 @@ export default function AlertsScreen() {
);
setItems(merged);
} catch (err: any) {
setError(err.message || 'No se pudieron cargar las notificaciones');
setError(err.message || t('alerts.loadError'));
} finally {
setIsLoading(false);
}
@@ -63,12 +65,12 @@ export default function AlertsScreen() {
async function handleDelete(item: NotificationItem) {
const key = `${item.scope}:${item.id}`;
Alert.alert(
'Eliminar notificación',
`¿Eliminar la notificación de ${item.medicine_name || item.medicine_nregistro}?`,
t('alerts.delete'),
`${t('alerts.deleteConfirm')} ${item.medicine_name || item.medicine_nregistro}?`,
[
{ text: 'Cancelar', style: 'cancel' },
{ text: t('alerts.cancel'), style: 'cancel' },
{
text: 'Eliminar',
text: t('alerts.confirmDelete'),
style: 'destructive',
onPress: async () => {
setDeletingId(key);
@@ -78,7 +80,7 @@ export default function AlertsScreen() {
});
setItems(prev => prev.filter(i => !(i.scope === item.scope && i.id === item.id)));
} catch (err: any) {
Alert.alert('Error', err.message || 'No se pudo eliminar');
Alert.alert('Error', err.message || t('alerts.deleteError'));
} finally {
setDeletingId(null);
}
@@ -105,8 +107,8 @@ export default function AlertsScreen() {
/>
<Text style={[styles.chipText, { color: colors.onPrimaryContainer }]}>
{item.scope === 'pharmacy'
? item.pharmacy_name || `Farmacia #${item.pharmacy_id}`
: 'Cualquier farmacia'}
? item.pharmacy_name || `${t('alerts.pharmacy')} #${item.pharmacy_id}`
: t('alerts.anyPharmacy')}
</Text>
</View>
{item.pharmacy_address && (
@@ -132,22 +134,22 @@ export default function AlertsScreen() {
}
if (isLoading || authLoading) {
return <LoadingSpinner message="Cargando notificaciones..." />;
return <LoadingSpinner message={t('alerts.loading')} />;
}
if (!isAuthenticated) {
return (
<View style={[styles.container, styles.centered, { backgroundColor: colors.background }]}>
<Ionicons name="lock-closed-outline" size={64} color={colors.border} />
<Text style={[styles.loginTitle, { color: colors.text }]}>Inicia sesión para continuar</Text>
<Text style={[styles.loginTitle, { color: colors.text }]}>{t('alerts.loginRequired')}</Text>
<Text style={[styles.loginSubtitle, { color: colors.textSecondary }]}>
Necesitas estar autenticado para ver tus notificaciones
{t('alerts.loginDescription')}
</Text>
<TouchableOpacity
style={[styles.loginButton, { backgroundColor: colors.primary }]}
onPress={() => router.push('/auth/login')}
>
<Text style={[styles.loginButtonText, { color: colors.onPrimaryContainer }]}>Iniciar Sesión</Text>
<Text style={[styles.loginButtonText, { color: colors.onPrimaryContainer }]}>{t('alerts.loginBtn')}</Text>
</TouchableOpacity>
</View>
);
@@ -156,9 +158,9 @@ export default function AlertsScreen() {
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={[styles.header, isTablet && styles.headerTablet]}>
<Text style={[styles.title, isTablet && styles.titleTablet, { color: colors.text }]}>Notificaciones Guardadas</Text>
<Text style={[styles.title, isTablet && styles.titleTablet, { color: colors.text }]}>{t('alerts.title')}</Text>
<Text style={[styles.subtitle, isTablet && styles.subtitleTablet, { color: colors.textSecondary }]}>
Recibe avisos cuando medicamentos sin stock se repongan
{t('alerts.description')}
</Text>
</View>
@@ -166,7 +168,7 @@ export default function AlertsScreen() {
<View style={[styles.errorContainer, isTablet && styles.errorContainerTablet, { backgroundColor: colors.dangerContainer }]}>
<Text style={[styles.errorText, { color: colors.danger }]}>{error}</Text>
<TouchableOpacity onPress={loadNotifications} style={styles.retryButton}>
<Text style={[styles.retryText, { color: colors.primary }]}>Reintentar</Text>
<Text style={[styles.retryText, { color: colors.primary }]}>{t('alerts.retry')}</Text>
</TouchableOpacity>
</View>
)}
@@ -174,9 +176,9 @@ export default function AlertsScreen() {
{!error && items.length === 0 && (
<View style={styles.emptyContainer}>
<Ionicons name="notifications-off-outline" size={isTablet ? 80 : 64} color={colors.border} />
<Text style={[styles.emptyTitle, isTablet && styles.emptyTitleTablet, { color: colors.text }]}>Sin notificaciones</Text>
<Text style={[styles.emptyTitle, isTablet && styles.emptyTitleTablet, { color: colors.text }]}>{t('alerts.empty')}</Text>
<Text style={[styles.emptyText, isTablet && styles.emptyTextTablet, { color: colors.textSecondary }]}>
Toca la campana en una farmacia sin stock para recibir notificaciones cuando se reponga.
{t('alerts.emptyDescription')}
</Text>
</View>
)}