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
This commit is contained in:
@@ -4,6 +4,8 @@ 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 { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||
import { useThemeContext } from '../../components/ThemeProvider';
|
||||
import { spacing, borderRadius } from '../../constants/theme';
|
||||
@@ -13,9 +15,11 @@ export default function PharmacyDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const { colors } = useThemeContext();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [pharmacy, setPharmacy] = useState<Pharmacy | null>(null);
|
||||
const [medicines, setMedicines] = useState<PharmacyMedicine[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [subscribedMeds, setSubscribedMeds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -51,6 +55,27 @@ export default function PharmacyDetailScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleMedSubscription = async (nregistro: string, medicineName: string) => {
|
||||
if (!isAuthenticated) return;
|
||||
const key = nregistro;
|
||||
const isSub = subscribedMeds.has(key);
|
||||
try {
|
||||
if (isSub) {
|
||||
await unsubscribeFromMedicine(nregistro, Number(id));
|
||||
setSubscribedMeds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
await subscribeToMedicine(nregistro, medicineName, Number(id));
|
||||
setSubscribedMeds(prev => new Set(prev).add(key));
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message="Cargando farmacia..." />;
|
||||
}
|
||||
@@ -124,22 +149,41 @@ export default function PharmacyDetailScreen() {
|
||||
{medicines.length === 0 ? (
|
||||
<Text style={[styles.noMedicines, { color: colors.textSecondary }]}>No hay medicamentos disponibles</Text>
|
||||
) : (
|
||||
medicines.map((med) => (
|
||||
<TouchableOpacity
|
||||
key={med.id}
|
||||
style={[styles.medicineCard, { backgroundColor: colors.card }]}
|
||||
onPress={() => router.push(`/medicine/${med.medicine_nregistro}`)}
|
||||
>
|
||||
<View style={styles.medicineInfo}>
|
||||
<Text style={[styles.medicineName, { color: colors.text }]}>{med.medicine_name}</Text>
|
||||
<Text style={[styles.medicineNregistro, { color: colors.textSecondary }]}>Reg: {med.medicine_nregistro}</Text>
|
||||
medicines.map((med) => {
|
||||
const isSub = subscribedMeds.has(med.medicine_nregistro);
|
||||
return (
|
||||
<View key={med.id} style={[styles.medicineCard, { backgroundColor: colors.card }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.medicineCardContent}
|
||||
onPress={() => router.push(`/medicine/${med.medicine_nregistro}`)}
|
||||
>
|
||||
<View style={styles.medicineInfo}>
|
||||
<Text style={[styles.medicineName, { color: colors.text }]}>{med.medicine_name}</Text>
|
||||
<Text style={[styles.medicineNregistro, { color: colors.textSecondary }]}>Reg: {med.medicine_nregistro}</Text>
|
||||
</View>
|
||||
<View style={styles.medicineStock}>
|
||||
<Text style={[styles.price, { color: colors.primary }]}>{med.price.toFixed(2)} €</Text>
|
||||
<Text style={[styles.stock, { color: colors.textSecondary }]}>Stock: {med.stock}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{isAuthenticated && (
|
||||
<TouchableOpacity
|
||||
style={[styles.bellButton, { backgroundColor: isSub ? colors.primary : colors.surfaceVariant, borderTopColor: colors.border }]}
|
||||
onPress={() => handleToggleMedSubscription(med.medicine_nregistro, med.medicine_name)}
|
||||
>
|
||||
<Ionicons
|
||||
name={isSub ? 'notifications' : 'notifications-outline'}
|
||||
size={18}
|
||||
color={isSub ? '#fff' : colors.textSecondary}
|
||||
/>
|
||||
<Text style={[styles.bellText, { color: isSub ? '#fff' : colors.textSecondary }]}>
|
||||
{isSub ? 'Notificaciones activas' : 'Notificarme cuando haya stock'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.medicineStock}>
|
||||
<Text style={[styles.price, { color: colors.primary }]}>{med.price.toFixed(2)} €</Text>
|
||||
<Text style={[styles.stock, { color: colors.textSecondary }]}>Stock: {med.stock}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -207,11 +251,14 @@ const styles = StyleSheet.create({
|
||||
padding: spacing.xl,
|
||||
},
|
||||
medicineCard: {
|
||||
borderRadius: borderRadius.md,
|
||||
marginBottom: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
medicineCardContent: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
medicineInfo: {
|
||||
flex: 1,
|
||||
@@ -235,6 +282,18 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
bellButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
bellText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
|
||||
Reference in New Issue
Block a user