Files
Antoni Nuñez Romeu 0c43d32cf1
Run Tests on Branches / Detect Changes (push) Successful in 13s
Run Tests on Branches / Backend Tests (push) Successful in 1m58s
Run Tests on Branches / Frontend Tests (push) Successful in 1m53s
Run Tests on Branches / Frontend Mobile Tests (push) Successful in 1m49s
feat: add Expo Push notifications for mobile and configure EXPO_ACCESS_TOKEN
- Backend: add expo_push_tokens table, expo-register/unregister endpoints, hybrid VAPID+Expo push sender
- Mobile: register Expo push token on app launch, bell toggle on medicine/pharmacy detail screens
- .env.example: document EXPO_ACCESS_TOKEN configuration
- Dark mode hover fixes for search suggestions and recent buttons
2026-07-13 12:21:25 +02:00

493 lines
16 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 { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications';
import { useAuth } from '../../hooks/useAuth';
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 { isAuthenticated } = useAuth();
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);
const [isSubscribed, setIsSubscribed] = useState(false);
const [togglingSub, setTogglingSub] = useState(false);
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 handleToggleSubscription = async () => {
if (!isAuthenticated || !id) return;
setTogglingSub(true);
try {
if (isSubscribed) {
await unsubscribeFromMedicine(id as string);
setIsSubscribed(false);
} else {
await subscribeToMedicine(id as string, medicine?.name || null);
setIsSubscribed(true);
}
} catch {
// silently fail
} finally {
setTogglingSub(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>
<View style={styles.headerRight}>
{medicine.stock != null && <StockBadge stock={medicine.stock} />}
{isAuthenticated && (
<TouchableOpacity
style={[styles.bellButton, { backgroundColor: isSubscribed ? colors.primary : colors.surfaceVariant }]}
onPress={handleToggleSubscription}
disabled={togglingSub}
>
<Ionicons
name={isSubscribed ? 'notifications' : 'notifications-outline'}
size={20}
color={isSubscribed ? '#fff' : colors.textSecondary}
/>
</TouchableOpacity>
)}
</View>
</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,
},
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
name: {
flex: 1,
fontSize: 22,
fontWeight: 'bold',
marginRight: spacing.sm,
},
bellButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
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,
},
});