Files
FarmaFinder/apps/frontend-mobile/app/medicine/[id].tsx
T
Ichitux 795d935413
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
Run Tests on Branches / Detect Changes (push) Successful in 13s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Successful in 1m35s
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Remove Google Maps vs OSM
2026-07-17 16:11:10 +00:00

519 lines
17 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 MapView, { Marker, UrlTile } from 'react-native-maps';
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';
import { useTranslation } from '../../src/i18n';
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 { t } = useTranslation();
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(t('medicine.locationDenied'));
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(t('medicine.locationError'));
} 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={t('medicine.loading')} />;
}
if (!medicine) {
return (
<View style={[styles.errorContainer, { backgroundColor: colors.background }]}>
<Text style={[styles.errorText, { color: colors.textSecondary }]}>{t('medicine.notFound')}</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={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={t('medicine.price')}
value={medicine.precio != null ? `${medicine.precio.toFixed(2)} €` : t('medicine.notAvailable')}
colors={colors}
/>
<InfoRow label={t('medicine.registration')} value={medicine.nregistro} colors={colors} />
</View>
{locatedPharmacies.length > 0 && (
<View style={styles.mapContainer}>
<MapView
style={styles.map}
mapType="none"
initialRegion={{
latitude: mapCenter.latitude,
longitude: mapCenter.longitude,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
}}
>
<UrlTile
urlTemplate="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
maximumZ={19}
flipY={false}
tileSize={256}
/>
{locatedPharmacies.map((pharm) => {
const lat = getPharmacyLat(pharm);
const lon = getPharmacyLon(pharm);
if (lat == null || lon == null) return null;
return (
<Marker
key={pharm.id}
coordinate={{ latitude: lat, longitude: lon }}
title={pharm.pharmacy?.name || pharm.name}
description={pharm.pharmacy?.address || pharm.address}
onCalloutPress={() => router.push(`/pharmacy/${pharm.pharmacy_id || pharm.id}`)}
/>
);
})}
</MapView>
</View>
)}
<View style={styles.pharmaciesSection}>
<View style={styles.pharmaciesHeader}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>
{t('medicine.pharmacies')} ({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
? t('medicine.locating')
: sortByDistance
? t('medicine.distanceReset')
: t('medicine.sortByDistance')}
</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 }]}>{t('medicine.retry')}</Text>
</TouchableOpacity>
</View>
)}
{sortedPharmacies.length === 0 ? (
<Text style={[styles.noPharmacies, { color: colors.textSecondary }]}>{t('medicine.noPharmacies')}</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 }]}>{t('medicine.checkPrice')}</Text>
)}
{pharm.stock > 0 && (
<Text style={[styles.stock, { color: colors.textSecondary }]}>{t('pharmacy.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 }]}>{t('medicine.howToGet')}</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',
},
map: {
flex: 1,
},
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,
},
});