diff --git a/apps/frontend-mobile/app/(tabs)/_layout.tsx b/apps/frontend-mobile/app/(tabs)/_layout.tsx index f95d92c..fc4334c 100644 --- a/apps/frontend-mobile/app/(tabs)/_layout.tsx +++ b/apps/frontend-mobile/app/(tabs)/_layout.tsx @@ -1,14 +1,40 @@ import { Tabs } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; +import { View, StyleSheet, Platform } from 'react-native'; +import { colors, shadows } from '../../constants/theme'; + +function ScanIcon({ color, size }: { color: string; size: number }) { + return ( + + + + + + ); +} export default function TabLayout() { return ( + ( + + ), + }} + /> + , + tabBarLabel: () => null, + }} + /> + ( + + ), + }} + /> ( - - ), + href: null, }} /> ); } + +const styles = StyleSheet.create({ + tabBar: { + backgroundColor: colors.card, + borderTopColor: colors.border, + height: Platform.OS === 'ios' ? 88 : 64, + paddingBottom: Platform.OS === 'ios' ? 24 : 8, + paddingTop: 8, + }, + tabBarLabel: { + fontSize: 11, + fontWeight: '500', + }, + header: { + backgroundColor: colors.card, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 1, + }, + headerTitle: { + color: colors.text, + fontWeight: '600', + fontSize: 17, + }, + fabContainer: { + position: 'absolute', + top: -16, + alignItems: 'center', + }, + fab: { + width: 52, + height: 52, + borderRadius: 26, + backgroundColor: colors.scanButton, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/apps/frontend-mobile/app/(tabs)/alerts.tsx b/apps/frontend-mobile/app/(tabs)/alerts.tsx new file mode 100644 index 0000000..0b9b7ea --- /dev/null +++ b/apps/frontend-mobile/app/(tabs)/alerts.tsx @@ -0,0 +1,284 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { colors, spacing, borderRadius, shadows } from '../../constants/theme'; +import { LoadingSpinner } from '../../components/LoadingSpinner'; + +interface NotificationItem { + scope: string; + id: number; + medicine_name?: string; + medicine_nregistro?: string; + pharmacy_name?: string; + pharmacy_id?: number; + pharmacy_address?: string; + created_at?: string; +} + +export default function AlertsScreen() { + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + useEffect(() => { + loadNotifications(); + }, []); + + async function loadNotifications() { + setIsLoading(true); + setError(null); + try { + const res = await fetch('/api/notifications/mine', { credentials: 'include' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const merged = [ + ...(data.pharmacy || []), + ...(data.global || []), + ].sort((a: NotificationItem, b: NotificationItem) => + (b.created_at || '').localeCompare(a.created_at || '') + ); + setItems(merged); + } catch (err: any) { + setError(err.message || 'No se pudieron cargar las notificaciones'); + } finally { + setIsLoading(false); + } + } + + async function handleDelete(item: NotificationItem) { + const key = `${item.scope}:${item.id}`; + Alert.alert( + 'Eliminar notificación', + `¿Eliminar la notificación de ${item.medicine_name || item.medicine_nregistro}?`, + [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Eliminar', + style: 'destructive', + onPress: async () => { + setDeletingId(key); + try { + const res = await fetch('/api/notifications/mine', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ scope: item.scope, id: item.id }), + }); + if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`); + setItems(prev => prev.filter(i => !(i.scope === item.scope && i.id === item.id))); + } catch (err: any) { + Alert.alert('Error', err.message || 'No se pudo eliminar'); + } finally { + setDeletingId(null); + } + }, + }, + ] + ); + } + + function renderItem({ item }: { item: NotificationItem }) { + const key = `${item.scope}:${item.id}`; + return ( + + + + {item.medicine_name || item.medicine_nregistro} + + + + + + {item.scope === 'pharmacy' + ? item.pharmacy_name || `Farmacia #${item.pharmacy_id}` + : 'Cualquier farmacia'} + + + {item.pharmacy_address && ( + + {item.pharmacy_address} + + )} + + + handleDelete(item)} + disabled={deletingId === key} + > + {deletingId === key ? ( + + ) : ( + + )} + + + ); + } + + if (isLoading) { + return ; + } + + return ( + + + Notificaciones Guardadas + + Recibe avisos cuando medicamentos sin stock se repongan + + + + {error && ( + + {error} + + Reintentar + + + )} + + {!error && items.length === 0 && ( + + + Sin notificaciones + + Toca la campana en una farmacia sin stock para recibir notificaciones cuando se reponga. + + + )} + + {!error && items.length > 0 && ( + `${item.scope}:${item.id}`} + renderItem={renderItem} + contentContainerStyle={styles.list} + showsVerticalScrollIndicator={false} + /> + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.lg, + paddingBottom: spacing.md, + }, + title: { + fontSize: 22, + fontWeight: 'bold', + color: colors.text, + }, + subtitle: { + fontSize: 14, + color: colors.textSecondary, + marginTop: spacing.xs, + lineHeight: 20, + }, + list: { + paddingHorizontal: spacing.lg, + paddingBottom: spacing.xl, + gap: spacing.sm, + }, + item: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.card, + borderRadius: borderRadius.lg, + padding: spacing.md, + ...shadows.card, + }, + itemContent: { + flex: 1, + gap: spacing.xs, + }, + itemName: { + fontSize: 16, + fontWeight: '600', + color: colors.text, + lineHeight: 22, + }, + itemMeta: { + gap: spacing.xs, + }, + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + backgroundColor: colors.primaryContainer, + borderRadius: borderRadius.full, + paddingHorizontal: spacing.sm, + paddingVertical: 3, + alignSelf: 'flex-start', + }, + chipText: { + fontSize: 12, + fontWeight: '600', + color: colors.onPrimaryContainer, + }, + itemAddress: { + fontSize: 12, + color: colors.textSecondary, + }, + deleteButton: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: colors.dangerContainer, + alignItems: 'center', + justifyContent: 'center', + marginLeft: spacing.sm, + }, + errorContainer: { + margin: spacing.lg, + padding: spacing.md, + backgroundColor: colors.dangerContainer, + borderRadius: borderRadius.lg, + alignItems: 'center', + gap: spacing.sm, + }, + errorText: { + fontSize: 14, + color: colors.danger, + textAlign: 'center', + }, + retryButton: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + }, + retryText: { + fontSize: 14, + fontWeight: '600', + color: colors.primary, + }, + emptyContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.xl, + gap: spacing.md, + }, + emptyTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.text, + }, + emptyText: { + fontSize: 14, + color: colors.textSecondary, + textAlign: 'center', + lineHeight: 20, + }, +}); diff --git a/apps/frontend-mobile/app/(tabs)/home.tsx b/apps/frontend-mobile/app/(tabs)/home.tsx new file mode 100644 index 0000000..d9f0c97 --- /dev/null +++ b/apps/frontend-mobile/app/(tabs)/home.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Image } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import { colors, spacing, borderRadius, shadows } from '../../constants/theme'; + +export default function HomeScreen() { + const router = useRouter(); + + return ( + + + + FarmaClic + + Encuentra tus medicamentos en farmacias cercanas + + + + + router.push('/(tabs)')} + > + + + + + Buscar Medicamento + + + + + router.push('/scanner')} + > + + + + + Escanear TSI + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.lg, + gap: spacing.lg, + }, + hero: { + alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.md, + }, + logo: { + width: 120, + height: 120, + marginBottom: spacing.xs, + }, + brandName: { + fontSize: 28, + fontWeight: 'bold', + color: colors.text, + letterSpacing: -0.5, + }, + description: { + fontSize: 16, + color: colors.textSecondary, + textAlign: 'center', + maxWidth: 260, + lineHeight: 24, + }, + cards: { + width: '100%', + maxWidth: 320, + gap: spacing.md, + }, + card: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + backgroundColor: colors.primaryContainer, + borderRadius: borderRadius.lg, + padding: spacing.md, + minHeight: 64, + ...shadows.card, + }, + cardScan: { + backgroundColor: colors.scanButton, + }, + cardIcon: { + width: 48, + height: 48, + borderRadius: borderRadius.md, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + cardIconSearch: { + backgroundColor: 'rgba(255, 255, 255, 0.3)', + }, + cardIconScan: { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + }, + cardContent: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + cardLabel: { + fontSize: 18, + fontWeight: '700', + color: colors.onPrimaryContainer, + lineHeight: 24, + }, + cardLabelScan: { + color: '#ffffff', + }, +}); diff --git a/apps/frontend-mobile/app/(tabs)/index.tsx b/apps/frontend-mobile/app/(tabs)/index.tsx index 0db711b..ebe19f3 100644 --- a/apps/frontend-mobile/app/(tabs)/index.tsx +++ b/apps/frontend-mobile/app/(tabs)/index.tsx @@ -55,11 +55,11 @@ export default function HomeScreen() { value={query} onChangeText={setQuery} /> - router.push('/scanner')} > - + @@ -96,25 +96,34 @@ const styles = StyleSheet.create({ searchContainer: { flexDirection: 'row', alignItems: 'center', + paddingRight: spacing.lg, }, scannerButton: { - marginRight: spacing.md, - padding: spacing.sm, - backgroundColor: colors.card, - borderRadius: borderRadius.md, + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: colors.scanButton, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#2b5bb5', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 6, }, list: { paddingBottom: spacing.xl, }, errorContainer: { padding: spacing.md, - marginHorizontal: spacing.md, - backgroundColor: '#F8D7DA', - borderRadius: 8, + marginHorizontal: spacing.lg, + backgroundColor: colors.dangerContainer, + borderRadius: borderRadius.lg, }, errorText: { - color: '#721C24', + color: colors.danger, textAlign: 'center', + fontSize: 14, }, emptyContainer: { padding: spacing.xl, diff --git a/apps/frontend-mobile/app/(tabs)/map.tsx b/apps/frontend-mobile/app/(tabs)/map.tsx index e663741..33c49ae 100644 --- a/apps/frontend-mobile/app/(tabs)/map.tsx +++ b/apps/frontend-mobile/app/(tabs)/map.tsx @@ -92,14 +92,14 @@ const styles = StyleSheet.create({ left: spacing.md, right: spacing.md, backgroundColor: colors.card, - borderRadius: 8, - padding: spacing.sm, + borderRadius: borderRadius.lg, + padding: spacing.sm + 4, alignItems: 'center', shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.25, - shadowRadius: 4, - elevation: 5, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.08, + shadowRadius: 20, + elevation: 4, }, legendText: { fontSize: 14, diff --git a/apps/frontend-mobile/app/(tabs)/profile.tsx b/apps/frontend-mobile/app/(tabs)/profile.tsx index afb33ae..5d6c3c6 100644 --- a/apps/frontend-mobile/app/(tabs)/profile.tsx +++ b/apps/frontend-mobile/app/(tabs)/profile.tsx @@ -830,7 +830,7 @@ const styles = StyleSheet.create({ width: 100, height: 100, borderRadius: 50, - backgroundColor: colors.background, + backgroundColor: colors.primaryContainer, justifyContent: 'center', alignItems: 'center', overflow: 'hidden', @@ -846,7 +846,7 @@ const styles = StyleSheet.create({ left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.5)', + backgroundColor: 'rgba(0, 0, 0, 0.4)', justifyContent: 'center', alignItems: 'center', opacity: 0.8, @@ -857,17 +857,17 @@ const styles = StyleSheet.create({ fontSize: 14, }, infoSection: { - padding: spacing.xl, + padding: spacing.lg, backgroundColor: colors.card, - marginTop: spacing.md, + marginTop: spacing.sm, gap: spacing.md, }, infoCard: { - backgroundColor: colors.background, + backgroundColor: colors.surfaceLow, padding: spacing.md, - borderRadius: borderRadius.md, + borderRadius: borderRadius.lg, borderWidth: 1, - borderColor: colors.border || '#E0E0E0', + borderColor: colors.border, }, infoLabel: { fontSize: 12, @@ -883,7 +883,7 @@ const styles = StyleSheet.create({ color: colors.text, }, menuSection: { - marginTop: spacing.md, + marginTop: spacing.sm, backgroundColor: colors.card, }, menuItem: { @@ -891,7 +891,7 @@ const styles = StyleSheet.create({ alignItems: 'center', padding: spacing.md, borderBottomWidth: 1, - borderBottomColor: colors.separator, + borderBottomColor: colors.border, }, menuText: { flex: 1, @@ -901,9 +901,9 @@ const styles = StyleSheet.create({ }, searchHistorySection: { padding: spacing.md, - backgroundColor: colors.background, + backgroundColor: colors.surfaceLow, borderBottomWidth: 1, - borderBottomColor: colors.separator, + borderBottomColor: colors.border, }, searchHistoryTitle: { fontSize: 14, @@ -916,7 +916,7 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', paddingVertical: spacing.sm, borderBottomWidth: 1, - borderBottomColor: colors.separator, + borderBottomColor: colors.border, }, searchAddress: { flex: 1, @@ -928,7 +928,7 @@ const styles = StyleSheet.create({ }, button: { backgroundColor: colors.primary, - borderRadius: borderRadius.md, + borderRadius: borderRadius.lg, paddingVertical: spacing.md, paddingHorizontal: spacing.xl, }, @@ -951,7 +951,7 @@ const styles = StyleSheet.create({ margin: spacing.xl, padding: spacing.md, backgroundColor: colors.card, - borderRadius: borderRadius.md, + borderRadius: borderRadius.lg, borderWidth: 1, borderColor: colors.danger, }, @@ -979,7 +979,7 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', padding: spacing.lg, borderBottomWidth: 1, - borderBottomColor: colors.separator, + borderBottomColor: colors.border, }, modalTitle: { fontSize: 18, @@ -1000,26 +1000,26 @@ const styles = StyleSheet.create({ }, modalInput: { borderWidth: 1, - borderColor: colors.border || '#E0E0E0', - borderRadius: borderRadius.md, + borderColor: colors.border, + borderRadius: borderRadius.lg, padding: spacing.md, fontSize: 16, color: colors.text, }, modalFeedback: { padding: spacing.md, - borderRadius: borderRadius.md, + borderRadius: borderRadius.lg, marginBottom: spacing.md, }, modalFeedbackOk: { - backgroundColor: 'rgba(0, 69, 13, 0.1)', + backgroundColor: '#eaf7ec', borderWidth: 1, - borderColor: 'rgba(0, 69, 13, 0.2)', + borderColor: '#cfead0', }, modalFeedbackErr: { - backgroundColor: 'rgba(186, 26, 26, 0.1)', + backgroundColor: colors.dangerContainer, borderWidth: 1, - borderColor: 'rgba(186, 26, 26, 0.2)', + borderColor: '#fecaca', }, modalFeedbackText: { fontSize: 14, @@ -1034,9 +1034,9 @@ const styles = StyleSheet.create({ modalCancelBtn: { paddingVertical: spacing.md, paddingHorizontal: spacing.lg, - borderRadius: borderRadius.md, + borderRadius: borderRadius.lg, borderWidth: 1, - borderColor: colors.border || '#E0E0E0', + borderColor: colors.border, }, modalCancelText: { fontSize: 16, @@ -1046,7 +1046,7 @@ const styles = StyleSheet.create({ backgroundColor: colors.primary, paddingVertical: spacing.md, paddingHorizontal: spacing.xl, - borderRadius: borderRadius.md, + borderRadius: borderRadius.lg, minWidth: 100, alignItems: 'center', }, @@ -1068,7 +1068,7 @@ const styles = StyleSheet.create({ tabContainer: { flexDirection: 'row', borderBottomWidth: 1, - borderBottomColor: colors.separator, + borderBottomColor: colors.border, }, tabButton: { flex: 1, @@ -1115,10 +1115,10 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', padding: spacing.md, - backgroundColor: colors.background, - borderRadius: borderRadius.md, + backgroundColor: colors.surfaceLow, + borderRadius: borderRadius.lg, borderWidth: 1, - borderColor: colors.border || '#E5E5EA', + borderColor: colors.border, }, uploadIconContainer: { width: 56, @@ -1155,14 +1155,14 @@ const styles = StyleSheet.create({ alignItems: 'flex-start', justifyContent: 'space-between', padding: spacing.md, - backgroundColor: colors.background, - borderRadius: borderRadius.md, + backgroundColor: colors.surfaceLow, + borderRadius: borderRadius.lg, borderWidth: 1, - borderColor: colors.border || '#E0E0E0', + borderColor: colors.border, }, addressItemDefault: { borderColor: colors.primary, - backgroundColor: 'rgba(0, 69, 13, 0.03)', + backgroundColor: '#eaf7ec', }, addressItemInfo: { flex: 1, @@ -1181,7 +1181,7 @@ const styles = StyleSheet.create({ addressBadge: { alignSelf: 'flex-start', backgroundColor: colors.primary, - borderRadius: 12, + borderRadius: borderRadius.full, paddingHorizontal: spacing.sm, paddingVertical: 2, marginTop: 4, @@ -1219,9 +1219,9 @@ const styles = StyleSheet.create({ padding: spacing.md, marginTop: spacing.md, borderWidth: 2, - borderColor: colors.border || '#E0E0E0', + borderColor: colors.border, borderStyle: 'dashed', - borderRadius: borderRadius.md, + borderRadius: borderRadius.lg, }, addressAddBtnText: { fontSize: 15, diff --git a/apps/frontend-mobile/app/(tabs)/scan.tsx b/apps/frontend-mobile/app/(tabs)/scan.tsx new file mode 100644 index 0000000..831aced --- /dev/null +++ b/apps/frontend-mobile/app/(tabs)/scan.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import { colors, spacing, borderRadius, shadows } from '../../constants/theme'; + +export default function ScanTabScreen() { + const router = useRouter(); + + return ( + + + + + + Escanear TSI + + Escanea el código de barras de tu tarjeta sanitaria para encontrar tus medicamentos + + router.push('/scanner')} + > + + Iniciar escaneo + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + alignItems: 'center', + justifyContent: 'center', + }, + content: { + alignItems: 'center', + paddingHorizontal: spacing.xl, + gap: spacing.md, + }, + iconContainer: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: colors.primaryContainer, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.sm, + }, + title: { + fontSize: 22, + fontWeight: 'bold', + color: colors.text, + }, + description: { + fontSize: 15, + color: colors.textSecondary, + textAlign: 'center', + lineHeight: 22, + maxWidth: 280, + }, + button: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.scanButton, + borderRadius: borderRadius.lg, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + marginTop: spacing.md, + }, + buttonText: { + fontSize: 17, + fontWeight: '600', + color: '#ffffff', + }, +}); diff --git a/apps/frontend-mobile/app/_layout.tsx b/apps/frontend-mobile/app/_layout.tsx index b8039f4..77f4f42 100644 --- a/apps/frontend-mobile/app/_layout.tsx +++ b/apps/frontend-mobile/app/_layout.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { useAuthStore } from '../store/authStore'; import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications'; +import { colors } from '../constants/theme'; const queryClient = new QueryClient(); @@ -35,20 +36,27 @@ export default function RootLayout() { return ( - + +