feat(i18n): add bilingual support (Català / Castellano) #41
@@ -3,6 +3,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { View, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useThemeContext } from '../../components/ThemeProvider';
|
||||
import { useTranslation } from '../../src/i18n';
|
||||
import { shadows } from '../../constants/theme';
|
||||
|
||||
const TABLET_MIN_WIDTH = 768;
|
||||
@@ -25,6 +26,7 @@ export default function TabLayout() {
|
||||
const { width } = useWindowDimensions();
|
||||
const isTablet = width >= TABLET_MIN_WIDTH;
|
||||
const { colors } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
// Use safe area insets if available, otherwise use standard Android nav bar height
|
||||
const bottomPadding = insets.bottom > 0 ? insets.bottom : ANDROID_NAV_BAR_HEIGHT;
|
||||
|
||||
@@ -62,7 +64,7 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Inicio',
|
||||
title: t('nav.home'),
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="home" size={size} color={color} />
|
||||
),
|
||||
@@ -71,7 +73,7 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="search"
|
||||
options={{
|
||||
title: 'Buscar',
|
||||
title: t('nav.search'),
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="search" size={size} color={color} />
|
||||
),
|
||||
@@ -80,7 +82,7 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="scan"
|
||||
options={{
|
||||
title: 'Escanear',
|
||||
title: t('nav.scan'),
|
||||
tabBarIcon: ({ color, size }) => <ScanIcon color={color} size={size} />,
|
||||
tabBarLabel: () => null,
|
||||
}}
|
||||
@@ -88,7 +90,7 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="alerts"
|
||||
options={{
|
||||
title: 'Avisos',
|
||||
title: t('nav.alerts'),
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="notifications" size={size} color={color} />
|
||||
),
|
||||
@@ -103,7 +105,7 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Perfil',
|
||||
title: t('nav.profile'),
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="person" size={size} color={color} />
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity, Image, useWindowDimensions }
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useThemeContext } from '../../components/ThemeProvider';
|
||||
import { useTranslation } from '../../src/i18n';
|
||||
import { spacing, borderRadius, shadows } from '../../constants/theme';
|
||||
|
||||
const TABLET_MIN_WIDTH = 768;
|
||||
@@ -12,6 +13,7 @@ export default function HomeScreen() {
|
||||
const { width } = useWindowDimensions();
|
||||
const isTablet = width >= TABLET_MIN_WIDTH;
|
||||
const { colors } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
@@ -23,7 +25,7 @@ export default function HomeScreen() {
|
||||
/>
|
||||
<Text style={[styles.brandName, isTablet && styles.brandNameTablet, { color: colors.text }]}>FarmaClic</Text>
|
||||
<Text style={[styles.description, isTablet && styles.descriptionTablet, { color: colors.textSecondary }]}>
|
||||
Encuentra tus medicamentos en farmacias cercanas
|
||||
{t('home.description')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -37,7 +39,7 @@ export default function HomeScreen() {
|
||||
<Ionicons name="search" size={isTablet ? 28 : 24} color={colors.onPrimaryContainer} />
|
||||
</View>
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={[styles.cardLabel, isTablet && styles.cardLabelTablet, { color: colors.onPrimaryContainer }]}>Buscar Medicamento</Text>
|
||||
<Text style={[styles.cardLabel, isTablet && styles.cardLabelTablet, { color: colors.onPrimaryContainer }]}>{t('home.searchMedicine')}</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.onPrimaryContainer} style={{ opacity: 0.7 }} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
@@ -51,7 +53,7 @@ export default function HomeScreen() {
|
||||
<Ionicons name="scan" size={isTablet ? 28 : 24} color="#ffffff" />
|
||||
</View>
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={[styles.cardLabel, styles.cardLabelScan, isTablet && styles.cardLabelTablet]}>Escanear TSI</Text>
|
||||
<Text style={[styles.cardLabel, styles.cardLabelScan, isTablet && styles.cardLabelTablet]}>{t('home.scanTSI')}</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="#ffffff" style={{ opacity: 0.7 }} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as ImagePicker from 'expo-image-picker';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { useThemeContext } from '../../components/ThemeProvider';
|
||||
import { useThemeStore, ThemeMode } from '../../store/themeStore';
|
||||
import { useTranslation } from '../../src/i18n';
|
||||
import { spacing, borderRadius, shadows } from '../../constants/theme';
|
||||
import api from '../../services/api';
|
||||
|
||||
@@ -62,10 +63,10 @@ interface Address {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const THEME_OPTIONS: { mode: ThemeMode; label: string; icon: string }[] = [
|
||||
{ mode: 'system', label: 'Sistema', icon: 'phone-portrait-outline' },
|
||||
{ mode: 'light', label: 'Claro', icon: 'sunny-outline' },
|
||||
{ mode: 'dark', label: 'Oscuro', icon: 'moon-outline' },
|
||||
const THEME_OPTIONS: { mode: ThemeMode; labelKey: string; icon: string }[] = [
|
||||
{ mode: 'system', labelKey: 'profile.themeSystem', icon: 'phone-portrait-outline' },
|
||||
{ mode: 'light', labelKey: 'profile.themeLight', icon: 'sunny-outline' },
|
||||
{ mode: 'dark', labelKey: 'profile.themeDark', icon: 'moon-outline' },
|
||||
];
|
||||
|
||||
export default function ProfileScreen() {
|
||||
@@ -74,6 +75,7 @@ export default function ProfileScreen() {
|
||||
const isTablet = width >= TABLET_MIN_WIDTH;
|
||||
const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth();
|
||||
const { colors, isDark } = useThemeContext();
|
||||
const { t, lang, setLang } = useTranslation();
|
||||
const themeMode = useThemeStore((s) => s.mode);
|
||||
const setThemeMode = useThemeStore((s) => s.setMode);
|
||||
|
||||
@@ -146,10 +148,10 @@ export default function ProfileScreen() {
|
||||
});
|
||||
setFirstName(res.data.first_name || '');
|
||||
setLastName(res.data.last_name || '');
|
||||
setConfigFeedback({ type: 'ok', text: 'Perfil guardado.' });
|
||||
setConfigFeedback({ type: 'ok', text: t('profile.profileSaved') });
|
||||
setTimeout(() => setShowConfig(false), 1200);
|
||||
} catch (err: any) {
|
||||
setConfigFeedback({ type: 'err', text: err.message || 'Error al guardar' });
|
||||
setConfigFeedback({ type: 'err', text: err.message || t('profile.saveError') });
|
||||
} finally { setConfigSaving(false); }
|
||||
}
|
||||
|
||||
@@ -164,7 +166,7 @@ export default function ProfileScreen() {
|
||||
|
||||
async function handleTakePhoto() {
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (status !== 'granted') { Alert.alert('Permiso requerido', 'Necesitamos permiso para acceder a la cámara.'); return; }
|
||||
if (status !== 'granted') { Alert.alert(t('profile.cameraPermission'), t('profile.cameraPermissionDesc')); return; }
|
||||
const result = await ImagePicker.launchCameraAsync({ allowsEditing: true, aspect: [1, 1], quality: 0.8, base64: true });
|
||||
if (!result.canceled && result.assets[0]?.base64) {
|
||||
const dataUri = `data:${result.assets[0].mimeType};base64,${result.assets[0].base64}`;
|
||||
@@ -204,12 +206,12 @@ export default function ProfileScreen() {
|
||||
|
||||
async function handleAddressSave() {
|
||||
const addr = formAddress.trim();
|
||||
if (!addr) { setFormError('La dirección es obligatoria'); return; }
|
||||
if (!addr) { setFormError(t('profile.addressRequired')); return; }
|
||||
setFormSaving(true); setFormError('');
|
||||
try {
|
||||
const url = editingAddressId ? `/api/addresses/${editingAddressId}` : '/api/addresses';
|
||||
const res = await fetch(url, { method: editingAddressId ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ address: addr, label: formLabel.trim(), is_default: formDefault }) });
|
||||
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Error al guardar'); }
|
||||
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || t('profile.saveError')); }
|
||||
setShowAddressForm(false); setEditingAddressId(null); loadAddresses();
|
||||
} catch (err: any) { setFormError(err.message); } finally { setFormSaving(false); }
|
||||
}
|
||||
@@ -223,9 +225,9 @@ export default function ProfileScreen() {
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
Alert.alert('Cerrar Sesión', '¿Estás seguro que deseas cerrar sesión?', [
|
||||
{ text: 'Cancelar', style: 'cancel' },
|
||||
{ text: 'Cerrar Sesión', style: 'destructive', onPress: async () => { await logout(); router.replace('/auth/login'); } },
|
||||
Alert.alert(t('profile.logoutTitle'), t('profile.logoutConfirm'), [
|
||||
{ text: t('profile.logoutCancel'), style: 'cancel' },
|
||||
{ text: t('profile.logoutConfirmBtn'), style: 'destructive', onPress: async () => { await logout(); router.replace('/auth/login'); } },
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -246,12 +248,12 @@ export default function ProfileScreen() {
|
||||
<View style={[styles.authIconCircle, { backgroundColor: colors.primaryContainer }]}>
|
||||
<Ionicons name="person-outline" size={isTablet ? 60 : 48} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.authTitle, isTablet && styles.authTitleTablet, { color: colors.text }]}>Inicia Sesión</Text>
|
||||
<Text style={[styles.authTitle, isTablet && styles.authTitleTablet, { color: colors.text }]}>{t('profile.loginTitle')}</Text>
|
||||
<Text style={[styles.authSubtitle, isTablet && styles.authSubtitleTablet, { color: colors.textSecondary }]}>
|
||||
Inicia sesión para acceder a tu perfil, notificaciones y más
|
||||
{t('profile.loginDescription')}
|
||||
</Text>
|
||||
<TouchableOpacity style={[styles.authButton, { backgroundColor: colors.primary }]} onPress={() => router.push('/auth/login')}>
|
||||
<Text style={[styles.authButtonText, { color: colors.onPrimaryContainer }]}>Iniciar Sesión</Text>
|
||||
<Text style={[styles.authButtonText, { color: colors.onPrimaryContainer }]}>{t('profile.loginBtn')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -287,15 +289,15 @@ export default function ProfileScreen() {
|
||||
<View style={[styles.card, { backgroundColor: colors.card }, shadows.card]}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Ionicons name="person-outline" size={18} color={colors.primary} />
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>Datos personales</Text>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{t('profile.personalData')}</Text>
|
||||
</View>
|
||||
<View style={styles.infoGrid}>
|
||||
<View style={[styles.infoBox, { backgroundColor: colors.surfaceLow }]}>
|
||||
<Text style={[styles.infoLabel, { color: colors.textSecondary }]}>Nombre</Text>
|
||||
<Text style={[styles.infoLabel, { color: colors.textSecondary }]}>{t('profile.firstName')}</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>{firstName || '—'}</Text>
|
||||
</View>
|
||||
<View style={[styles.infoBox, { backgroundColor: colors.surfaceLow }]}>
|
||||
<Text style={[styles.infoLabel, { color: colors.textSecondary }]}>Apellidos</Text>
|
||||
<Text style={[styles.infoLabel, { color: colors.textSecondary }]}>{t('profile.lastName')}</Text>
|
||||
<Text style={[styles.infoValue, { color: colors.text }]}>{lastName || '—'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -306,7 +308,7 @@ export default function ProfileScreen() {
|
||||
<View style={[styles.card, { backgroundColor: colors.card }, shadows.card]}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Ionicons name={getThemeModeIcon()} size={18} color={colors.primary} />
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>Apariencia</Text>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{t('profile.appearance')}</Text>
|
||||
</View>
|
||||
<View style={[styles.themePills, { backgroundColor: colors.surfaceLow }]}>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
@@ -316,7 +318,30 @@ export default function ProfileScreen() {
|
||||
onPress={() => setThemeMode(opt.mode)}
|
||||
>
|
||||
<Ionicons name={opt.icon as any} size={16} color={themeMode === opt.mode ? colors.onPrimaryContainer : colors.textSecondary} />
|
||||
<Text style={[styles.themePillText, { color: themeMode === opt.mode ? colors.onPrimaryContainer : colors.textSecondary }]}>{opt.label}</Text>
|
||||
<Text style={[styles.themePillText, { color: themeMode === opt.mode ? colors.onPrimaryContainer : colors.textSecondary }]}>{t(opt.labelKey)}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Language card */}
|
||||
<View style={[styles.card, { backgroundColor: colors.card }, shadows.card]}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Ionicons name="globe-outline" size={18} color={colors.primary} />
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{t('profile.language')}</Text>
|
||||
</View>
|
||||
<View style={[styles.themePills, { backgroundColor: colors.surfaceLow }]}>
|
||||
{[
|
||||
{ value: 'ca' as const, labelKey: 'profile.languageCatalan', icon: 'language-outline' },
|
||||
{ value: 'es' as const, labelKey: 'profile.languageSpanish', icon: 'language-outline' },
|
||||
].map((opt) => (
|
||||
<TouchableOpacity
|
||||
key={opt.value}
|
||||
style={[styles.themePill, lang === opt.value && { backgroundColor: colors.primary }]}
|
||||
onPress={() => setLang(opt.value)}
|
||||
>
|
||||
<Ionicons name={opt.icon as any} size={16} color={lang === opt.value ? colors.onPrimaryContainer : colors.textSecondary} />
|
||||
<Text style={[styles.themePillText, { color: lang === opt.value ? colors.onPrimaryContainer : colors.textSecondary }]}>{t(opt.labelKey)}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
@@ -328,7 +353,7 @@ export default function ProfileScreen() {
|
||||
<View style={[styles.menuIconCircle, { backgroundColor: colors.surfaceLow }]}>
|
||||
<Ionicons name="settings-outline" size={20} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.menuLabel, { color: colors.text }]}>Configuración</Text>
|
||||
<Text style={[styles.menuLabel, { color: colors.text }]}>{t('profile.config')}</Text>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -338,7 +363,7 @@ export default function ProfileScreen() {
|
||||
<View style={[styles.menuIconCircle, { backgroundColor: colors.surfaceLow }]}>
|
||||
<Ionicons name="location-outline" size={20} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.menuLabel, { color: colors.text }]}>Mis Direcciones</Text>
|
||||
<Text style={[styles.menuLabel, { color: colors.text }]}>{t('profile.myAddresses')}</Text>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -349,7 +374,7 @@ export default function ProfileScreen() {
|
||||
<View style={[styles.menuIconCircle, { backgroundColor: colors.surfaceLow }]}>
|
||||
<Ionicons name="shield-outline" size={20} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.menuLabel, { color: colors.text }]}>Panel Admin</Text>
|
||||
<Text style={[styles.menuLabel, { color: colors.text }]}>{t('profile.admin')}</Text>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
@@ -361,7 +386,7 @@ export default function ProfileScreen() {
|
||||
<View style={[styles.card, { backgroundColor: colors.card }, shadows.card]}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Ionicons name="time-outline" size={18} color={colors.primary} />
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>Búsquedas recientes</Text>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{t('profile.recentSearches')}</Text>
|
||||
</View>
|
||||
{searchHistory.map((item, i) => (
|
||||
<React.Fragment key={item.id}>
|
||||
@@ -381,7 +406,7 @@ export default function ProfileScreen() {
|
||||
{/* Logout */}
|
||||
<TouchableOpacity style={[styles.logoutCard, { backgroundColor: colors.card, borderColor: isDark ? '#5a2020' : '#fecaca' }]} onPress={handleLogout}>
|
||||
<Ionicons name="log-out-outline" size={20} color={colors.danger} />
|
||||
<Text style={[styles.logoutText, { color: colors.danger }]}>Cerrar Sesión</Text>
|
||||
<Text style={[styles.logoutText, { color: colors.danger }]}>{t('profile.logout')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ height: spacing.xl }} />
|
||||
@@ -394,16 +419,16 @@ export default function ProfileScreen() {
|
||||
<View style={[styles.modalSheet, { backgroundColor: colors.card }]}>
|
||||
<View style={[styles.modalHandle, { backgroundColor: colors.border }]} />
|
||||
<View style={[styles.modalHeader, { borderBottomColor: colors.surfaceLow }]}>
|
||||
<Text style={[styles.modalTitle, { color: colors.text }]}>Cambiar Avatar</Text>
|
||||
<Text style={[styles.modalTitle, { color: colors.text }]}>{t('profile.changeAvatar')}</Text>
|
||||
<TouchableOpacity onPress={() => setShowAvatarModal(false)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={[styles.avatarTabBar, { borderBottomColor: colors.surfaceLow }]}>
|
||||
{(['presets', 'colors', 'upload'] as const).map((t) => (
|
||||
<TouchableOpacity key={t} style={[styles.avatarTabBtn, avatarTab === t && { borderBottomColor: colors.primary }]} onPress={() => setAvatarTab(t)}>
|
||||
<Ionicons name={t === 'presets' ? 'person-outline' : t === 'colors' ? 'color-palette-outline' : 'cloud-upload-outline'} size={18} color={avatarTab === t ? colors.primary : colors.textSecondary} />
|
||||
<Text style={[styles.avatarTabLabel, { color: avatarTab === t ? colors.primary : colors.textSecondary }]}>{t === 'presets' ? 'Prediseñado' : t === 'colors' ? 'Colores' : 'Subir'}</Text>
|
||||
{(['presets', 'colors', 'upload'] as const).map((tab) => (
|
||||
<TouchableOpacity key={tab} style={[styles.avatarTabBtn, avatarTab === tab && { borderBottomColor: colors.primary }]} onPress={() => setAvatarTab(tab)}>
|
||||
<Ionicons name={tab === 'presets' ? 'person-outline' : tab === 'colors' ? 'color-palette-outline' : 'cloud-upload-outline'} size={18} color={avatarTab === tab ? colors.primary : colors.textSecondary} />
|
||||
<Text style={[styles.avatarTabLabel, { color: avatarTab === tab ? colors.primary : colors.textSecondary }]}>{tab === 'presets' ? t('profile.presetAvatar') : tab === 'colors' ? t('profile.colors') : t('profile.upload')}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
@@ -433,8 +458,8 @@ export default function ProfileScreen() {
|
||||
<Ionicons name="camera-outline" size={28} color={colors.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.uploadTitle, { color: colors.text }]}>Tomar foto</Text>
|
||||
<Text style={[styles.uploadSub, { color: colors.textSecondary }]}>Usa la cámara de tu dispositivo</Text>
|
||||
<Text style={[styles.uploadTitle, { color: colors.text }]}>{t('profile.takePhoto')}</Text>
|
||||
<Text style={[styles.uploadSub, { color: colors.textSecondary }]}>{t('profile.takePhotoDesc')}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
@@ -443,8 +468,8 @@ export default function ProfileScreen() {
|
||||
<Ionicons name="images-outline" size={28} color={colors.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.uploadTitle, { color: colors.text }]}>Elegir de galería</Text>
|
||||
<Text style={[styles.uploadSub, { color: colors.textSecondary }]}>Selecciona una imagen existente</Text>
|
||||
<Text style={[styles.uploadTitle, { color: colors.text }]}>{t('profile.chooseGallery')}</Text>
|
||||
<Text style={[styles.uploadSub, { color: colors.textSecondary }]}>{t('profile.chooseGalleryDesc')}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
@@ -461,28 +486,28 @@ export default function ProfileScreen() {
|
||||
<View style={[styles.modalSheet, { backgroundColor: colors.card }]}>
|
||||
<View style={[styles.modalHandle, { backgroundColor: colors.border }]} />
|
||||
<View style={[styles.modalHeader, { borderBottomColor: colors.surfaceLow }]}>
|
||||
<Text style={[styles.modalTitle, { color: colors.text }]}>Configuración</Text>
|
||||
<Text style={[styles.modalTitle, { color: colors.text }]}>{t('profile.configTitle')}</Text>
|
||||
<TouchableOpacity onPress={() => setShowConfig(false)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={styles.modalBody} contentContainerStyle={{ paddingBottom: spacing.xl }}>
|
||||
{[
|
||||
{ label: 'Nombre', value: configFirstName, onChange: setConfigFirstName, placeholder: 'Tu nombre', icon: 'person-outline' },
|
||||
{ label: 'Apellidos', value: configLastName, onChange: setConfigLastName, placeholder: 'Tus apellidos', icon: 'person-outline' },
|
||||
{ label: 'Correo electrónico', value: configEmail, onChange: setConfigEmail, placeholder: 'tu@email.com', icon: 'mail-outline', keyboard: 'email-address' as const },
|
||||
{ label: 'Ciudad', value: configCity, onChange: setConfigCity, placeholder: 'Tu ciudad', icon: 'business-outline' },
|
||||
{ label: 'Dirección', value: configAddress, onChange: setConfigAddress, placeholder: 'Calle Mayor 1, Madrid', icon: 'location-outline' },
|
||||
{ labelKey: 'profile.firstNameLabel', placeholderKey: 'profile.firstNamePlaceholder', value: configFirstName, onChange: setConfigFirstName, icon: 'person-outline' },
|
||||
{ labelKey: 'profile.lastNameLabel', placeholderKey: 'profile.lastNamePlaceholder', value: configLastName, onChange: setConfigLastName, icon: 'person-outline' },
|
||||
{ labelKey: 'profile.email', placeholder: 'tu@email.com', value: configEmail, onChange: setConfigEmail, icon: 'mail-outline', keyboard: 'email-address' as const },
|
||||
{ labelKey: 'profile.city', placeholderKey: 'profile.cityPlaceholder', value: configCity, onChange: setConfigCity, icon: 'business-outline' },
|
||||
{ labelKey: 'profile.address', placeholderKey: 'profile.addressPlaceholder', value: configAddress, onChange: setConfigAddress, icon: 'location-outline' },
|
||||
].map((field) => (
|
||||
<View key={field.label} style={styles.modalField}>
|
||||
<Text style={[styles.modalLabel, { color: colors.textSecondary }]}>{field.label}</Text>
|
||||
<View key={field.labelKey} style={styles.modalField}>
|
||||
<Text style={[styles.modalLabel, { color: colors.textSecondary }]}>{t(field.labelKey)}</Text>
|
||||
<View style={[styles.modalInputRow, { backgroundColor: colors.surfaceLow, borderColor: colors.border }]}>
|
||||
<Ionicons name={field.icon as any} size={18} color={colors.textSecondary} style={{ marginRight: spacing.sm }} />
|
||||
<TextInput
|
||||
style={[styles.modalInput, { color: colors.text }]}
|
||||
value={field.value}
|
||||
onChangeText={field.onChange}
|
||||
placeholder={field.placeholder}
|
||||
placeholder={field.placeholderKey ? t(field.placeholderKey) : field.placeholder}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
keyboardType={field.keyboard}
|
||||
autoCapitalize="none"
|
||||
@@ -501,10 +526,10 @@ export default function ProfileScreen() {
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity style={[styles.modalCancelBtn, { borderColor: colors.border }]} onPress={() => setShowConfig(false)} disabled={configSaving}>
|
||||
<Text style={[styles.modalCancelText, { color: colors.text }]}>Cancelar</Text>
|
||||
<Text style={[styles.modalCancelText, { color: colors.text }]}>{t('profile.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.modalSaveBtn, { backgroundColor: colors.primary }, configSaving && { opacity: 0.6 }]} onPress={handleConfigSave} disabled={configSaving}>
|
||||
{configSaving ? <ActivityIndicator color={colors.onPrimaryContainer} /> : <Text style={[styles.modalSaveText, { color: colors.onPrimaryContainer }]}>Guardar</Text>}
|
||||
{configSaving ? <ActivityIndicator color={colors.onPrimaryContainer} /> : <Text style={[styles.modalSaveText, { color: colors.onPrimaryContainer }]}>{t('profile.save')}</Text>}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -518,7 +543,7 @@ export default function ProfileScreen() {
|
||||
<View style={[styles.modalSheet, { backgroundColor: colors.card }]}>
|
||||
<View style={[styles.modalHandle, { backgroundColor: colors.border }]} />
|
||||
<View style={[styles.modalHeader, { borderBottomColor: colors.surfaceLow }]}>
|
||||
<Text style={[styles.modalTitle, { color: colors.text }]}>Mis Direcciones</Text>
|
||||
<Text style={[styles.modalTitle, { color: colors.text }]}>{t('profile.addressesTitle')}</Text>
|
||||
<TouchableOpacity onPress={() => { setShowAddresses(false); setShowAddressForm(false); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
@@ -527,22 +552,22 @@ export default function ProfileScreen() {
|
||||
{showAddressForm ? (
|
||||
<View>
|
||||
<View style={styles.modalField}>
|
||||
<Text style={[styles.modalLabel, { color: colors.textSecondary }]}>Dirección</Text>
|
||||
<Text style={[styles.modalLabel, { color: colors.textSecondary }]}>{t('profile.addressLabel')}</Text>
|
||||
<View style={[styles.modalInputRow, { backgroundColor: colors.surfaceLow, borderColor: colors.border }]}>
|
||||
<Ionicons name="location-outline" size={18} color={colors.textSecondary} style={{ marginRight: spacing.sm }} />
|
||||
<TextInput style={[styles.modalInput, { color: colors.text }]} value={formAddress} onChangeText={setFormAddress} placeholder="Calle Mayor 1, Madrid" placeholderTextColor={colors.textSecondary} editable={!formSaving} />
|
||||
<TextInput style={[styles.modalInput, { color: colors.text }]} value={formAddress} onChangeText={setFormAddress} placeholder={t('profile.addressPlaceholder')} placeholderTextColor={colors.textSecondary} editable={!formSaving} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.modalField}>
|
||||
<Text style={[styles.modalLabel, { color: colors.textSecondary }]}>Etiqueta (opcional)</Text>
|
||||
<Text style={[styles.modalLabel, { color: colors.textSecondary }]}>{t('profile.addressOptional')}</Text>
|
||||
<View style={[styles.modalInputRow, { backgroundColor: colors.surfaceLow, borderColor: colors.border }]}>
|
||||
<Ionicons name="pricetag-outline" size={18} color={colors.textSecondary} style={{ marginRight: spacing.sm }} />
|
||||
<TextInput style={[styles.modalInput, { color: colors.text }]} value={formLabel} onChangeText={setFormLabel} placeholder="Casa, Trabajo..." placeholderTextColor={colors.textSecondary} editable={!formSaving} />
|
||||
<TextInput style={[styles.modalInput, { color: colors.text }]} value={formLabel} onChangeText={setFormLabel} placeholder={t('profile.addressLabelPlaceholder')} placeholderTextColor={colors.textSecondary} editable={!formSaving} />
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.checkboxRow} onPress={() => setFormDefault(!formDefault)} disabled={formSaving}>
|
||||
<Ionicons name={formDefault ? 'checkbox' : 'square-outline'} size={22} color={formDefault ? colors.primary : colors.textSecondary} />
|
||||
<Text style={[styles.checkboxLabel, { color: colors.text }]}>Dirección predeterminada</Text>
|
||||
<Text style={[styles.checkboxLabel, { color: colors.text }]}>{t('profile.defaultAddress')}</Text>
|
||||
</TouchableOpacity>
|
||||
{formError ? (
|
||||
<View style={[styles.modalFeedback, { backgroundColor: colors.dangerContainer, borderColor: isDark ? '#5a2020' : '#fecaca' }]}>
|
||||
@@ -551,10 +576,10 @@ export default function ProfileScreen() {
|
||||
) : null}
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity style={[styles.modalCancelBtn, { borderColor: colors.border }]} onPress={() => { setShowAddressForm(false); setFormError(''); }} disabled={formSaving}>
|
||||
<Text style={[styles.modalCancelText, { color: colors.text }]}>Cancelar</Text>
|
||||
<Text style={[styles.modalCancelText, { color: colors.text }]}>{t('profile.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.modalSaveBtn, { backgroundColor: colors.primary }, formSaving && { opacity: 0.6 }]} onPress={handleAddressSave} disabled={formSaving}>
|
||||
{formSaving ? <ActivityIndicator color={colors.onPrimaryContainer} /> : <Text style={[styles.modalSaveText, { color: colors.onPrimaryContainer }]}>{editingAddressId ? 'Actualizar' : 'Añadir'}</Text>}
|
||||
{formSaving ? <ActivityIndicator color={colors.onPrimaryContainer} /> : <Text style={[styles.modalSaveText, { color: colors.onPrimaryContainer }]}>{editingAddressId ? t('profile.update') : t('profile.add')}</Text>}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -567,7 +592,7 @@ export default function ProfileScreen() {
|
||||
{user?.address && (
|
||||
<View style={[styles.addrCard, { borderColor: colors.primary, backgroundColor: isDark ? '#1a3a1c' : '#eaf7ec' }]}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.addrBadge, { color: colors.primary }]}>Principal</Text>
|
||||
<Text style={[styles.addrBadge, { color: colors.primary }]}>{t('profile.mainAddress')}</Text>
|
||||
<Text style={[styles.addrText, { color: colors.text }]}>{user.address}</Text>
|
||||
</View>
|
||||
<Ionicons name="checkmark-circle" size={20} color={colors.primary} />
|
||||
@@ -580,7 +605,7 @@ export default function ProfileScreen() {
|
||||
<Text style={[styles.addrText, { color: colors.text }]}>{addr.address}</Text>
|
||||
{!addr.is_default && (
|
||||
<TouchableOpacity onPress={() => handleSetDefault(addr.id)}>
|
||||
<Text style={[styles.addrDefaultLink, { color: colors.primary }]}>Marcar como predeterminada</Text>
|
||||
<Text style={[styles.addrDefaultLink, { color: colors.primary }]}>{t('profile.setDefault')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
@@ -596,7 +621,7 @@ export default function ProfileScreen() {
|
||||
))}
|
||||
<TouchableOpacity style={[styles.addAddrBtn, { borderColor: colors.border }]} onPress={openAddAddressForm}>
|
||||
<Ionicons name="add-circle-outline" size={20} color={colors.primary} />
|
||||
<Text style={[styles.addAddrText, { color: colors.primary }]}>Añadir dirección</Text>
|
||||
<Text style={[styles.addAddrText, { color: colors.primary }]}>{t('profile.addAddress')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { useThemeContext } from '../../components/ThemeProvider';
|
||||
import { useTranslation } from '../../src/i18n';
|
||||
import { spacing, borderRadius, shadows } from '../../constants/theme';
|
||||
|
||||
const TABLET_MIN_WIDTH = 768;
|
||||
@@ -13,6 +14,7 @@ export default function ScanTabScreen() {
|
||||
const { width } = useWindowDimensions();
|
||||
const isTablet = width >= TABLET_MIN_WIDTH;
|
||||
const { colors } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
const [manualNumber, setManualNumber] = useState('');
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
|
||||
@@ -20,8 +22,8 @@ export default function ScanTabScreen() {
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert(
|
||||
'Permiso requerido',
|
||||
'Necesitamos acceso a la cámara para tomar fotos del dispositivo.'
|
||||
t('scanner.cameraPermission'),
|
||||
t('scanner.cameraPermissionDesc')
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -41,8 +43,8 @@ export default function ScanTabScreen() {
|
||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert(
|
||||
'Permiso requerido',
|
||||
'Necesitamos acceso a la galería para seleccionar fotos.'
|
||||
t('scanner.galleryPermission'),
|
||||
t('scanner.galleryPermissionDesc')
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -61,7 +63,7 @@ export default function ScanTabScreen() {
|
||||
const handleManualSubmit = () => {
|
||||
const trimmed = manualNumber.trim();
|
||||
if (trimmed.length === 0) {
|
||||
Alert.alert('Campo requerido', 'Por favor, introduce el número de la tarjeta.');
|
||||
Alert.alert(t('scanner.fieldRequired'), t('scanner.fieldRequiredDesc'));
|
||||
return;
|
||||
}
|
||||
router.push(`/medicine/${trimmed}`);
|
||||
@@ -76,9 +78,9 @@ export default function ScanTabScreen() {
|
||||
<View style={[styles.iconContainer, isTablet && styles.iconContainerTablet, { backgroundColor: colors.primaryContainer }]}>
|
||||
<Ionicons name="scan" size={isTablet ? 80 : 64} color={colors.scanButton} />
|
||||
</View>
|
||||
<Text style={[styles.title, isTablet && styles.titleTablet, { color: colors.text }]}>Escanear TSI</Text>
|
||||
<Text style={[styles.title, isTablet && styles.titleTablet, { color: colors.text }]}>{t('scanner.title')}</Text>
|
||||
<Text style={[styles.description, isTablet && styles.descriptionTablet, { color: colors.textSecondary }]}>
|
||||
Escanea el código de barras de tu tarjeta sanitaria para encontrar tus medicamentos
|
||||
{t('scanner.description')}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryButton, shadows.scanButton, isTablet && styles.primaryButtonTablet]}
|
||||
@@ -87,14 +89,14 @@ export default function ScanTabScreen() {
|
||||
>
|
||||
<Ionicons name="scan" size={isTablet ? 28 : 24} color="#ffffff" />
|
||||
<Text style={[styles.primaryButtonText, isTablet && styles.primaryButtonTextTablet]}>
|
||||
Iniciar escaneo
|
||||
{t('scanner.startScan')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Separator */}
|
||||
<View style={styles.separator}>
|
||||
<View style={[styles.separatorLine, { backgroundColor: colors.border }]} />
|
||||
<Text style={[styles.separatorText, { color: colors.textSecondary }]}>o</Text>
|
||||
<Text style={[styles.separatorText, { color: colors.textSecondary }]}>{t('scanner.or')}</Text>
|
||||
<View style={[styles.separatorLine, { backgroundColor: colors.border }]} />
|
||||
</View>
|
||||
|
||||
@@ -103,10 +105,10 @@ export default function ScanTabScreen() {
|
||||
<Ionicons name="camera" size={24} color={colors.primary} />
|
||||
<View style={styles.optionTextContainer}>
|
||||
<Text style={[styles.optionTitle, isTablet && styles.optionTitleTablet, { color: colors.text }]}>
|
||||
Subir foto del dispositivo
|
||||
{t('scanner.uploadPhoto')}
|
||||
</Text>
|
||||
<Text style={[styles.optionDescription, isTablet && styles.optionDescriptionTablet, { color: colors.textSecondary }]}>
|
||||
Toma o selecciona una foto del dispositivo sanitario
|
||||
{t('scanner.uploadDescription')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -117,7 +119,7 @@ export default function ScanTabScreen() {
|
||||
onPress={handleTakePhoto}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={20} color={colors.primary} />
|
||||
<Text style={[styles.photoButtonText, { color: colors.primary }]}>Tomar foto</Text>
|
||||
<Text style={[styles.photoButtonText, { color: colors.primary }]}>{t('scanner.takePhoto')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.photoButton, isTablet && styles.photoButtonTablet, { backgroundColor: colors.primaryContainer, borderColor: colors.primary }]}
|
||||
@@ -125,13 +127,13 @@ export default function ScanTabScreen() {
|
||||
onPress={handleSelectFromGallery}
|
||||
>
|
||||
<Ionicons name="images-outline" size={20} color={colors.primary} />
|
||||
<Text style={[styles.photoButtonText, { color: colors.primary }]}>Galería</Text>
|
||||
<Text style={[styles.photoButtonText, { color: colors.primary }]}>{t('scanner.gallery')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{selectedImage && (
|
||||
<View style={styles.imagePreviewContainer}>
|
||||
<Ionicons name="checkmark-circle" size={20} color={colors.success} />
|
||||
<Text style={[styles.imagePreviewText, { color: colors.success }]}>Foto seleccionada correctamente</Text>
|
||||
<Text style={[styles.imagePreviewText, { color: colors.success }]}>{t('scanner.photoSelected')}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -140,17 +142,17 @@ export default function ScanTabScreen() {
|
||||
<Ionicons name="keypad" size={24} color={colors.primary} />
|
||||
<View style={styles.optionTextContainer}>
|
||||
<Text style={[styles.optionTitle, isTablet && styles.optionTitleTablet, { color: colors.text }]}>
|
||||
Introducir número manualmente
|
||||
{t('scanner.manualEntry')}
|
||||
</Text>
|
||||
<Text style={[styles.optionDescription, isTablet && styles.optionDescriptionTablet, { color: colors.textSecondary }]}>
|
||||
Escribe el número de tu tarjeta sanitaria
|
||||
{t('scanner.manualDescription')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.manualInputRow}>
|
||||
<TextInput
|
||||
style={[styles.manualInput, isTablet && styles.manualInputTablet, { backgroundColor: colors.card, borderColor: colors.border, color: colors.text }]}
|
||||
placeholder="Introduce el número de tarjeta"
|
||||
placeholder={t('scanner.manualPlaceholder')}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
value={manualNumber}
|
||||
onChangeText={setManualNumber}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useThemeContext } from '../../components/ThemeProvider';
|
||||
import { spacing, borderRadius } from '../../constants/theme';
|
||||
import { Medicine } from '../../types';
|
||||
import { config } from '../../constants/config';
|
||||
import { useTranslation } from '../../src/i18n';
|
||||
|
||||
const TABLET_MIN_WIDTH = 768;
|
||||
|
||||
@@ -25,6 +26,7 @@ const suggestions = [
|
||||
];
|
||||
|
||||
export default function SearchScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
const isTablet = width >= TABLET_MIN_WIDTH;
|
||||
const { isAuthenticated } = useAuth();
|
||||
@@ -68,7 +70,7 @@ export default function SearchScreen() {
|
||||
const data = await searchMedicines(debouncedQuery);
|
||||
setResults(data);
|
||||
} catch (err) {
|
||||
setError('Error al buscar medicamentos');
|
||||
setError(t('search.error'));
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -106,7 +108,7 @@ export default function SearchScreen() {
|
||||
{query.length >= 2 && (results.length + products.length) > 0 && (
|
||||
<View style={styles.resultsSummary}>
|
||||
<Text style={[styles.resultsSummaryText, { color: colors.textSecondary }]}>
|
||||
{results.length + products.length} resultados encontrados
|
||||
{results.length + products.length} {t('search.resultsFound')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -114,7 +116,7 @@ export default function SearchScreen() {
|
||||
{showSuggestions && (
|
||||
<>
|
||||
<View style={[styles.suggestionsSection, isTablet && styles.suggestionsSectionTablet]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Sugerencias</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t('search.suggestions')}</Text>
|
||||
<View style={styles.suggestionsGrid}>
|
||||
{suggestions.map((s) => (
|
||||
<TouchableOpacity
|
||||
@@ -134,7 +136,7 @@ export default function SearchScreen() {
|
||||
|
||||
{isAuthenticated && recentSearches.length > 0 && (
|
||||
<View style={[styles.recentSection, isTablet && styles.recentSectionTablet]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Búsquedas recientes</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t('search.recentSearches')}</Text>
|
||||
{recentSearches.map((term) => (
|
||||
<TouchableOpacity
|
||||
key={term}
|
||||
@@ -154,7 +156,7 @@ export default function SearchScreen() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLoading && <LoadingSpinner message="Buscando..." />}
|
||||
{isLoading && <LoadingSpinner message={t('search.loading')} />}
|
||||
|
||||
{error && (
|
||||
<View style={[styles.errorContainer, isTablet && styles.errorContainerTablet, { backgroundColor: colors.dangerContainer }]}>
|
||||
@@ -164,7 +166,7 @@ export default function SearchScreen() {
|
||||
|
||||
{!isLoading && !error && results.length === 0 && products.length === 0 && query.length >= 2 && (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>No se encontraron resultados</Text>
|
||||
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>{t('search.noResults')}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -176,7 +178,7 @@ export default function SearchScreen() {
|
||||
<>
|
||||
{products.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Parafarmacia</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t('search.parapharmacy')}</Text>
|
||||
{products.map((product) => (
|
||||
<TouchableOpacity
|
||||
key={`${product.source}-${product.id || product._id}`}
|
||||
@@ -187,7 +189,7 @@ export default function SearchScreen() {
|
||||
<View style={styles.productInfo}>
|
||||
<View style={styles.badges}>
|
||||
<View style={[styles.sourceBadge, { backgroundColor: '#16a34a' }]}>
|
||||
<Text style={styles.badgeText}>Parafarmacia</Text>
|
||||
<Text style={styles.badgeText}>{t('search.parapharmacy')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.productName, { color: colors.text }]} numberOfLines={1}>{product.name}</Text>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications';
|
||||
import { ThemeProvider, useThemeContext } from '../components/ThemeProvider';
|
||||
import { LanguageProvider, useTranslation } from '../src/i18n';
|
||||
import { initFaro } from '../services/faro';
|
||||
|
||||
// Boot Faro RUM once, as early as possible.
|
||||
@@ -21,6 +22,7 @@ const bgDark = require('../assets/bg_dark.png');
|
||||
function RootLayoutInner() {
|
||||
const { checkAuth } = useAuthStore();
|
||||
const { colors, isDark } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
const notificationListener = useRef<ReturnType<typeof addNotificationListener>>();
|
||||
const responseListener = useRef<ReturnType<typeof addNotificationResponseListener>>();
|
||||
|
||||
@@ -56,30 +58,30 @@ function RootLayoutInner() {
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="medicine/[id]"
|
||||
options={{ title: 'Medicamento' }}
|
||||
options={{ title: t('stack.medicine') }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="pharmacy/[id]"
|
||||
options={{ title: 'Farmacia' }}
|
||||
options={{ title: t('stack.pharmacy') }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="scanner"
|
||||
options={{
|
||||
title: 'Escanear',
|
||||
title: t('stack.scanner'),
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="auth/login"
|
||||
options={{
|
||||
title: 'Iniciar Sesión',
|
||||
title: t('stack.login'),
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="auth/register"
|
||||
options={{
|
||||
title: 'Registrarse',
|
||||
title: t('stack.register'),
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
@@ -112,11 +114,13 @@ export default function RootLayout() {
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<SafeAreaProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LanguageProvider>
|
||||
<ThemeProvider>
|
||||
<ThemedBackground>
|
||||
<RootLayoutInner />
|
||||
</ThemedBackground>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
</QueryClientProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,12 +10,14 @@ import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||
import { useThemeContext } from '../../components/ThemeProvider';
|
||||
import { spacing, borderRadius } from '../../constants/theme';
|
||||
import { Pharmacy, PharmacyMedicine } from '../../types';
|
||||
import { useTranslation } from '../../src/i18n';
|
||||
|
||||
export default function PharmacyDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const { colors } = useThemeContext();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [pharmacy, setPharmacy] = useState<Pharmacy | null>(null);
|
||||
const [medicines, setMedicines] = useState<PharmacyMedicine[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -77,13 +79,13 @@ export default function PharmacyDetailScreen() {
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message="Cargando farmacia..." />;
|
||||
return <LoadingSpinner message={t('pharmacy.loading')} />;
|
||||
}
|
||||
|
||||
if (!pharmacy) {
|
||||
return (
|
||||
<View style={[styles.errorContainer, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.errorText, { color: colors.textSecondary }]}>Farmacia no encontrada</Text>
|
||||
<Text style={[styles.errorText, { color: colors.textSecondary }]}>{t('pharmacy.notFound')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -97,12 +99,12 @@ export default function PharmacyDetailScreen() {
|
||||
<View style={[styles.actionsRow, { backgroundColor: colors.card, borderBottomColor: colors.separator }]}>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleCall}>
|
||||
<Ionicons name="call" size={20} color={colors.primary} />
|
||||
<Text style={[styles.actionText, { color: colors.primary }]}>Llamar</Text>
|
||||
<Text style={[styles.actionText, { color: colors.primary }]}>{t('pharmacy.call')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleDirections}>
|
||||
<Ionicons name="navigate" size={20} color={colors.primary} />
|
||||
<Text style={[styles.actionText, { color: colors.primary }]}>Cómo llegar</Text>
|
||||
<Text style={[styles.actionText, { color: colors.primary }]}>{t('pharmacy.howToGet')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -143,11 +145,11 @@ export default function PharmacyDetailScreen() {
|
||||
|
||||
<View style={styles.medicinesSection}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>
|
||||
Medicamentos ({medicines.length})
|
||||
{t('pharmacy.medications')} ({medicines.length})
|
||||
</Text>
|
||||
|
||||
{medicines.length === 0 ? (
|
||||
<Text style={[styles.noMedicines, { color: colors.textSecondary }]}>No hay medicamentos disponibles</Text>
|
||||
<Text style={[styles.noMedicines, { color: colors.textSecondary }]}>{t('pharmacy.noMedications')}</Text>
|
||||
) : (
|
||||
medicines.map((med) => {
|
||||
const isSub = subscribedMeds.has(med.medicine_nregistro);
|
||||
@@ -159,11 +161,11 @@ export default function PharmacyDetailScreen() {
|
||||
>
|
||||
<View style={styles.medicineInfo}>
|
||||
<Text style={[styles.medicineName, { color: colors.text }]}>{med.medicine_name}</Text>
|
||||
<Text style={[styles.medicineNregistro, { color: colors.textSecondary }]}>Reg: {med.medicine_nregistro}</Text>
|
||||
<Text style={[styles.medicineNregistro, { color: colors.textSecondary }]}>{t('pharmacy.reg')}{med.medicine_nregistro}</Text>
|
||||
</View>
|
||||
<View style={styles.medicineStock}>
|
||||
<Text style={[styles.price, { color: colors.primary }]}>{med.price.toFixed(2)} €</Text>
|
||||
<Text style={[styles.stock, { color: colors.textSecondary }]}>Stock: {med.stock}</Text>
|
||||
<Text style={[styles.stock, { color: colors.textSecondary }]}>{t('pharmacy.stock')}{med.stock}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{isAuthenticated && (
|
||||
@@ -177,7 +179,7 @@ export default function PharmacyDetailScreen() {
|
||||
color={isSub ? '#fff' : colors.textSecondary}
|
||||
/>
|
||||
<Text style={[styles.bellText, { color: isSub ? '#fff' : colors.textSecondary }]}>
|
||||
{isSub ? 'Notificaciones activas' : 'Notificarme cuando haya stock'}
|
||||
{isSub ? t('pharmacy.notificationsActive') : t('pharmacy.notifyWhenAvailable')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
@@ -5,10 +5,12 @@ import { getProduct, Product } from '../../../services/products';
|
||||
import { LoadingSpinner } from '../../../components/LoadingSpinner';
|
||||
import { useThemeContext } from '../../../components/ThemeProvider';
|
||||
import { spacing, borderRadius } from '../../../constants/theme';
|
||||
import { useTranslation } from '../../../src/i18n';
|
||||
|
||||
export default function ProductDetailScreen() {
|
||||
const { source, id } = useLocalSearchParams<{ source: string; id: string }>();
|
||||
const { colors } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
@@ -35,13 +37,13 @@ export default function ProductDetailScreen() {
|
||||
}, [source, id]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message="Cargando producto..." />;
|
||||
return <LoadingSpinner message={t('product.loading')} />;
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<View style={[styles.errorContainer, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.errorText, { color: colors.textSecondary }]}>Producto no encontrado</Text>
|
||||
<Text style={[styles.errorText, { color: colors.textSecondary }]}>{t('product.notFound')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +56,7 @@ export default function ProductDetailScreen() {
|
||||
<Image source={{ uri: product.image_url }} style={styles.image} resizeMode="contain" />
|
||||
) : (
|
||||
<View style={[styles.imagePlaceholder, { backgroundColor: colors.surfaceLow }]}>
|
||||
<Text style={[styles.placeholderText, { color: colors.textSecondary }]}>Sin imagen</Text>
|
||||
<Text style={[styles.placeholderText, { color: colors.textSecondary }]}>{t('product.noImage')}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -62,7 +64,7 @@ export default function ProductDetailScreen() {
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={[styles.name, { color: colors.text }]}>{product.name}</Text>
|
||||
<View style={[styles.badge, { backgroundColor: isCima ? '#2b5bb5' : '#4caf50' }]}>
|
||||
<Text style={styles.badgeText}>{isCima ? 'CIMA' : 'Parafarmacia'}</Text>
|
||||
<Text style={styles.badgeText}>{isCima ? 'CIMA' : t('product.parapharmacy')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{product.brand ? (
|
||||
@@ -75,45 +77,45 @@ export default function ProductDetailScreen() {
|
||||
|
||||
{isCima ? (
|
||||
<View style={[styles.infoSection, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Detalles CIMA</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t('product.cimaDetails')}</Text>
|
||||
{product.active_ingredient && (
|
||||
<InfoRow label="Principio activo" value={product.active_ingredient} colors={colors} />
|
||||
<InfoRow label={t('product.activeIngredient')} value={product.active_ingredient} colors={colors} />
|
||||
)}
|
||||
{product.dosage && (
|
||||
<InfoRow label="Dosificación" value={product.dosage} colors={colors} />
|
||||
<InfoRow label={t('product.dosage')} value={product.dosage} colors={colors} />
|
||||
)}
|
||||
{product.form && (
|
||||
<InfoRow label="Forma farmacéutica" value={product.form} colors={colors} />
|
||||
<InfoRow label={t('product.form')} value={product.form} colors={colors} />
|
||||
)}
|
||||
{product.prescription && (
|
||||
<InfoRow label="Tipo de dispensación" value={product.prescription} colors={colors} />
|
||||
<InfoRow label={t('product.dispensation')} value={product.prescription} colors={colors} />
|
||||
)}
|
||||
<InfoRow label="Comercializado" value={product.commercialized ? 'Sí' : 'No'} colors={colors} />
|
||||
<InfoRow label={t('product.commercialized')} value={product.commercialized ? t('product.yes') : t('product.no')} colors={colors} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.infoSection, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Información del producto</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t('product.productInfo')}</Text>
|
||||
{product.price != null && (
|
||||
<InfoRow label="Precio" value={`${product.price} €`} colors={colors} />
|
||||
<InfoRow label={t('product.price')} value={`${product.price} €`} colors={colors} />
|
||||
)}
|
||||
{product.original_price != null && product.original_price > (product.price || 0) && (
|
||||
<InfoRow label="Precio anterior" value={`${product.original_price} €`} colors={colors} />
|
||||
<InfoRow label={t('product.previousPrice')} value={`${product.original_price} €`} colors={colors} />
|
||||
)}
|
||||
{product.category && (
|
||||
<InfoRow label="Categoría" value={product.category} colors={colors} />
|
||||
<InfoRow label={t('product.category')} value={product.category} colors={colors} />
|
||||
)}
|
||||
{product.brand && (
|
||||
<InfoRow label="Marca" value={product.brand} colors={colors} />
|
||||
<InfoRow label={t('product.brand')} value={product.brand} colors={colors} />
|
||||
)}
|
||||
{product.source_url && (
|
||||
<InfoRow label="Fuente" value={product.source || 'Parafarmacia'} colors={colors} />
|
||||
<InfoRow label={t('product.source')} value={product.source || t('product.parapharmacy')} colors={colors} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isCima && product.photos && product.photos.length > 0 && (
|
||||
<View style={[styles.infoSection, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Imágenes</Text>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t('product.images')}</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.photosScroll}>
|
||||
{product.photos.map((photo, i) => (
|
||||
<View key={i} style={styles.photoItem}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useThemeContext } from './ThemeProvider';
|
||||
import { spacing, borderRadius } from '../constants/theme';
|
||||
import { useTranslation } from '../src/i18n';
|
||||
|
||||
interface BarcodeScannerProps {
|
||||
onBarcodeScanned: (barcode: string) => void;
|
||||
@@ -14,6 +15,7 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [scanned, setScanned] = useState(false);
|
||||
const { colors } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!permission) {
|
||||
return <View style={styles.container} />;
|
||||
@@ -23,15 +25,15 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp
|
||||
return (
|
||||
<View style={[styles.permissionContainer, { backgroundColor: colors.background }]}>
|
||||
<Ionicons name="camera" size={64} color={colors.textSecondary} />
|
||||
<Text style={[styles.permissionTitle, { color: colors.text }]}>Permiso de cámara requerido</Text>
|
||||
<Text style={[styles.permissionTitle, { color: colors.text }]}>{t('barcodeScanner.permissionRequired')}</Text>
|
||||
<Text style={[styles.permissionText, { color: colors.textSecondary }]}>
|
||||
Necesitamos acceso a la cámara para escanear códigos de barras
|
||||
{t('barcodeScanner.permissionDesc')}
|
||||
</Text>
|
||||
<TouchableOpacity style={[styles.permissionButton, { backgroundColor: colors.primary }]} onPress={requestPermission}>
|
||||
<Text style={styles.permissionButtonText}>Conceder permiso</Text>
|
||||
<Text style={styles.permissionButtonText}>{t('barcodeScanner.grantPermission')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.cancelButton} onPress={onClose}>
|
||||
<Text style={[styles.cancelButtonText, { color: colors.textSecondary }]}>Cancelar</Text>
|
||||
<Text style={[styles.cancelButtonText, { color: colors.textSecondary }]}>{t('barcodeScanner.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
@@ -63,7 +65,7 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp
|
||||
</View>
|
||||
|
||||
<Text style={styles.instruction}>
|
||||
Apunta la cámara al código de barras del medicamento
|
||||
{t('barcodeScanner.scanningHint')}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
@@ -77,7 +79,7 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp
|
||||
style={[styles.scanAgainButton, { backgroundColor: colors.primary }]}
|
||||
onPress={() => setScanned(false)}
|
||||
>
|
||||
<Text style={styles.scanAgainText}>Escanear de nuevo</Text>
|
||||
<Text style={styles.scanAgainText}>{t('barcodeScanner.scanAgain')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -2,18 +2,20 @@ import React from 'react';
|
||||
import { View, ActivityIndicator, Text, StyleSheet } from 'react-native';
|
||||
import { useThemeContext } from './ThemeProvider';
|
||||
import { spacing } from '../constants/theme';
|
||||
import { useTranslation } from '../src/i18n';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ message = 'Cargando...' }: LoadingSpinnerProps) {
|
||||
export function LoadingSpinner({ message }: LoadingSpinnerProps) {
|
||||
const { colors } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.message, { color: colors.textSecondary }]}>{message}</Text>
|
||||
<Text style={[styles.message, { color: colors.textSecondary }]}>{message ?? t('loadingSpinner.loading')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useThemeContext } from './ThemeProvider';
|
||||
import { spacing, borderRadius } from '../constants/theme';
|
||||
import { StockBadge } from './StockBadge';
|
||||
import { Medicine } from '../types';
|
||||
import { useTranslation } from '../src/i18n';
|
||||
|
||||
const TABLET_MIN_WIDTH = 768;
|
||||
|
||||
@@ -18,6 +19,7 @@ export function MedicineCard({ medicine }: MedicineCardProps) {
|
||||
const { width } = useWindowDimensions();
|
||||
const isTablet = width >= TABLET_MIN_WIDTH;
|
||||
const { colors } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handlePress = () => {
|
||||
router.push(`/medicine/${medicine.nregistro}`);
|
||||
@@ -44,7 +46,7 @@ export function MedicineCard({ medicine }: MedicineCardProps) {
|
||||
<View style={styles.priceContainer}>
|
||||
<Ionicons name="pricetag" size={14} color={colors.textSecondary} />
|
||||
<Text style={[styles.price, isTablet && styles.priceTablet, { color: colors.primary }]}>
|
||||
{medicine.precio != null ? `${medicine.precio.toFixed(2)} €` : 'Sin precio'}
|
||||
{medicine.precio != null ? `${medicine.precio.toFixed(2)} €` : t('medicineCard.noPrice')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, TextInput, StyleSheet, TouchableOpacity, useWindowDimensions } fr
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useThemeContext } from './ThemeProvider';
|
||||
import { spacing, borderRadius } from '../constants/theme';
|
||||
import { useTranslation } from '../src/i18n';
|
||||
|
||||
const TABLET_MIN_WIDTH = 768;
|
||||
|
||||
@@ -14,7 +15,7 @@ interface SearchBarProps {
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
placeholder = 'Buscar medicamentos...',
|
||||
placeholder,
|
||||
onSearch,
|
||||
value,
|
||||
onChangeText
|
||||
@@ -22,6 +23,7 @@ export function SearchBar({
|
||||
const { width } = useWindowDimensions();
|
||||
const isTablet = width >= TABLET_MIN_WIDTH;
|
||||
const { colors } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
const [localValue, setLocalValue] = useState(value || '');
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
@@ -44,7 +46,7 @@ export function SearchBar({
|
||||
<Ionicons name="search" size={20} color={colors.textSecondary} style={styles.icon} />
|
||||
<TextInput
|
||||
style={[styles.input, isTablet && styles.inputTablet, { color: colors.text }]}
|
||||
placeholder={placeholder}
|
||||
placeholder={placeholder ?? t('search.placeholder')}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
value={localValue}
|
||||
onChangeText={handleChange}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { useThemeContext } from './ThemeProvider';
|
||||
import { borderRadius, spacing } from '../constants/theme';
|
||||
import { useTranslation } from '../src/i18n';
|
||||
|
||||
interface StockBadgeProps {
|
||||
stock: number;
|
||||
@@ -9,6 +10,7 @@ interface StockBadgeProps {
|
||||
|
||||
export function StockBadge({ stock }: StockBadgeProps) {
|
||||
const { colors, isDark } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getBadgeColors = () => {
|
||||
if (stock === 0) {
|
||||
@@ -25,7 +27,7 @@ export function StockBadge({ stock }: StockBadgeProps) {
|
||||
return (
|
||||
<View style={[styles.badge, { backgroundColor: badgeColors.bg }]}>
|
||||
<Text style={[styles.text, { color: badgeColors.text }]}>
|
||||
{stock === 0 ? 'Sin stock' : stock < 5 ? `Bajo (${stock})` : `Disponible (${stock})`}
|
||||
{stock === 0 ? t('stockBadge.outOfStock') : stock < 5 ? `${t('stockBadge.lowStock')} (${stock})` : `${t('stockBadge.inStock')} (${stock})`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { createContext, useState, useCallback, useMemo } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import es from './locales/es';
|
||||
import ca from './locales/ca';
|
||||
|
||||
const locales = { es, ca };
|
||||
const STORAGE_KEY = 'ff-lang';
|
||||
|
||||
function getInitialLang() {
|
||||
// Default to 'es' - will be updated from AsyncStorage in the provider
|
||||
return 'es';
|
||||
}
|
||||
|
||||
export const LanguageContext = createContext(null);
|
||||
|
||||
export function LanguageProvider({ children }) {
|
||||
const [lang, setLangState] = useState(getInitialLang);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// Load saved language on mount
|
||||
React.useEffect(() => {
|
||||
AsyncStorage.getItem(STORAGE_KEY).then((saved) => {
|
||||
if (saved === 'ca' || saved === 'es') {
|
||||
setLangState(saved);
|
||||
}
|
||||
// Default to 'es' if no saved preference
|
||||
setReady(true);
|
||||
}).catch(() => {
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setLang = useCallback((newLang) => {
|
||||
if (newLang !== 'ca' && newLang !== 'es') return;
|
||||
setLangState(newLang);
|
||||
AsyncStorage.setItem(STORAGE_KEY, newLang).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const t = useCallback((key) => {
|
||||
return locales[lang]?.[key] || locales.es[key] || key;
|
||||
}, [lang]);
|
||||
|
||||
const value = useMemo(() => ({ lang, setLang, t, ready }), [lang, setLang, t, ready]);
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={value}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LanguageProvider } from './LanguageContext';
|
||||
export { useTranslation } from './useTranslation';
|
||||
@@ -0,0 +1,228 @@
|
||||
const ca = {
|
||||
// Tab bar
|
||||
'nav.home': 'Inici',
|
||||
'nav.search': 'Cercar',
|
||||
'nav.scan': 'Escanejar',
|
||||
'nav.alerts': 'Alertes',
|
||||
'nav.profile': 'Perfil',
|
||||
|
||||
// Stack screens
|
||||
'stack.medicine': 'Medicament',
|
||||
'stack.pharmacy': 'Farmàcia',
|
||||
'stack.scanner': 'Escanejar',
|
||||
'stack.login': 'Iniciar Sessió',
|
||||
'stack.register': 'Registrar-se',
|
||||
|
||||
// Home
|
||||
'home.description': 'Trobeu els vostres medicaments a farmàcies properes',
|
||||
'home.searchMedicine': 'Cercar Medicament',
|
||||
'home.scanTSI': 'Escanejar TSI',
|
||||
|
||||
// Search
|
||||
'search.placeholder': 'Cercar medicaments...',
|
||||
'search.suggestions': 'Suggeriments',
|
||||
'search.recentSearches': 'Cerques recents',
|
||||
'search.loading': 'Cercant...',
|
||||
'search.noResults': 'No s\'han trobat resultats',
|
||||
'search.resultsFound': 'resultats trobats',
|
||||
'search.parapharmacy': 'Parafarmàcia',
|
||||
'search.error': 'Error en cercar medicaments',
|
||||
|
||||
// Login
|
||||
'login.tab.login': 'Iniciar Sessió',
|
||||
'login.tab.register': 'Crear Compte',
|
||||
'login.title.login': 'Benvingut de nou',
|
||||
'login.title.register': 'Crea el teu compte',
|
||||
'login.subtitle.login': 'Inicia sessió per gestionar el teu perfil i notificacions.',
|
||||
'login.subtitle.register': 'Desa la teva adreça i rep notificacions quan arribin medicaments.',
|
||||
'login.username': 'Usuari',
|
||||
'login.password': 'Contrasenya',
|
||||
'login.confirmPassword': 'Confirmar contrasenya',
|
||||
'login.hint.username': '3-32 caràcters per a l\'usuari',
|
||||
'login.hint.password': 'Mínim 8 caràcters per a la contrasenya',
|
||||
'login.biometric': 'Iniciar amb biometria',
|
||||
'login.link.toLogin': 'Ja tens compte? Inicia sessió',
|
||||
'login.link.toRegister': 'No tens compte? Registra\'t',
|
||||
'login.alert.error': 'Error',
|
||||
'login.alert.fillFields': 'Si us plau, completeu tots els camps',
|
||||
'login.alert.passwordLength': 'La contrasenya ha de tenir almenys 8 caràcters',
|
||||
'login.alert.passwordMismatch': 'Les contrasenyes no coincideixen',
|
||||
'login.alert.success': 'Èxit',
|
||||
'login.alert.accountCreated': 'Compte creat correctament',
|
||||
'login.alert.ok': 'D\'acord',
|
||||
'login.alert.createError': 'No s\'ha pogut crear el compte',
|
||||
'login.alert.wrongCredentials': 'Credencials incorrectes',
|
||||
'login.alert.noBiometrics': 'No hi ha credencials biomètriques guardades',
|
||||
'login.alert.biometricFailed': 'Autenticació biomètrica fallida',
|
||||
'login.alert.biometricError': 'Error en l\'autenticació biomètrica',
|
||||
|
||||
// Scanner
|
||||
'scanner.title': 'Escanejar TSI',
|
||||
'scanner.description': 'Escanegeu el codi de barres de la vostra targeta sanitària per trobar els vostres medicaments',
|
||||
'scanner.startScan': 'Iniciar escaneig',
|
||||
'scanner.or': 'o',
|
||||
'scanner.uploadPhoto': 'Pujar foto del dispositiu',
|
||||
'scanner.uploadDescription': 'Tireu o seleccioneu una foto del dispositiu sanitari',
|
||||
'scanner.takePhoto': 'Fer foto',
|
||||
'scanner.gallery': 'Galeria',
|
||||
'scanner.photoSelected': 'Foto seleccionada correctament',
|
||||
'scanner.manualEntry': 'Introduir número manualment',
|
||||
'scanner.manualDescription': 'Escriviu el número de la vostra targeta sanitària',
|
||||
'scanner.manualPlaceholder': 'Introduïu el número de targeta',
|
||||
'scanner.cameraPermission': 'Permís de càmera requerit',
|
||||
'scanner.cameraPermissionDesc': 'Necessitem accés a la càmera per fer fotos del dispositiu.',
|
||||
'scanner.galleryPermission': 'Permís requerit',
|
||||
'scanner.galleryPermissionDesc': 'Necessitem accés a la galeria per seleccionar fotos.',
|
||||
'scanner.fieldRequired': 'Camp requerit',
|
||||
'scanner.fieldRequiredDesc': 'Si us plau, introduïu el número de la targeta.',
|
||||
|
||||
// Alerts
|
||||
'alerts.title': 'Notificacions Desades',
|
||||
'alerts.description': 'Rebeu avisos quan medicaments sense estoc es repoixin',
|
||||
'alerts.loading': 'Carregant notificacions...',
|
||||
'alerts.loginRequired': 'Inicieu sessió per continuar',
|
||||
'alerts.loginDescription': 'Necessiteu estar autenticat per veure les vostres notificacions',
|
||||
'alerts.loginBtn': 'Iniciar Sessió',
|
||||
'alerts.empty': 'Sense notificacions',
|
||||
'alerts.emptyDescription': 'Toqueu la campana a una farmàcia sense estoc per rebre notificacions quan es repoixi.',
|
||||
'alerts.retry': 'Tornar a provar',
|
||||
'alerts.delete': 'Eliminar notificació',
|
||||
'alerts.deleteConfirm': 'Eliminar la notificació de',
|
||||
'alerts.cancel': 'Cancel·lar',
|
||||
'alerts.confirmDelete': 'Eliminar',
|
||||
'alerts.deleteError': 'No s\'ha pogut eliminar',
|
||||
'alerts.loadError': 'No s\'han pogut carregar les notificacions',
|
||||
'alerts.pharmacy': 'Farmàcia',
|
||||
'alerts.anyPharmacy': 'Qualsevol farmàcia',
|
||||
|
||||
// Profile
|
||||
'profile.title': 'Perfil',
|
||||
'profile.loginTitle': 'Iniciar Sessió',
|
||||
'profile.loginDescription': 'Inicieu sessió per accedir al vostre perfil, notificacions i més',
|
||||
'profile.loginBtn': 'Iniciar Sessió',
|
||||
'profile.personalData': 'Dades personals',
|
||||
'profile.firstName': 'Nom',
|
||||
'profile.lastName': 'Cognoms',
|
||||
'profile.appearance': 'Aparença',
|
||||
'profile.config': 'Configuració',
|
||||
'profile.myAddresses': 'Les Meves Adreces',
|
||||
'profile.admin': 'Panell Admin',
|
||||
'profile.recentSearches': 'Cerques recents',
|
||||
'profile.logout': 'Tancar Sessió',
|
||||
'profile.logoutConfirm': 'Esteu segur que voleu tancar la sessió?',
|
||||
'profile.logoutTitle': 'Tancar Sessió',
|
||||
'profile.logoutCancel': 'Cancel·lar',
|
||||
'profile.logoutConfirmBtn': 'Tancar Sessió',
|
||||
'profile.changeAvatar': 'Canviar Avatar',
|
||||
'profile.presetAvatar': 'Predissenyat',
|
||||
'profile.colors': 'Colors',
|
||||
'profile.upload': 'Pujar',
|
||||
'profile.takePhoto': 'Fer foto',
|
||||
'profile.takePhotoDesc': 'Useu la càmera del vostre dispositiu',
|
||||
'profile.chooseGallery': 'Trieu de galeria',
|
||||
'profile.chooseGalleryDesc': 'Seleccioneu una imatge existent',
|
||||
'profile.configTitle': 'Configuració',
|
||||
'profile.firstNameLabel': 'Nom',
|
||||
'profile.firstNamePlaceholder': 'El vostre nom',
|
||||
'profile.lastNameLabel': 'Cognoms',
|
||||
'profile.lastNamePlaceholder': 'Els vostres cognoms',
|
||||
'profile.email': 'Correu electrònic',
|
||||
'profile.city': 'Ciutat',
|
||||
'profile.cityPlaceholder': 'La vostra ciutat',
|
||||
'profile.address': 'Adreça',
|
||||
'profile.addressPlaceholder': 'Carrer Major 1, Barcelona',
|
||||
'profile.cancel': 'Cancel·lar',
|
||||
'profile.save': 'Desar',
|
||||
'profile.profileSaved': 'Perfil desat.',
|
||||
'profile.saveError': 'Error en desar',
|
||||
'profile.addressRequired': 'L\'adreça és obligatòria',
|
||||
'profile.addressesTitle': 'Les Meves Adreces',
|
||||
'profile.addressLabel': 'Adreça',
|
||||
'profile.addressOptional': 'Etiqueta (opcional)',
|
||||
'profile.addressLabelPlaceholder': 'Casa, Treball...',
|
||||
'profile.defaultAddress': 'Adreça predeterminada',
|
||||
'profile.update': 'Actualitzar',
|
||||
'profile.add': 'Afegir',
|
||||
'profile.mainAddress': 'Principal',
|
||||
'profile.setDefault': 'Establir com a predeterminada',
|
||||
'profile.addAddress': 'Afegir adreça',
|
||||
'profile.cameraPermission': 'Permís requerit',
|
||||
'profile.cameraPermissionDesc': 'Necessitem permís per accedir a la càmera.',
|
||||
'profile.themeSystem': 'Sistema',
|
||||
'profile.themeLight': 'Clar',
|
||||
'profile.themeDark': 'Fosc',
|
||||
'profile.language': 'Idioma',
|
||||
'profile.languageTitle': 'Idioma de l\'aplicació',
|
||||
'profile.languageCatalan': 'Català',
|
||||
'profile.languageSpanish': 'Castellà',
|
||||
|
||||
// Medicine detail
|
||||
'medicine.loading': 'Carregant medicament...',
|
||||
'medicine.notFound': 'Medicament no trobat',
|
||||
'medicine.activeIngredient': 'Principi actiu',
|
||||
'medicine.laboratory': 'Laboratori',
|
||||
'medicine.form': 'Forma farmacèutica',
|
||||
'medicine.dosage': 'Dosificació',
|
||||
'medicine.price': 'Preu',
|
||||
'medicine.notAvailable': 'No disponible',
|
||||
'medicine.registration': 'Registre',
|
||||
'medicine.mapSoon': 'Mapa properament…',
|
||||
'medicine.pharmacies': 'Farmàcies',
|
||||
'medicine.locating': 'Localitzant…',
|
||||
'medicine.distanceReset': 'Distància · Restablir',
|
||||
'medicine.sortByDistance': 'Ordenar per distància',
|
||||
'medicine.retry': 'Tornar a provar',
|
||||
'medicine.noPharmacies': 'No hi ha farmàcies disponibles',
|
||||
'medicine.checkPrice': 'Consultar preu',
|
||||
'medicine.howToGet': 'Com arribar',
|
||||
'medicine.locationDenied': 'Permís d\'ubicació denegat',
|
||||
'medicine.locationError': 'No s\'ha pogut obtenir la vostra ubicació',
|
||||
|
||||
// Pharmacy detail
|
||||
'pharmacy.loading': 'Carregant farmàcia...',
|
||||
'pharmacy.notFound': 'Farmàcia no trobada',
|
||||
'pharmacy.call': 'Trucar',
|
||||
'pharmacy.howToGet': 'Com arribar',
|
||||
'pharmacy.medications': 'Medicaments',
|
||||
'pharmacy.noMedications': 'No hi ha medicaments disponibles',
|
||||
'pharmacy.reg': 'Reg: ',
|
||||
'pharmacy.stock': 'Estoc: ',
|
||||
'pharmacy.notificationsActive': 'Notificacions actives',
|
||||
'pharmacy.notifyWhenAvailable': 'Notificar-me quan hi hagi estoc',
|
||||
|
||||
// Product detail
|
||||
'product.loading': 'Carregant producte...',
|
||||
'product.notFound': 'Producte no trobat',
|
||||
'product.noImage': 'Sense imatge',
|
||||
'product.parapharmacy': 'Parafarmàcia',
|
||||
'product.cimaDetails': 'Detalls CIMA',
|
||||
'product.activeIngredient': 'Principi actiu',
|
||||
'product.dosage': 'Dosificació',
|
||||
'product.form': 'Forma farmacèutica',
|
||||
'product.dispensation': 'Tipus de dispensació',
|
||||
'product.commercialized': 'Comercialitzat',
|
||||
'product.yes': 'Sí',
|
||||
'product.no': 'No',
|
||||
'product.productInfo': 'Informació del producte',
|
||||
'product.price': 'Preu',
|
||||
'product.previousPrice': 'Preu anterior',
|
||||
'product.category': 'Categoria',
|
||||
'product.brand': 'Marca',
|
||||
'product.source': 'Font',
|
||||
'product.images': 'Imatges',
|
||||
|
||||
// Components
|
||||
'medicineCard.noPrice': 'Sense preu',
|
||||
'stockBadge.outOfStock': 'Sense estoc',
|
||||
'stockBadge.lowStock': 'Baix',
|
||||
'stockBadge.inStock': 'Disponible',
|
||||
'loadingSpinner.loading': 'Carregant...',
|
||||
'barcodeScanner.permissionRequired': 'Permís de càmera requerit',
|
||||
'barcodeScanner.permissionDesc': 'Necessitem accés a la càmera per escanejar codis de barres',
|
||||
'barcodeScanner.grantPermission': 'Concedir permís',
|
||||
'barcodeScanner.cancel': 'Cancel·lar',
|
||||
'barcodeScanner.scanningHint': 'Apunteu la càmera al codi de barres del medicament',
|
||||
'barcodeScanner.scanAgain': 'Escanejar de nou',
|
||||
};
|
||||
|
||||
export default ca;
|
||||
@@ -0,0 +1,228 @@
|
||||
const es = {
|
||||
// Tab bar
|
||||
'nav.home': 'Inicio',
|
||||
'nav.search': 'Buscar',
|
||||
'nav.scan': 'Escanear',
|
||||
'nav.alerts': 'Avisos',
|
||||
'nav.profile': 'Perfil',
|
||||
|
||||
// Stack screens
|
||||
'stack.medicine': 'Medicamento',
|
||||
'stack.pharmacy': 'Farmacia',
|
||||
'stack.scanner': 'Escanear',
|
||||
'stack.login': 'Iniciar Sesión',
|
||||
'stack.register': 'Registrarse',
|
||||
|
||||
// Login / Register
|
||||
'login.tab.login': 'Iniciar Sesión',
|
||||
'login.tab.register': 'Crear Cuenta',
|
||||
'login.title.login': 'Bienvenido de nuevo',
|
||||
'login.title.register': 'Crea tu cuenta',
|
||||
'login.subtitle.login': 'Inicia sesión para gestionar tu perfil y notificaciones.',
|
||||
'login.subtitle.register': 'Guarda tu dirección y recibe notificaciones cuando lleguen medicamentos.',
|
||||
'login.username': 'Usuario',
|
||||
'login.password': 'Contraseña',
|
||||
'login.confirmPassword': 'Confirmar contraseña',
|
||||
'login.hint.username': '3-32 caracteres para el usuario',
|
||||
'login.hint.password': 'Mínimo 8 caracteres para la contraseña',
|
||||
'login.biometric': 'Iniciar con biometría',
|
||||
'login.link.toLogin': '¿Ya tienes cuenta? Inicia sesión',
|
||||
'login.link.toRegister': '¿No tienes cuenta? Regístrate',
|
||||
'login.alert.error': 'Error',
|
||||
'login.alert.fillFields': 'Por favor completa todos los campos',
|
||||
'login.alert.passwordLength': 'La contraseña debe tener al menos 8 caracteres',
|
||||
'login.alert.passwordMismatch': 'Las contraseñas no coinciden',
|
||||
'login.alert.success': 'Éxito',
|
||||
'login.alert.accountCreated': 'Cuenta creada correctamente',
|
||||
'login.alert.ok': 'OK',
|
||||
'login.alert.createError': 'No se pudo crear la cuenta',
|
||||
'login.alert.wrongCredentials': 'Credenciales incorrectas',
|
||||
'login.alert.noBiometrics': 'No hay credenciales biométricas guardadas',
|
||||
'login.alert.biometricFailed': 'Autenticación biométrica fallida',
|
||||
'login.alert.biometricError': 'Error en la autenticación biométrica',
|
||||
|
||||
// Home
|
||||
'home.description': 'Encuentra tus medicamentos en farmacias cercanas',
|
||||
'home.searchMedicine': 'Buscar Medicamento',
|
||||
'home.scanTSI': 'Escanear TSI',
|
||||
|
||||
// Search
|
||||
'search.placeholder': 'Buscar medicamentos...',
|
||||
'search.suggestions': 'Sugerencias',
|
||||
'search.recentSearches': 'Búsquedas recientes',
|
||||
'search.loading': 'Buscando...',
|
||||
'search.noResults': 'No se encontraron resultados',
|
||||
'search.resultsFound': 'resultados encontrados',
|
||||
'search.parapharmacy': 'Parafarmacia',
|
||||
'search.error': 'Error al buscar medicamentos',
|
||||
|
||||
// Scanner
|
||||
'scanner.title': 'Escanear TSI',
|
||||
'scanner.description': 'Escanea el código de barras de tu tarjeta sanitaria para encontrar tus medicamentos',
|
||||
'scanner.startScan': 'Iniciar escaneo',
|
||||
'scanner.or': 'o',
|
||||
'scanner.uploadPhoto': 'Subir foto del dispositivo',
|
||||
'scanner.uploadDescription': 'Toma o selecciona una foto del dispositivo sanitario',
|
||||
'scanner.takePhoto': 'Tomar foto',
|
||||
'scanner.gallery': 'Galería',
|
||||
'scanner.photoSelected': 'Foto seleccionada correctamente',
|
||||
'scanner.manualEntry': 'Introducir número manualmente',
|
||||
'scanner.manualDescription': 'Escribe el número de tu tarjeta sanitaria',
|
||||
'scanner.manualPlaceholder': 'Introduce el número de tarjeta',
|
||||
'scanner.cameraPermission': 'Permiso de cámara requerido',
|
||||
'scanner.cameraPermissionDesc': 'Necesitamos acceso a la cámara para tomar fotos del dispositivo.',
|
||||
'scanner.galleryPermission': 'Permiso requerido',
|
||||
'scanner.galleryPermissionDesc': 'Necesitamos acceso a la galería para seleccionar fotos.',
|
||||
'scanner.fieldRequired': 'Campo requerido',
|
||||
'scanner.fieldRequiredDesc': 'Por favor, introduce el número de la tarjeta.',
|
||||
|
||||
// Alerts
|
||||
'alerts.title': 'Notificaciones Guardadas',
|
||||
'alerts.description': 'Recibe avisos cuando medicamentos sin stock se repongan',
|
||||
'alerts.loading': 'Cargando notificaciones...',
|
||||
'alerts.loginRequired': 'Inicia sesión para continuar',
|
||||
'alerts.loginDescription': 'Necesitas estar autenticado para ver tus notificaciones',
|
||||
'alerts.loginBtn': 'Iniciar Sesión',
|
||||
'alerts.empty': 'Sin notificaciones',
|
||||
'alerts.emptyDescription': 'Toca la campana en una farmacia sin stock para recibir notificaciones cuando se reponga.',
|
||||
'alerts.retry': 'Reintentar',
|
||||
'alerts.delete': 'Eliminar notificación',
|
||||
'alerts.deleteConfirm': '¿Eliminar la notificación de',
|
||||
'alerts.cancel': 'Cancelar',
|
||||
'alerts.confirmDelete': 'Eliminar',
|
||||
'alerts.deleteError': 'No se pudo eliminar',
|
||||
'alerts.loadError': 'No se pudieron cargar las notificaciones',
|
||||
'alerts.pharmacy': 'Farmacia',
|
||||
'alerts.anyPharmacy': 'Cualquier farmacia',
|
||||
|
||||
// Profile
|
||||
'profile.title': 'Perfil',
|
||||
'profile.loginTitle': 'Inicia Sesión',
|
||||
'profile.loginDescription': 'Inicia sesión para acceder a tu perfil, notificaciones y más',
|
||||
'profile.loginBtn': 'Iniciar Sesión',
|
||||
'profile.personalData': 'Datos personales',
|
||||
'profile.firstName': 'Nombre',
|
||||
'profile.lastName': 'Apellidos',
|
||||
'profile.appearance': 'Apariencia',
|
||||
'profile.config': 'Configuración',
|
||||
'profile.myAddresses': 'Mis Direcciones',
|
||||
'profile.admin': 'Panel Admin',
|
||||
'profile.recentSearches': 'Búsquedas recientes',
|
||||
'profile.logout': 'Cerrar Sesión',
|
||||
'profile.logoutConfirm': '¿Estás seguro que deseas cerrar sesión?',
|
||||
'profile.logoutTitle': 'Cerrar Sesión',
|
||||
'profile.logoutCancel': 'Cancelar',
|
||||
'profile.logoutConfirmBtn': 'Cerrar Sesión',
|
||||
'profile.changeAvatar': 'Cambiar Avatar',
|
||||
'profile.presetAvatar': 'Prediseñado',
|
||||
'profile.colors': 'Colores',
|
||||
'profile.upload': 'Subir',
|
||||
'profile.takePhoto': 'Tomar foto',
|
||||
'profile.takePhotoDesc': 'Usa la cámara de tu dispositivo',
|
||||
'profile.chooseGallery': 'Elegir de galería',
|
||||
'profile.chooseGalleryDesc': 'Selecciona una imagen existente',
|
||||
'profile.configTitle': 'Configuración',
|
||||
'profile.firstNameLabel': 'Nombre',
|
||||
'profile.firstNamePlaceholder': 'Tu nombre',
|
||||
'profile.lastNameLabel': 'Apellidos',
|
||||
'profile.lastNamePlaceholder': 'Tus apellidos',
|
||||
'profile.email': 'Correo electrónico',
|
||||
'profile.city': 'Ciudad',
|
||||
'profile.cityPlaceholder': 'Tu ciudad',
|
||||
'profile.address': 'Dirección',
|
||||
'profile.addressPlaceholder': 'Calle Mayor 1, Madrid',
|
||||
'profile.cancel': 'Cancelar',
|
||||
'profile.save': 'Guardar',
|
||||
'profile.profileSaved': 'Perfil guardado.',
|
||||
'profile.saveError': 'Error al guardar',
|
||||
'profile.addressRequired': 'La dirección es obligatoria',
|
||||
'profile.addressesTitle': 'Mis Direcciones',
|
||||
'profile.addressLabel': 'Dirección',
|
||||
'profile.addressOptional': 'Etiqueta (opcional)',
|
||||
'profile.addressLabelPlaceholder': 'Casa, Trabajo...',
|
||||
'profile.defaultAddress': 'Dirección predeterminada',
|
||||
'profile.update': 'Actualizar',
|
||||
'profile.add': 'Añadir',
|
||||
'profile.mainAddress': 'Principal',
|
||||
'profile.setDefault': 'Marcar como predeterminada',
|
||||
'profile.addAddress': 'Añadir dirección',
|
||||
'profile.cameraPermission': 'Permiso requerido',
|
||||
'profile.cameraPermissionDesc': 'Necesitamos permiso para acceder a la cámara.',
|
||||
'profile.themeSystem': 'Sistema',
|
||||
'profile.themeLight': 'Claro',
|
||||
'profile.themeDark': 'Oscuro',
|
||||
'profile.language': 'Idioma',
|
||||
'profile.languageTitle': 'Idioma de la aplicación',
|
||||
'profile.languageCatalan': 'Català',
|
||||
'profile.languageSpanish': 'Castellano',
|
||||
|
||||
// Medicine detail
|
||||
'medicine.loading': 'Cargando medicamento...',
|
||||
'medicine.notFound': 'Medicamento no encontrado',
|
||||
'medicine.activeIngredient': 'Principio activo',
|
||||
'medicine.laboratory': 'Laboratorio',
|
||||
'medicine.form': 'Forma farmacéutica',
|
||||
'medicine.dosage': 'Dosificación',
|
||||
'medicine.price': 'Precio',
|
||||
'medicine.notAvailable': 'No disponible',
|
||||
'medicine.registration': 'Registro',
|
||||
'medicine.mapSoon': 'Mapa próximamente…',
|
||||
'medicine.pharmacies': 'Farmacias',
|
||||
'medicine.locating': 'Localizando…',
|
||||
'medicine.distanceReset': 'Distancia · Reset',
|
||||
'medicine.sortByDistance': 'Ordenar por distancia',
|
||||
'medicine.retry': 'Reintentar',
|
||||
'medicine.noPharmacies': 'No hay farmacias disponibles',
|
||||
'medicine.checkPrice': 'Consultar precio',
|
||||
'medicine.howToGet': 'Cómo llegar',
|
||||
'medicine.locationDenied': 'Permiso de ubicación denegado',
|
||||
'medicine.locationError': 'No se pudo obtener tu ubicación',
|
||||
|
||||
// Pharmacy detail
|
||||
'pharmacy.loading': 'Cargando farmacia...',
|
||||
'pharmacy.notFound': 'Farmacia no encontrada',
|
||||
'pharmacy.call': 'Llamar',
|
||||
'pharmacy.howToGet': 'Cómo llegar',
|
||||
'pharmacy.medications': 'Medicamentos',
|
||||
'pharmacy.noMedications': 'No hay medicamentos disponibles',
|
||||
'pharmacy.reg': 'Reg: ',
|
||||
'pharmacy.stock': 'Stock: ',
|
||||
'pharmacy.notificationsActive': 'Notificaciones activas',
|
||||
'pharmacy.notifyWhenAvailable': 'Notificarme cuando haya stock',
|
||||
|
||||
// Product detail
|
||||
'product.loading': 'Cargando producto...',
|
||||
'product.notFound': 'Producto no encontrado',
|
||||
'product.noImage': 'Sin imagen',
|
||||
'product.parapharmacy': 'Parafarmacia',
|
||||
'product.cimaDetails': 'Detalles CIMA',
|
||||
'product.activeIngredient': 'Principio activo',
|
||||
'product.dosage': 'Dosificación',
|
||||
'product.form': 'Forma farmacéutica',
|
||||
'product.dispensation': 'Tipo de dispensación',
|
||||
'product.commercialized': 'Comercializado',
|
||||
'product.yes': 'Sí',
|
||||
'product.no': 'No',
|
||||
'product.productInfo': 'Información del producto',
|
||||
'product.price': 'Precio',
|
||||
'product.previousPrice': 'Precio anterior',
|
||||
'product.category': 'Categoría',
|
||||
'product.brand': 'Marca',
|
||||
'product.source': 'Fuente',
|
||||
'product.images': 'Imágenes',
|
||||
|
||||
// Components
|
||||
'medicineCard.noPrice': 'Sin precio',
|
||||
'stockBadge.outOfStock': 'Sin stock',
|
||||
'stockBadge.lowStock': 'Bajo',
|
||||
'stockBadge.inStock': 'Disponible',
|
||||
'loadingSpinner.loading': 'Cargando...',
|
||||
'barcodeScanner.permissionRequired': 'Permiso de cámara requerido',
|
||||
'barcodeScanner.permissionDesc': 'Necesitamos acceso a la cámara para escanear códigos de barras',
|
||||
'barcodeScanner.grantPermission': 'Conceder permiso',
|
||||
'barcodeScanner.cancel': 'Cancelar',
|
||||
'barcodeScanner.scanningHint': 'Apunta la cámara al código de barras del medicamento',
|
||||
'barcodeScanner.scanAgain': 'Escanear de nuevo',
|
||||
};
|
||||
|
||||
export default es;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useContext } from 'react';
|
||||
import { LanguageContext } from './LanguageContext';
|
||||
|
||||
export function useTranslation() {
|
||||
const ctx = useContext(LanguageContext);
|
||||
if (!ctx) throw new Error('useTranslation must be used within LanguageProvider');
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user