feat: add Expo Push notifications for mobile and configure EXPO_ACCESS_TOKEN
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

- 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:
Antoni Nuñez Romeu
2026-07-13 12:21:25 +02:00
parent a00cfe949c
commit 0c43d32cf1
6 changed files with 436 additions and 51 deletions
+51 -1
View File
@@ -4,6 +4,8 @@ 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';
@@ -40,6 +42,7 @@ 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);
@@ -47,6 +50,8 @@ export default function MedicineDetailScreen() {
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;
@@ -96,6 +101,24 @@ export default function MedicineDetailScreen() {
}
};
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;
@@ -148,7 +171,22 @@ export default function MedicineDetailScreen() {
<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 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 }]}>
@@ -277,12 +315,24 @@ const styles = StyleSheet.create({
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,
+76 -17
View File
@@ -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',