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
+23 -21
View File
@@ -11,6 +11,7 @@ import { LoadingSpinner } from '../../components/LoadingSpinner';
import { useThemeContext } from '../../components/ThemeProvider';
import { spacing, borderRadius } from '../../constants/theme';
import { Medicine, PharmacyMedicine } from '../../types';
import { useTranslation } from '../../src/i18n';
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371;
@@ -43,6 +44,7 @@ export default function MedicineDetailScreen() {
const router = useRouter();
const { colors } = useThemeContext();
const { isAuthenticated } = useAuth();
const { t } = useTranslation();
const [medicine, setMedicine] = useState<Medicine | null>(null);
const [pharmacies, setPharmacies] = useState<PharmacyMedicine[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -86,7 +88,7 @@ export default function MedicineDetailScreen() {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
setLocationError('Permiso de ubicación denegado');
setLocationError(t('medicine.locationDenied'));
setLocating(false);
return;
}
@@ -95,7 +97,7 @@ export default function MedicineDetailScreen() {
setUserPosition({ lat: pos.coords.latitude, lon: pos.coords.longitude });
setSortByDistance(true);
} catch {
setLocationError('No se pudo obtener tu ubicación');
setLocationError(t('medicine.locationError'));
} finally {
setLocating(false);
}
@@ -156,13 +158,13 @@ export default function MedicineDetailScreen() {
};
if (isLoading) {
return <LoadingSpinner message="Cargando medicamento..." />;
return <LoadingSpinner message={t('medicine.loading')} />;
}
if (!medicine) {
return (
<View style={[styles.errorContainer, { backgroundColor: colors.background }]}>
<Text style={[styles.errorText, { color: colors.textSecondary }]}>Medicamento no encontrado</Text>
<Text style={[styles.errorText, { color: colors.textSecondary }]}>{t('medicine.notFound')}</Text>
</View>
);
}
@@ -190,23 +192,23 @@ export default function MedicineDetailScreen() {
</View>
<View style={[styles.infoSection, { backgroundColor: colors.card }]}>
<InfoRow label="Principio activo" value={medicine.active_ingredient} colors={colors} />
<InfoRow label="Laboratorio" value={medicine.laboratory} colors={colors} />
<InfoRow label="Forma farmacéutica" value={medicine.form} colors={colors} />
<InfoRow label="Dosificación" value={medicine.dosage} colors={colors} />
<InfoRow label={t('medicine.activeIngredient')} value={medicine.active_ingredient} colors={colors} />
<InfoRow label={t('medicine.laboratory')} value={medicine.laboratory} colors={colors} />
<InfoRow label={t('medicine.form')} value={medicine.form} colors={colors} />
<InfoRow label={t('medicine.dosage')} value={medicine.dosage} colors={colors} />
<InfoRow
label="Precio"
value={medicine.precio != null ? `${medicine.precio.toFixed(2)} €` : 'No disponible'}
label={t('medicine.price')}
value={medicine.precio != null ? `${medicine.precio.toFixed(2)} €` : t('medicine.notAvailable')}
colors={colors}
/>
<InfoRow label="Registro" value={medicine.nregistro} colors={colors} />
<InfoRow label={t('medicine.registration')} value={medicine.nregistro} colors={colors} />
</View>
{locatedPharmacies.length > 0 && (
<View style={[styles.mapContainer, { backgroundColor: colors.surfaceVariant }]}>
<Ionicons name="map-outline" size={48} color={colors.textSecondary} />
<Text style={[styles.mapPlaceholder, { color: colors.textSecondary }]}>
Mapa próximamente…
{t('medicine.mapSoon')}
</Text>
</View>
)}
@@ -214,7 +216,7 @@ export default function MedicineDetailScreen() {
<View style={styles.pharmaciesSection}>
<View style={styles.pharmaciesHeader}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>
Farmacias ({sortedPharmacies.length})
{t('medicine.pharmacies')} ({sortedPharmacies.length})
</Text>
<TouchableOpacity
style={[styles.sortButton, sortByDistance && styles.sortButtonActive, { backgroundColor: sortByDistance ? colors.primary : colors.primaryContainer, borderColor: colors.primary }]}
@@ -224,10 +226,10 @@ export default function MedicineDetailScreen() {
<Ionicons name="location" size={16} color={sortByDistance ? '#fff' : colors.primary} />
<Text style={[styles.sortButtonText, sortByDistance && styles.sortButtonTextActive, { color: sortByDistance ? '#fff' : colors.primary }]}>
{locating
? 'Localizando…'
? t('medicine.locating')
: sortByDistance
? 'Distancia · Reset'
: 'Ordenar por distancia'}
? t('medicine.distanceReset')
: t('medicine.sortByDistance')}
</Text>
</TouchableOpacity>
</View>
@@ -236,13 +238,13 @@ export default function MedicineDetailScreen() {
<View style={[styles.locationErrorContainer, { backgroundColor: colors.dangerContainer }]}>
<Text style={[styles.locationErrorText, { color: colors.danger }]}>{locationError}</Text>
<TouchableOpacity onPress={handleSortByDistance}>
<Text style={[styles.retryText, { color: colors.primary }]}>Reintentar</Text>
<Text style={[styles.retryText, { color: colors.primary }]}>{t('medicine.retry')}</Text>
</TouchableOpacity>
</View>
)}
{sortedPharmacies.length === 0 ? (
<Text style={[styles.noPharmacies, { color: colors.textSecondary }]}>No hay farmacias disponibles</Text>
<Text style={[styles.noPharmacies, { color: colors.textSecondary }]}>{t('medicine.noPharmacies')}</Text>
) : (
sortedPharmacies.map((pharm) => {
const lat = getPharmacyLat(pharm);
@@ -271,10 +273,10 @@ export default function MedicineDetailScreen() {
{pharm.price != null ? (
<Text style={[styles.price, { color: colors.primary }]}>{pharm.price.toFixed(2)} €</Text>
) : (
<Text style={[styles.price, { color: colors.primary }]}>Consultar precio</Text>
<Text style={[styles.price, { color: colors.primary }]}>{t('medicine.checkPrice')}</Text>
)}
{pharm.stock > 0 && (
<Text style={[styles.stock, { color: colors.textSecondary }]}>Stock: {pharm.stock}</Text>
<Text style={[styles.stock, { color: colors.textSecondary }]}>{t('pharmacy.stock')}{pharm.stock}</Text>
)}
</View>
</TouchableOpacity>
@@ -284,7 +286,7 @@ export default function MedicineDetailScreen() {
onPress={() => handleDirections(lat, lon)}
>
<Ionicons name="navigate" size={16} color={colors.primary} />
<Text style={[styles.directionsText, { color: colors.primary }]}>Cómo llegar</Text>
<Text style={[styles.directionsText, { color: colors.primary }]}>{t('medicine.howToGet')}</Text>
</TouchableOpacity>
)}
</View>