Restructure with Turborepo
Run Tests on Branches / Backend Tests (push) Successful in 3m38s
Run Tests on Branches / Frontend Tests (push) Successful in 3m28s

This commit is contained in:
Antoni Nuñez Romeu
2026-07-06 15:51:53 +02:00
parent f66cafbbc3
commit 190b3d163d
277 changed files with 53253 additions and 0 deletions
@@ -0,0 +1,41 @@
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: '#8E8E93',
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Buscar',
tabBarIcon: ({ color, size }) => (
<Ionicons name="search" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="map"
options={{
title: 'Mapa',
tabBarIcon: ({ color, size }) => (
<Ionicons name="map" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Perfil',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useEffect } from 'react';
import { View, FlatList, StyleSheet, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { SearchBar } from '../../components/SearchBar';
import { MedicineCard } from '../../components/MedicineCard';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import { useDebounce } from '../../hooks/useDebounce';
import { searchMedicines } from '../../services/medicines';
import { colors, spacing, borderRadius } from '../../constants/theme';
import { Medicine } from '../../types';
import { config } from '../../constants/config';
export default function HomeScreen() {
const router = useRouter();
const [query, setQuery] = useState('');
const [results, setResults] = useState<Medicine[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const debouncedQuery = useDebounce(query, config.SEARCH_DEBOUNCE_MS);
useEffect(() => {
if (debouncedQuery.length < 2) {
setResults([]);
return;
}
const fetchResults = async () => {
setIsLoading(true);
setError(null);
try {
const data = await searchMedicines(debouncedQuery);
setResults(data);
} catch (err) {
setError('Error al buscar medicamentos');
console.error(err);
} finally {
setIsLoading(false);
}
};
fetchResults();
}, [debouncedQuery]);
const handleSearch = (searchQuery: string) => {
setQuery(searchQuery);
};
return (
<View style={styles.container}>
<View style={styles.searchContainer}>
<SearchBar
onSearch={handleSearch}
value={query}
onChangeText={setQuery}
/>
<TouchableOpacity
style={styles.scannerButton}
onPress={() => router.push('/scanner')}
>
<Ionicons name="scan" size={24} color={colors.primary} />
</TouchableOpacity>
</View>
{isLoading && <LoadingSpinner message="Buscando medicamentos..." />}
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
{!isLoading && !error && results.length === 0 && query.length >= 2 && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No se encontraron medicamentos</Text>
</View>
)}
<FlatList
data={results}
keyExtractor={(item) => item.nregistro}
renderItem={({ item }) => <MedicineCard medicine={item} />}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
},
scannerButton: {
marginRight: spacing.md,
padding: spacing.sm,
backgroundColor: colors.card,
borderRadius: borderRadius.md,
},
list: {
paddingBottom: spacing.xl,
},
errorContainer: {
padding: spacing.md,
marginHorizontal: spacing.md,
backgroundColor: '#F8D7DA',
borderRadius: 8,
},
errorText: {
color: '#721C24',
textAlign: 'center',
},
emptyContainer: {
padding: spacing.xl,
alignItems: 'center',
},
emptyText: {
color: colors.textSecondary,
fontSize: 16,
},
});
+108
View File
@@ -0,0 +1,108 @@
import React, { useEffect, useState } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import MapView, { Marker } from 'react-native-maps';
import { useRouter } from 'expo-router';
import { getPharmacies } from '../../services/pharmacies';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import { colors, spacing } from '../../constants/theme';
import { Pharmacy } from '../../types';
export default function MapScreen() {
const router = useRouter();
const [pharmacies, setPharmacies] = useState<Pharmacy[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [region, setRegion] = useState({
latitude: 40.4168, // Madrid default
longitude: -3.7038,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
});
useEffect(() => {
const fetchPharmacies = async () => {
try {
const data = await getPharmacies();
setPharmacies(data);
// Center map on first pharmacy if available
if (data.length > 0) {
setRegion({
latitude: data[0].latitude,
longitude: data[0].longitude,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
});
}
} catch (error) {
console.error('Error fetching pharmacies:', error);
} finally {
setIsLoading(false);
}
};
fetchPharmacies();
}, []);
if (isLoading) {
return <LoadingSpinner message="Cargando farmacias..." />;
}
return (
<View style={styles.container}>
<MapView
style={styles.map}
region={region}
onRegionChangeComplete={setRegion}
showsUserLocation={true}
showsMyLocationButton={true}
>
{pharmacies.map((pharmacy) => (
<Marker
key={pharmacy.id}
coordinate={{
latitude: pharmacy.latitude,
longitude: pharmacy.longitude,
}}
title={pharmacy.name}
description={pharmacy.address}
onCalloutPress={() => router.push(`/pharmacy/${pharmacy.id}`)}
/>
))}
</MapView>
<View style={styles.legend}>
<Text style={styles.legendText}>
{pharmacies.length} farmacias en el mapa
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
map: {
flex: 1,
},
legend: {
position: 'absolute',
bottom: spacing.lg,
left: spacing.md,
right: spacing.md,
backgroundColor: colors.card,
borderRadius: 8,
padding: spacing.sm,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
legendText: {
fontSize: 14,
color: colors.text,
},
});
+219
View File
@@ -0,0 +1,219 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../../hooks/useAuth';
import { colors, spacing, borderRadius } from '../../constants/theme';
export default function ProfileScreen() {
const router = useRouter();
const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth();
const handleLogout = async () => {
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');
}
},
]
);
};
if (isLoading) {
return (
<View style={styles.container}>
<Text style={styles.loadingText}>Cargando...</Text>
</View>
);
}
if (!isAuthenticated) {
return (
<View style={styles.container}>
<View style={styles.authPrompt}>
<Ionicons name="person-outline" size={64} color={colors.textSecondary} />
<Text style={styles.authTitle}>Inicia Sesión</Text>
<Text style={styles.authSubtitle}>
Inicia sesión para acceder a todas las funcionalidades
</Text>
<TouchableOpacity
style={styles.button}
onPress={() => router.push('/auth/login')}
>
<Text style={styles.buttonText}>Iniciar Sesión</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.push('/auth/register')}
>
<Text style={styles.linkText}>Crear cuenta nueva</Text>
</TouchableOpacity>
</View>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.avatar}>
<Ionicons name="person" size={40} color={colors.primary} />
</View>
<Text style={styles.username}>{user?.username}</Text>
{isAdmin && <Text style={styles.adminBadge}>Administrador</Text>}
</View>
<View style={styles.menuSection}>
{isAdmin && (
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="settings" size={24} color={colors.text} />
<Text style={styles.menuText}>Panel Admin</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
)}
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="heart" size={24} color={colors.text} />
<Text style={styles.menuText}>Favoritos</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="notifications" size={24} color={colors.text} />
<Text style={styles.menuText}>Notificaciones</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity style={styles.menuItem}>
<Ionicons name="help-circle" size={24} color={colors.text} />
<Text style={styles.menuText}>Ayuda</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Ionicons name="log-out" size={20} color={colors.danger} />
<Text style={styles.logoutText}>Cerrar Sesión</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
loadingText: {
textAlign: 'center',
marginTop: spacing.xxl,
color: colors.textSecondary,
},
authPrompt: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
authTitle: {
fontSize: 24,
fontWeight: 'bold',
color: colors.text,
marginTop: spacing.lg,
},
authSubtitle: {
fontSize: 16,
color: colors.textSecondary,
textAlign: 'center',
marginTop: spacing.sm,
marginBottom: spacing.xl,
},
header: {
alignItems: 'center',
padding: spacing.xl,
backgroundColor: colors.card,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
username: {
fontSize: 20,
fontWeight: 'bold',
color: colors.text,
marginTop: spacing.md,
},
adminBadge: {
fontSize: 12,
color: colors.primary,
backgroundColor: '#E3F2FD',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.sm,
marginTop: spacing.sm,
},
menuSection: {
marginTop: spacing.md,
backgroundColor: colors.card,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.separator,
},
menuText: {
flex: 1,
marginLeft: spacing.md,
fontSize: 16,
color: colors.text,
},
button: {
backgroundColor: colors.primary,
borderRadius: borderRadius.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
},
buttonText: {
color: colors.textInverse,
fontSize: 16,
fontWeight: '600',
},
linkButton: {
marginTop: spacing.md,
},
linkText: {
color: colors.primary,
fontSize: 14,
},
logoutButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
margin: spacing.xl,
padding: spacing.md,
backgroundColor: colors.card,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.danger,
},
logoutText: {
marginLeft: spacing.sm,
color: colors.danger,
fontSize: 16,
fontWeight: '500',
},
});
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useAuthStore } from '../store/authStore';
import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications';
const queryClient = new QueryClient();
export default function RootLayout() {
const { checkAuth } = useAuthStore();
const notificationListener = useRef<ReturnType<typeof addNotificationListener>>();
const responseListener = useRef<ReturnType<typeof addNotificationResponseListener>>();
useEffect(() => {
checkAuth();
registerForPushNotifications();
notificationListener.current = addNotificationListener((notification) => {
console.log('Notification received:', notification);
});
responseListener.current = addNotificationResponseListener((response) => {
console.log('Notification clicked:', response);
});
return () => {
notificationListener.current?.remove();
responseListener.current?.remove();
};
}, []);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="medicine/[id]"
options={{
title: 'Medicamento',
headerTintColor: '#007AFF',
}}
/>
<Stack.Screen
name="pharmacy/[id]"
options={{
title: 'Farmacia',
headerTintColor: '#007AFF',
}}
/>
<Stack.Screen
name="auth/login"
options={{
title: 'Iniciar Sesión',
headerShown: false,
}}
/>
<Stack.Screen
name="auth/register"
options={{
title: 'Registrarse',
headerShown: false,
}}
/>
</Stack>
<StatusBar style="auto" />
</QueryClientProvider>
</GestureHandlerRootView>
);
}
+237
View File
@@ -0,0 +1,237 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../../store/authStore';
import { colors, spacing, borderRadius } from '../../constants/theme';
import {
isBiometricsAvailable,
authenticateWithBiometrics,
saveBiometricCredentials,
getBiometricUsername
} from '../../services/biometrics';
export default function LoginScreen() {
const router = useRouter();
const { login } = useAuthStore();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
const [biometricUsername, setBiometricUsername] = useState<string | null>(null);
useEffect(() => {
checkBiometrics();
}, []);
const checkBiometrics = async () => {
try {
const available = await isBiometricsAvailable();
setBiometricsAvailable(available);
if (available) {
const savedUsername = await getBiometricUsername();
setBiometricUsername(savedUsername);
}
} catch (error) {
console.error('Error checking biometrics:', error);
}
};
const handleLogin = async () => {
if (!username || !password) {
Alert.alert('Error', 'Por favor ingresa usuario y contraseña');
return;
}
setIsLoading(true);
try {
await login(username, password);
// Save username for biometric login
if (biometricsAvailable) {
await saveBiometricCredentials(username);
}
router.replace('/(tabs)');
} catch (error) {
Alert.alert('Error', 'Credenciales incorrectas');
} finally {
setIsLoading(false);
}
};
const handleBiometricLogin = async () => {
if (!biometricUsername) {
Alert.alert('Error', 'No hay credenciales biométricas guardadas');
return;
}
setIsLoading(true);
try {
const authenticated = await authenticateWithBiometrics();
if (authenticated) {
// Use saved username with empty password for biometric login
// The backend should handle biometric authentication differently
await login(biometricUsername, '');
router.replace('/(tabs)');
} else {
Alert.alert('Error', 'Autenticación biométrica fallida');
}
} catch (error) {
Alert.alert('Error', 'Error en la autenticación biométrica');
} finally {
setIsLoading(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.form}>
<Text style={styles.title}>Iniciar Sesión</Text>
<Text style={styles.subtitle}>Ingresa tus credenciales para continuar</Text>
<View style={styles.inputContainer}>
<Text style={styles.label}>Usuario</Text>
<TextInput
style={styles.input}
value={username}
onChangeText={setUsername}
placeholder="Tu usuario"
placeholderTextColor={colors.textSecondary}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Contraseña</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="Tu contraseña"
placeholderTextColor={colors.textSecondary}
secureTextEntry
/>
</View>
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? 'Ingresando...' : 'Iniciar Sesión'}
</Text>
</TouchableOpacity>
{biometricsAvailable && biometricUsername && (
<TouchableOpacity
style={[styles.biometricButton, isLoading && styles.buttonDisabled]}
onPress={handleBiometricLogin}
disabled={isLoading}
>
<Ionicons name="finger-print" size={24} color={colors.primary} />
<Text style={styles.biometricText}>Iniciar con biometría</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.push('/auth/register')}
>
<Text style={styles.linkText}>¿No tienes cuenta? Regístrate</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
form: {
flex: 1,
justifyContent: 'center',
padding: spacing.xl,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: colors.text,
marginBottom: spacing.sm,
},
subtitle: {
fontSize: 16,
color: colors.textSecondary,
marginBottom: spacing.xl,
},
inputContainer: {
marginBottom: spacing.md,
},
label: {
fontSize: 14,
fontWeight: '500',
color: colors.text,
marginBottom: spacing.xs,
},
input: {
backgroundColor: colors.card,
borderRadius: borderRadius.md,
padding: spacing.md,
fontSize: 16,
color: colors.text,
},
button: {
backgroundColor: colors.primary,
borderRadius: borderRadius.md,
padding: spacing.md,
alignItems: 'center',
marginTop: spacing.md,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: colors.textInverse,
fontSize: 16,
fontWeight: '600',
},
linkButton: {
marginTop: spacing.lg,
alignItems: 'center',
},
linkText: {
color: colors.primary,
fontSize: 14,
},
biometricButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.card,
borderRadius: borderRadius.md,
padding: spacing.md,
marginTop: spacing.md,
borderWidth: 1,
borderColor: colors.primary,
},
biometricText: {
color: colors.primary,
fontSize: 16,
fontWeight: '600',
marginLeft: spacing.sm,
},
});
+172
View File
@@ -0,0 +1,172 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert
} from 'react-native';
import { useRouter } from 'expo-router';
import { register } from '../../services/auth';
import { colors, spacing, borderRadius } from '../../constants/theme';
export default function RegisterScreen() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleRegister = async () => {
if (!username || !password || !confirmPassword) {
Alert.alert('Error', 'Por favor completa todos los campos');
return;
}
if (password !== confirmPassword) {
Alert.alert('Error', 'Las contraseñas no coinciden');
return;
}
setIsLoading(true);
try {
await register(username, password);
router.replace('/(tabs)');
} catch (error) {
Alert.alert('Error', 'No se pudo crear la cuenta');
} finally {
setIsLoading(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.form}>
<Text style={styles.title}>Crear Cuenta</Text>
<Text style={styles.subtitle}>Regístrate para empezar</Text>
<View style={styles.inputContainer}>
<Text style={styles.label}>Usuario</Text>
<TextInput
style={styles.input}
value={username}
onChangeText={setUsername}
placeholder="Elige un usuario"
placeholderTextColor={colors.textSecondary}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Contraseña</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="Elige una contraseña"
placeholderTextColor={colors.textSecondary}
secureTextEntry
/>
</View>
<View style={styles.inputContainer}>
<Text style={styles.label}>Confirmar Contraseña</Text>
<TextInput
style={styles.input}
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="Repite la contraseña"
placeholderTextColor={colors.textSecondary}
secureTextEntry
/>
</View>
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? 'Creando cuenta...' : 'Crear Cuenta'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.back()}
>
<Text style={styles.linkText}>¿Ya tienes cuenta? Inicia sesión</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
form: {
flex: 1,
justifyContent: 'center',
padding: spacing.xl,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: colors.text,
marginBottom: spacing.sm,
},
subtitle: {
fontSize: 16,
color: colors.textSecondary,
marginBottom: spacing.xl,
},
inputContainer: {
marginBottom: spacing.md,
},
label: {
fontSize: 14,
fontWeight: '500',
color: colors.text,
marginBottom: spacing.xs,
},
input: {
backgroundColor: colors.card,
borderRadius: borderRadius.md,
padding: spacing.md,
fontSize: 16,
color: colors.text,
},
button: {
backgroundColor: colors.primary,
borderRadius: borderRadius.md,
padding: spacing.md,
alignItems: 'center',
marginTop: spacing.md,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: colors.textInverse,
fontSize: 16,
fontWeight: '600',
},
linkButton: {
marginTop: spacing.lg,
alignItems: 'center',
},
linkText: {
color: colors.primary,
fontSize: 14,
},
});
+205
View File
@@ -0,0 +1,205 @@
import React, { useEffect, useState } from 'react';
import { View, Text, ScrollView, StyleSheet, TouchableOpacity } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { getMedicine, getMedicinePharmacies } from '../../services/medicines';
import { StockBadge } from '../../components/StockBadge';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import { colors, spacing, borderRadius } from '../../constants/theme';
import { Medicine, PharmacyMedicine } from '../../types';
export default function MedicineDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [medicine, setMedicine] = useState<Medicine | null>(null);
const [pharmacies, setPharmacies] = useState<PharmacyMedicine[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!id) return;
const fetchMedicine = async () => {
try {
const [medData, pharmData] = await Promise.all([
getMedicine(id),
getMedicinePharmacies(id),
]);
setMedicine(medData);
setPharmacies(pharmData);
} catch (error) {
console.error('Error fetching medicine:', error);
} finally {
setIsLoading(false);
}
};
fetchMedicine();
}, [id]);
if (isLoading) {
return <LoadingSpinner message="Cargando medicamento..." />;
}
if (!medicine) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Medicamento no encontrado</Text>
</View>
);
}
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Text style={styles.name}>{medicine.nombre}</Text>
<StockBadge stock={medicine.stock} />
</View>
<View style={styles.infoSection}>
<InfoRow label="Principio activo" value={medicine.principioActivo} />
<InfoRow label="Laboratorio" value={medicine.laboratorio} />
<InfoRow label="Forma farmacéutica" value={medicine.formaFarmaceutica} />
<InfoRow
label="Precio"
value={medicine.precio ? `${medicine.precio.toFixed(2)}` : 'No disponible'}
/>
<InfoRow label="Registro" value={medicine.nregistro} />
</View>
<View style={styles.pharmaciesSection}>
<Text style={styles.sectionTitle}>
Farmacias ({pharmacies.length})
</Text>
{pharmacies.length === 0 ? (
<Text style={styles.noPharmacies}>No hay farmacias con este medicamento</Text>
) : (
pharmacies.map((pharm) => (
<TouchableOpacity
key={pharm.id}
style={styles.pharmacyCard}
onPress={() => router.push(`/pharmacy/${pharm.pharmacy_id}`)}
>
<View style={styles.pharmacyInfo}>
<Text style={styles.pharmacyName}>{pharm.pharmacy?.name}</Text>
<Text style={styles.pharmacyAddress}>{pharm.pharmacy?.address}</Text>
</View>
<View style={styles.pharmacyStock}>
<Text style={styles.price}>{pharm.price.toFixed(2)} </Text>
<Text style={styles.stock}>Stock: {pharm.stock}</Text>
</View>
</TouchableOpacity>
))
)}
</View>
</ScrollView>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={styles.infoValue}>{value}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
padding: spacing.md,
backgroundColor: colors.card,
},
name: {
flex: 1,
fontSize: 22,
fontWeight: 'bold',
color: colors.text,
marginRight: spacing.sm,
},
infoSection: {
backgroundColor: colors.card,
marginTop: spacing.sm,
padding: spacing.md,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.separator,
},
infoLabel: {
fontSize: 14,
color: colors.textSecondary,
},
infoValue: {
fontSize: 14,
color: colors.text,
fontWeight: '500',
},
pharmaciesSection: {
marginTop: spacing.sm,
padding: spacing.md,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.text,
marginBottom: spacing.md,
},
noPharmacies: {
color: colors.textSecondary,
textAlign: 'center',
padding: spacing.xl,
},
pharmacyCard: {
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: colors.card,
borderRadius: borderRadius.md,
padding: spacing.md,
marginBottom: spacing.sm,
},
pharmacyInfo: {
flex: 1,
},
pharmacyName: {
fontSize: 16,
fontWeight: '600',
color: colors.text,
},
pharmacyAddress: {
fontSize: 14,
color: colors.textSecondary,
marginTop: spacing.xs,
},
pharmacyStock: {
alignItems: 'flex-end',
},
price: {
fontSize: 16,
fontWeight: '600',
color: colors.primary,
},
stock: {
fontSize: 12,
color: colors.textSecondary,
marginTop: spacing.xs,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: colors.textSecondary,
},
});
+260
View File
@@ -0,0 +1,260 @@
import React, { useEffect, useState } from 'react';
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Linking } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import MapView, { Marker } from 'react-native-maps';
import { getPharmacy, getPharmacyMedicines } from '../../services/pharmacies';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import { colors, spacing, borderRadius } from '../../constants/theme';
import { Pharmacy, PharmacyMedicine } from '../../types';
export default function PharmacyDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [pharmacy, setPharmacy] = useState<Pharmacy | null>(null);
const [medicines, setMedicines] = useState<PharmacyMedicine[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!id) return;
const fetchPharmacy = async () => {
try {
const [pharmData, medData] = await Promise.all([
getPharmacy(Number(id)),
getPharmacyMedicines(Number(id)),
]);
setPharmacy(pharmData);
setMedicines(medData as PharmacyMedicine[]);
} catch (error) {
console.error('Error fetching pharmacy:', error);
} finally {
setIsLoading(false);
}
};
fetchPharmacy();
}, [id]);
const handleCall = () => {
if (pharmacy?.phone) {
Linking.openURL(`tel:${pharmacy.phone}`);
}
};
const handleDirections = () => {
if (pharmacy) {
const url = `https://www.google.com/maps/dir/?api=1&destination=${pharmacy.latitude},${pharmacy.longitude}`;
Linking.openURL(url);
}
};
if (isLoading) {
return <LoadingSpinner message="Cargando farmacia..." />;
}
if (!pharmacy) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Farmacia no encontrada</Text>
</View>
);
}
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Text style={styles.name}>{pharmacy.name}</Text>
</View>
<View style={styles.actionsRow}>
<TouchableOpacity style={styles.actionButton} onPress={handleCall}>
<Ionicons name="call" size={20} color={colors.primary} />
<Text style={styles.actionText}>Llamar</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton} onPress={handleDirections}>
<Ionicons name="navigate" size={20} color={colors.primary} />
<Text style={styles.actionText}>Cómo llegar</Text>
</TouchableOpacity>
</View>
<View style={styles.infoSection}>
<View style={styles.infoRow}>
<Ionicons name="location" size={18} color={colors.textSecondary} />
<Text style={styles.infoText}>{pharmacy.address}</Text>
</View>
{pharmacy.phone && (
<View style={styles.infoRow}>
<Ionicons name="call" size={18} color={colors.textSecondary} />
<Text style={styles.infoText}>{pharmacy.phone}</Text>
</View>
)}
</View>
<View style={styles.mapContainer}>
<MapView
style={styles.map}
initialRegion={{
latitude: pharmacy.latitude,
longitude: pharmacy.longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
scrollEnabled={false}
>
<Marker
coordinate={{
latitude: pharmacy.latitude,
longitude: pharmacy.longitude,
}}
title={pharmacy.name}
/>
</MapView>
</View>
<View style={styles.medicinesSection}>
<Text style={styles.sectionTitle}>
Medicamentos ({medicines.length})
</Text>
{medicines.length === 0 ? (
<Text style={styles.noMedicines}>No hay medicamentos disponibles</Text>
) : (
medicines.map((med) => (
<TouchableOpacity
key={med.id}
style={styles.medicineCard}
onPress={() => router.push(`/medicine/${med.medicine_nregistro}`)}
>
<View style={styles.medicineInfo}>
<Text style={styles.medicineName}>{med.medicine_name}</Text>
<Text style={styles.medicineNregistro}>Reg: {med.medicine_nregistro}</Text>
</View>
<View style={styles.medicineStock}>
<Text style={styles.price}>{med.price.toFixed(2)} </Text>
<Text style={styles.stock}>Stock: {med.stock}</Text>
</View>
</TouchableOpacity>
))
)}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
padding: spacing.md,
backgroundColor: colors.card,
},
name: {
fontSize: 22,
fontWeight: 'bold',
color: colors.text,
},
actionsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
padding: spacing.md,
backgroundColor: colors.card,
borderBottomWidth: 1,
borderBottomColor: colors.separator,
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.sm,
},
actionText: {
marginLeft: spacing.xs,
color: colors.primary,
fontWeight: '500',
},
infoSection: {
padding: spacing.md,
backgroundColor: colors.card,
marginTop: spacing.sm,
},
infoRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: spacing.sm,
},
infoText: {
flex: 1,
marginLeft: spacing.sm,
fontSize: 16,
color: colors.text,
},
mapContainer: {
height: 200,
marginTop: spacing.sm,
},
map: {
flex: 1,
},
medicinesSection: {
marginTop: spacing.sm,
padding: spacing.md,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.text,
marginBottom: spacing.md,
},
noMedicines: {
color: colors.textSecondary,
textAlign: 'center',
padding: spacing.xl,
},
medicineCard: {
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: colors.card,
borderRadius: borderRadius.md,
padding: spacing.md,
marginBottom: spacing.sm,
},
medicineInfo: {
flex: 1,
},
medicineName: {
fontSize: 16,
fontWeight: '500',
color: colors.text,
},
medicineNregistro: {
fontSize: 12,
color: colors.textSecondary,
marginTop: spacing.xs,
},
medicineStock: {
alignItems: 'flex-end',
},
price: {
fontSize: 16,
fontWeight: '600',
color: colors.primary,
},
stock: {
fontSize: 12,
color: colors.textSecondary,
marginTop: spacing.xs,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: colors.textSecondary,
},
});
+46
View File
@@ -0,0 +1,46 @@
import React, { useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { BarcodeScanner } from '../components/BarcodeScanner';
import { searchMedicines } from '../services/medicines';
export default function ScannerScreen() {
const router = useRouter();
const [isSearching, setIsSearching] = useState(false);
const handleBarcodeScanned = async (barcode: string) => {
setIsSearching(true);
try {
const results = await searchMedicines(barcode);
if (results.length > 0) {
router.push(`/medicine/${results[0].nregistro}`);
} else {
router.push(`/medicine/${barcode}`);
}
} catch (error) {
console.error('Error searching medicine:', error);
router.push(`/medicine/${barcode}`);
} finally {
setIsSearching(false);
}
};
const handleClose = () => {
router.back();
};
return (
<View style={styles.container}>
<BarcodeScanner
onBarcodeScanned={handleBarcodeScanned}
onClose={handleClose}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});