443 lines
14 KiB
TypeScript
443 lines
14 KiB
TypeScript
import React, { useEffect, useState, useMemo } 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 * as Location from 'expo-location';
|
|
import { getMedicine, getMedicinePharmacies } from '../../services/medicines';
|
|
import { StockBadge } from '../../components/StockBadge';
|
|
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
|
import { useThemeContext } from '../../components/ThemeProvider';
|
|
import { spacing, borderRadius } from '../../constants/theme';
|
|
import { Medicine, PharmacyMedicine } from '../../types';
|
|
|
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
|
const R = 6371;
|
|
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
|
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
|
const a =
|
|
Math.sin(dLat / 2) ** 2 +
|
|
Math.cos((lat1 * Math.PI) / 180) *
|
|
Math.cos((lat2 * Math.PI) / 180) *
|
|
Math.sin(dLon / 2) ** 2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
function formatDistance(km: number): string {
|
|
if (km < 1) return `${Math.round(km * 1000)} m`;
|
|
if (km < 10) return `${km.toFixed(1)} km`;
|
|
return `${Math.round(km)} km`;
|
|
}
|
|
|
|
function getPharmacyLat(p: PharmacyMedicine): number | null {
|
|
return p.latitude ?? p.pharmacy?.latitude ?? null;
|
|
}
|
|
|
|
function getPharmacyLon(p: PharmacyMedicine): number | null {
|
|
return p.longitude ?? p.pharmacy?.longitude ?? null;
|
|
}
|
|
|
|
export default function MedicineDetailScreen() {
|
|
const { id } = useLocalSearchParams<{ id: string }>();
|
|
const router = useRouter();
|
|
const { colors } = useThemeContext();
|
|
const [medicine, setMedicine] = useState<Medicine | null>(null);
|
|
const [pharmacies, setPharmacies] = useState<PharmacyMedicine[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [sortByDistance, setSortByDistance] = useState(false);
|
|
const [userPosition, setUserPosition] = useState<{ lat: number; lon: number } | null>(null);
|
|
const [locating, setLocating] = useState(false);
|
|
const [locationError, setLocationError] = useState<string | null>(null);
|
|
|
|
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]);
|
|
|
|
const handleSortByDistance = async () => {
|
|
if (sortByDistance) {
|
|
setSortByDistance(false);
|
|
return;
|
|
}
|
|
|
|
setLocating(true);
|
|
setLocationError(null);
|
|
|
|
try {
|
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
|
if (status !== 'granted') {
|
|
setLocationError('Permiso de ubicación denegado');
|
|
setLocating(false);
|
|
return;
|
|
}
|
|
|
|
const pos = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
|
|
setUserPosition({ lat: pos.coords.latitude, lon: pos.coords.longitude });
|
|
setSortByDistance(true);
|
|
} catch {
|
|
setLocationError('No se pudo obtener tu ubicación');
|
|
} finally {
|
|
setLocating(false);
|
|
}
|
|
};
|
|
|
|
const sortedPharmacies = useMemo(() => {
|
|
if (!sortByDistance || !userPosition) return pharmacies;
|
|
|
|
return [...pharmacies]
|
|
.map((p) => {
|
|
const lat = getPharmacyLat(p);
|
|
const lon = getPharmacyLon(p);
|
|
const distance =
|
|
lat != null && lon != null
|
|
? haversineKm(userPosition.lat, userPosition.lon, lat, lon)
|
|
: Infinity;
|
|
return { ...p, _distance: distance };
|
|
})
|
|
.sort((a, b) => a._distance - b._distance);
|
|
}, [pharmacies, sortByDistance, userPosition]);
|
|
|
|
const locatedPharmacies = useMemo(
|
|
() =>
|
|
sortedPharmacies.filter(
|
|
(p) => getPharmacyLat(p) != null && getPharmacyLon(p) != null
|
|
),
|
|
[sortedPharmacies]
|
|
);
|
|
|
|
const mapCenter = useMemo(() => {
|
|
if (locatedPharmacies.length === 0) return { latitude: 40.4168, longitude: -3.7038 };
|
|
const lat = locatedPharmacies.reduce((s, p) => s + (getPharmacyLat(p) || 0), 0) / locatedPharmacies.length;
|
|
const lon = locatedPharmacies.reduce((s, p) => s + (getPharmacyLon(p) || 0), 0) / locatedPharmacies.length;
|
|
return { latitude: lat, longitude: lon };
|
|
}, [locatedPharmacies]);
|
|
|
|
const handleDirections = (latitude: number, longitude: number) => {
|
|
const url = `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`;
|
|
Linking.openURL(url);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <LoadingSpinner message="Cargando medicamento..." />;
|
|
}
|
|
|
|
if (!medicine) {
|
|
return (
|
|
<View style={[styles.errorContainer, { backgroundColor: colors.background }]}>
|
|
<Text style={[styles.errorText, { color: colors.textSecondary }]}>Medicamento no encontrado</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
|
|
<View style={[styles.header, { backgroundColor: colors.card }]}>
|
|
<Text style={[styles.name, { color: colors.text }]}>{medicine.name}</Text>
|
|
{medicine.stock != null && <StockBadge stock={medicine.stock} />}
|
|
</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="Precio"
|
|
value={medicine.precio != null ? `${medicine.precio.toFixed(2)} €` : 'No disponible'}
|
|
colors={colors}
|
|
/>
|
|
<InfoRow label="Registro" 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…
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
<View style={styles.pharmaciesSection}>
|
|
<View style={styles.pharmaciesHeader}>
|
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>
|
|
Farmacias ({sortedPharmacies.length})
|
|
</Text>
|
|
<TouchableOpacity
|
|
style={[styles.sortButton, sortByDistance && styles.sortButtonActive, { backgroundColor: sortByDistance ? colors.primary : colors.primaryContainer, borderColor: colors.primary }]}
|
|
onPress={handleSortByDistance}
|
|
disabled={locating}
|
|
>
|
|
<Ionicons name="location" size={16} color={sortByDistance ? '#fff' : colors.primary} />
|
|
<Text style={[styles.sortButtonText, sortByDistance && styles.sortButtonTextActive, { color: sortByDistance ? '#fff' : colors.primary }]}>
|
|
{locating
|
|
? 'Localizando…'
|
|
: sortByDistance
|
|
? 'Distancia · Reset'
|
|
: 'Ordenar por distancia'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{locationError && (
|
|
<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>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
{sortedPharmacies.length === 0 ? (
|
|
<Text style={[styles.noPharmacies, { color: colors.textSecondary }]}>No hay farmacias disponibles</Text>
|
|
) : (
|
|
sortedPharmacies.map((pharm) => {
|
|
const lat = getPharmacyLat(pharm);
|
|
const lon = getPharmacyLon(pharm);
|
|
const distanceKm =
|
|
sortByDistance && userPosition && lat != null && lon != null
|
|
? haversineKm(userPosition.lat, userPosition.lon, lat, lon)
|
|
: null;
|
|
|
|
return (
|
|
<View key={pharm.id} style={[styles.pharmacyCard, { backgroundColor: colors.card }]}>
|
|
<TouchableOpacity
|
|
style={styles.pharmacyCardContent}
|
|
onPress={() => router.push(`/pharmacy/${pharm.pharmacy_id || pharm.id}`)}
|
|
>
|
|
<View style={styles.pharmacyInfo}>
|
|
<View style={styles.pharmacyNameRow}>
|
|
<Text style={[styles.pharmacyName, { color: colors.text }]}>{pharm.pharmacy?.name || pharm.name}</Text>
|
|
{distanceKm != null && (
|
|
<Text style={[styles.pharmacyDistance, { color: colors.primary, backgroundColor: colors.primaryContainer }]}>{formatDistance(distanceKm)}</Text>
|
|
)}
|
|
</View>
|
|
<Text style={[styles.pharmacyAddress, { color: colors.textSecondary }]}>{pharm.pharmacy?.address || pharm.address}</Text>
|
|
</View>
|
|
<View style={styles.pharmacyStock}>
|
|
{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>
|
|
)}
|
|
{pharm.stock > 0 && (
|
|
<Text style={[styles.stock, { color: colors.textSecondary }]}>Stock: {pharm.stock}</Text>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
{lat != null && lon != null && (
|
|
<TouchableOpacity
|
|
style={[styles.directionsButton, { borderTopColor: colors.border }]}
|
|
onPress={() => handleDirections(lat, lon)}
|
|
>
|
|
<Ionicons name="navigate" size={16} color={colors.primary} />
|
|
<Text style={[styles.directionsText, { color: colors.primary }]}>Cómo llegar</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
);
|
|
})
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
function InfoRow({ label, value, colors }: { label: string; value: string; colors: any }) {
|
|
return (
|
|
<View style={[styles.infoRow, { borderBottomColor: colors.border }]}>
|
|
<Text style={[styles.infoLabel, { color: colors.textSecondary }]}>{label}</Text>
|
|
<Text style={[styles.infoValue, { color: colors.text }]}>{value}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'flex-start',
|
|
padding: spacing.lg,
|
|
},
|
|
name: {
|
|
flex: 1,
|
|
fontSize: 22,
|
|
fontWeight: 'bold',
|
|
marginRight: spacing.sm,
|
|
},
|
|
infoSection: {
|
|
marginTop: spacing.sm,
|
|
padding: spacing.lg,
|
|
},
|
|
infoRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
paddingVertical: spacing.sm,
|
|
borderBottomWidth: 1,
|
|
},
|
|
infoLabel: {
|
|
fontSize: 13,
|
|
flexShrink: 0,
|
|
marginRight: spacing.sm,
|
|
},
|
|
infoValue: {
|
|
fontSize: 13,
|
|
fontWeight: '500',
|
|
flex: 1,
|
|
textAlign: 'right',
|
|
},
|
|
mapContainer: {
|
|
marginTop: spacing.sm,
|
|
marginHorizontal: spacing.lg,
|
|
height: 200,
|
|
borderRadius: borderRadius.lg,
|
|
overflow: 'hidden',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: spacing.sm,
|
|
},
|
|
mapPlaceholder: {
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
},
|
|
pharmaciesSection: {
|
|
marginTop: spacing.sm,
|
|
padding: spacing.lg,
|
|
},
|
|
pharmaciesHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: spacing.md,
|
|
},
|
|
sectionTitle: {
|
|
fontSize: 18,
|
|
fontWeight: '600',
|
|
},
|
|
sortButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
paddingHorizontal: spacing.sm + 4,
|
|
paddingVertical: spacing.xs + 2,
|
|
borderRadius: borderRadius.full,
|
|
borderWidth: 1,
|
|
},
|
|
sortButtonActive: {},
|
|
sortButtonText: {
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
},
|
|
sortButtonTextActive: {},
|
|
locationErrorContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
padding: spacing.sm,
|
|
marginBottom: spacing.sm,
|
|
borderRadius: borderRadius.md,
|
|
},
|
|
locationErrorText: {
|
|
flex: 1,
|
|
fontSize: 13,
|
|
},
|
|
retryText: {
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
},
|
|
noPharmacies: {
|
|
textAlign: 'center',
|
|
padding: spacing.xl,
|
|
},
|
|
pharmacyCard: {
|
|
borderRadius: borderRadius.lg,
|
|
marginBottom: spacing.sm,
|
|
overflow: 'hidden',
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.06,
|
|
shadowRadius: 8,
|
|
elevation: 2,
|
|
},
|
|
pharmacyCardContent: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
padding: spacing.md,
|
|
},
|
|
pharmacyInfo: {
|
|
flex: 1,
|
|
},
|
|
pharmacyNameRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
pharmacyName: {
|
|
flex: 1,
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
marginRight: spacing.sm,
|
|
},
|
|
pharmacyDistance: {
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: 2,
|
|
borderRadius: borderRadius.full,
|
|
overflow: 'hidden',
|
|
},
|
|
pharmacyAddress: {
|
|
fontSize: 14,
|
|
marginTop: spacing.xs,
|
|
},
|
|
pharmacyStock: {
|
|
alignItems: 'flex-end',
|
|
},
|
|
price: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
},
|
|
stock: {
|
|
fontSize: 12,
|
|
marginTop: spacing.xs,
|
|
},
|
|
directionsButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: spacing.xs,
|
|
paddingVertical: spacing.sm,
|
|
borderTopWidth: 1,
|
|
},
|
|
directionsText: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
},
|
|
errorContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
errorText: {
|
|
fontSize: 16,
|
|
},
|
|
});
|