Mobile App design
This commit is contained in:
@@ -1,19 +1,52 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 { colors, spacing, borderRadius } from '../../constants/theme';
|
||||
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;
|
||||
@@ -36,6 +69,64 @@ export default function MedicineDetailScreen() {
|
||||
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);
|
||||
@@ -47,76 +138,131 @@ export default function MedicineDetailScreen() {
|
||||
|
||||
if (!medicine) {
|
||||
return (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Medicamento no encontrado</Text>
|
||||
<View style={[styles.errorContainer, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.errorText, { color: colors.textSecondary }]}>Medicamento no encontrado</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.name}>{medicine.name}</Text>
|
||||
<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}>
|
||||
<InfoRow label="Principio activo" value={medicine.active_ingredient} />
|
||||
<InfoRow label="Laboratorio" value={medicine.laboratory} />
|
||||
<InfoRow label="Forma farmacéutica" value={medicine.form} />
|
||||
<InfoRow label="Dosificación" value={medicine.dosage} />
|
||||
<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} />
|
||||
<InfoRow label="Registro" value={medicine.nregistro} colors={colors} />
|
||||
</View>
|
||||
|
||||
<View style={styles.pharmaciesSection}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Farmacias ({pharmacies.length})
|
||||
</Text>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{pharmacies.length === 0 ? (
|
||||
<Text style={styles.noPharmacies}>No hay farmacias con este medicamento</Text>
|
||||
<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>
|
||||
) : (
|
||||
pharmacies.map((pharm) => (
|
||||
<View key={pharm.id} style={styles.pharmacyCard}>
|
||||
<TouchableOpacity
|
||||
style={styles.pharmacyCardContent}
|
||||
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>
|
||||
{pharm.pharmacy?.latitude != null && pharm.pharmacy?.longitude != null && (
|
||||
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.directionsButton}
|
||||
onPress={() => handleDirections(pharm.pharmacy.latitude, pharm.pharmacy.longitude)}
|
||||
style={styles.pharmacyCardContent}
|
||||
onPress={() => router.push(`/pharmacy/${pharm.pharmacy_id || pharm.id}`)}
|
||||
>
|
||||
<Ionicons name="navigate" size={16} color={colors.primary} />
|
||||
<Text style={styles.directionsText}>Cómo llegar</Text>
|
||||
<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>
|
||||
)}
|
||||
</View>
|
||||
))
|
||||
{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 }: { label: string; value: string }) {
|
||||
function InfoRow({ label, value, colors }: { label: string; value: string; colors: any }) {
|
||||
return (
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>{label}</Text>
|
||||
<Text style={styles.infoValue}>{value}</Text>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -124,24 +270,20 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
padding: spacing.lg,
|
||||
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.lg,
|
||||
},
|
||||
@@ -150,34 +292,82 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 14,
|
||||
color: colors.textSecondary,
|
||||
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,
|
||||
color: colors.text,
|
||||
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',
|
||||
color: colors.text,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
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: {
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
pharmacyCard: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginBottom: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
@@ -195,14 +385,27 @@ const styles = StyleSheet.create({
|
||||
pharmacyInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
pharmacyNameRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
pharmacyName: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
pharmacyDistance: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.full,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
pharmacyAddress: {
|
||||
fontSize: 14,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
pharmacyStock: {
|
||||
@@ -211,11 +414,9 @@ const styles = StyleSheet.create({
|
||||
price: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.primary,
|
||||
},
|
||||
stock: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
directionsButton: {
|
||||
@@ -225,10 +426,8 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
directionsText: {
|
||||
color: colors.primary,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
@@ -239,6 +438,5 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user