From 2f36ef685d1dea0dbaa6ba9d54540bf2a84e2dd3 Mon Sep 17 00:00:00 2001 From: Ichitux Date: Thu, 9 Jul 2026 13:33:54 +0200 Subject: [PATCH] Mobile App design --- .mimocode/.cron-lock | 2 +- .mimocode/plans/1783593202807-swift-garden.md | 141 +++++ apps/backend/server.js | 21 +- apps/frontend-mobile/app.json | 23 +- apps/frontend-mobile/app/(tabs)/_layout.tsx | 73 +-- apps/frontend-mobile/app/(tabs)/alerts.tsx | 46 +- apps/frontend-mobile/app/(tabs)/home.tsx | 166 ------ apps/frontend-mobile/app/(tabs)/index.tsx | 260 ++++----- apps/frontend-mobile/app/(tabs)/map.tsx | 13 +- apps/frontend-mobile/app/(tabs)/profile.tsx | 515 +++++++++--------- apps/frontend-mobile/app/(tabs)/scan.tsx | 285 +++++++++- apps/frontend-mobile/app/(tabs)/search.tsx | 248 +++++++++ apps/frontend-mobile/app/_layout.tsx | 93 ++-- apps/frontend-mobile/app/auth/login.tsx | 41 +- apps/frontend-mobile/app/auth/register.tsx | 35 +- apps/frontend-mobile/app/medicine/[id].tsx | 328 ++++++++--- apps/frontend-mobile/app/pharmacy/[id].tsx | 56 +- .../components/BarcodeScanner.tsx | 37 +- .../components/LoadingSpinner.tsx | 8 +- .../components/MedicineCard.tsx | 19 +- apps/frontend-mobile/components/SearchBar.tsx | 14 +- .../frontend-mobile/components/StockBadge.tsx | 38 +- .../components/ThemeProvider.tsx | 43 ++ apps/frontend-mobile/constants/theme.ts | 46 ++ .../hooks/useRecentSearches.ts | 41 ++ apps/frontend-mobile/hooks/useThemeColor.ts | 19 + apps/frontend-mobile/package.json | 1 + apps/frontend-mobile/services/api.ts | 25 +- apps/frontend-mobile/services/auth.ts | 22 +- apps/frontend-mobile/store/themeStore.ts | 30 + apps/frontend-mobile/types/index.ts | 17 +- package-lock.json | 13 + 32 files changed, 1793 insertions(+), 926 deletions(-) create mode 100644 .mimocode/plans/1783593202807-swift-garden.md delete mode 100644 apps/frontend-mobile/app/(tabs)/home.tsx create mode 100644 apps/frontend-mobile/app/(tabs)/search.tsx create mode 100644 apps/frontend-mobile/components/ThemeProvider.tsx create mode 100644 apps/frontend-mobile/hooks/useRecentSearches.ts create mode 100644 apps/frontend-mobile/hooks/useThemeColor.ts create mode 100644 apps/frontend-mobile/store/themeStore.ts diff --git a/.mimocode/.cron-lock b/.mimocode/.cron-lock index 2c7e60e..96d5d22 100644 --- a/.mimocode/.cron-lock +++ b/.mimocode/.cron-lock @@ -1 +1 @@ -{"pid":864463,"startedAt":1783550197179} \ No newline at end of file +{"pid":10782,"startedAt":1783578681151} \ No newline at end of file diff --git a/.mimocode/plans/1783593202807-swift-garden.md b/.mimocode/plans/1783593202807-swift-garden.md new file mode 100644 index 0000000..59b54ec --- /dev/null +++ b/.mimocode/plans/1783593202807-swift-garden.md @@ -0,0 +1,141 @@ +# Plan: Dark Mode para FarmaFinder Mobile + +## Objetivo +Añadir soporte de modo oscuro a la app móvil con: +- Detección automática del modo oscuro del sistema +- Toggle manual en la pantalla de perfil (Configuración) +- Persistencia de la preferencia del usuario + +## Arquitectura + +### 1. Theme Store (Zustand + AsyncStorage) +**Nuevo archivo: `store/themeStore.ts`** + +- Estado: `mode: 'light' | 'dark' | 'system'` +- Persistencia con `@react-native-async-storage/async-storage` +- Acciones: `setMode()`, `init()` (carga del storage al arrancar) +- Por defecto: `'system'` (respeta el dispositivo) + +### 2. Hook `useThemeColor` +**Nuevo archivo: `hooks/useThemeColor.ts`** + +- Usa `useColorScheme()` de React Native para detectar el color del sistema +- Combina con el `mode` del themeStore para determinar el tema efectivo +- Retorna el objeto `colors` apropiado (light o dark) +- Los componentes consumirán este hook en vez de importar `colors` directamente + +### 3. Paleta de colores oscura +**Modificar: `constants/theme.ts`** + +Añadir exportación `darkColors` con la paleta oscura: + +| Token | Light (actual) | Dark (nuevo) | +|---|---|---| +| `background` | `#fbfbfb` | `#121212` | +| `card` | `#ffffff` | `#1e1e1e` | +| `surfaceLow` | `#f2f4f5` | `#2a2a2a` | +| `surface` | `#eceeef` | `#333333` | +| `surfaceHigh` | `#e6e8e9` | `#3a3a3a` | +| `border` | `#c0c9bb` | `#3a3a3a` | +| `separator` | `#c0c9bb` | `#3a3a3a` | +| `text` | `#111417` | `#f0f0f0` | +| `textSecondary` | `#41493e` | `#a0a0a0` | +| `textInverse` | `#ffffff` | `#111417` | +| `primaryContainer` | `#cfead0` | `#1a3a1c` | +| `onPrimaryContainer` | `#0d2b12` | `#cfead0` | +| `secondaryContainer` | `#dbe7ff` | `#1a2a4a` | +| `tertiaryContainer` | `#efe7ff` | `#2a1a4a` | +| `dangerContainer` | `#feecec` | `#3a1a1a` | +| `accentWarm` | `#f5a97a` | `#f5a97a` (sin cambio) | +| `primary`, `secondary`, `tertiary`, `scanButton`, `success`, `danger`, `warning` | (sin cambios) | (sin cambios) | + +### 4. Provider wrapper +**Nuevo archivo: `components/ThemeProvider.tsx`** + +- Lee el `mode` del themeStore y el `useColorScheme()` del sistema +- Calcula el tema efectivo (`light` o `dark`) +- Provee `colors` y `isDark` vía React Context +- Carga la preferencia guardada en AsyncStorage al montar + +### 5. Integración en Root Layout +**Modificar: `app/_layout.tsx`** + +- Envolver toda la app con `` +- El `StatusBar` se ajusta: `style={isDark ? 'light' : 'auto'}` +- Los colores del Stack header se leen del contexto + +### 6. Toggle en el perfil +**Modificar: `app/(tabs)/profile.tsx`** + +Añadir en la pantalla de perfil (antes de "Configuración") un nuevo item de menú: +- Icono: `moon-outline` / `sunny-outline` según el tema actual +- Texto: "Modo de pantalla" +- Al pulsar, mostrar un picker/modal con 3 opciones: + - **Sistema** (icono: phone-portrait) - respeta el SO + - **Claro** (icono: sunny) - fuerza light + - **Oscuro** (icono: moon) - fuerza dark +- El toggle se muestra inline (sin modal extra) tipo Segmented Control + +### 7. Actualizar todos los screens y components + +Cada archivo que importa `colors` debe cambiar a usar el hook `useThemeColor()`: + +| Archivo | Cambio | +|---|---| +| `app/_layout.tsx` | Usa ThemeProvider + context para header colors | +| `app/(tabs)/_layout.tsx` | Tab bar y header leen colors del context | +| `app/(tabs)/index.tsx` | `const { colors } = useThemeColor()` | +| `app/(tabs)/search.tsx` | `const { colors } = useThemeColor()` | +| `app/(tabs)/scan.tsx` | `const { colors } = useThemeColor()` | +| `app/(tabs)/alerts.tsx` | `const { colors } = useThemeColor()` | +| `app/(tabs)/profile.tsx` | `const { colors, isDark } = useThemeColor()` | +| `app/(tabs)/map.tsx` | `const { colors } = useThemeColor()` | +| `app/medicine/[id].tsx` | `const { colors } = useThemeColor()` | +| `app/pharmacy/[id].tsx` | `const { colors } = useThemeColor()` | +| `app/auth/login.tsx` | `const { colors } = useThemeColor()` | +| `app/auth/register.tsx` | `const { colors } = useThemeColor()` | +| `components/SearchBar.tsx` | `const { colors } = useThemeColor()` | +| `components/MedicineCard.tsx` | `const { colors } = useThemeColor()` | +| `components/LoadingSpinner.tsx` | `const { colors } = useThemeColor()` | +| `components/StockBadge.tsx` | Añade colores dark para badges | +| `components/BarcodeScanner.tsx` | `const { colors } = useThemeColor()` | + +**Nota sobre StyleSheet**: Los `StyleSheet.create()` se ejecutan una vez. Para que los estilos se actualicen con el tema, las propiedades que dependen de `colors` deben aplicarse vía `style` inline o moverse a funciones que retornen estilos dinámicos. La estrategia más limpia es: +- Los colores de fondo/texto van como inline styles +- Las estructuras (flex, padding, borderRadius) se quedan en `StyleSheet.create` + +### 8. Colores hardcodeados +Los siguientes archivos tienen colores inline hardcodeados que necesitan variante dark: + +- **`StockBadge.tsx`**: `#eaf7ec`, `#fff3cd`, `#feecec` → añadir tokens al tema +- **`search.tsx`**: suggestions con `#ffffff`, `#f0f7ff`, etc. → usar colores del tema +- **`profile.tsx`**: feedback `#eaf7ec`, `#cfead0`, `#fecaca` → usar colores del tema +- **`home/index.tsx`**: `rgba(255,255,255,0.2/0.3)` para iconos → ajustar opacidad + +### 9. app.json +**Modificar: `app.json`** + +Cambiar `"userInterfaceStyle": "light"` → `"userInterfaceStyle": "automatic"` + +## Orden de ejecución + +1. `store/themeStore.ts` (nuevo) +2. `hooks/useThemeColor.ts` (nuevo) +3. `constants/theme.ts` (añadir `darkColors`) +4. `components/ThemeProvider.tsx` (nuevo) +5. `app/_layout.tsx` (envolver con ThemeProvider) +6. `app/(tabs)/_layout.tsx` (colores dinámicos) +7. `app/(tabs)/profile.tsx` (toggle de tema) +8. Todos los screens y components (migrar a hook) +9. `app.json` (userInterfaceStyle: automatic) +10. Corregir colores hardcodeados + +## Verificación + +1. Ejecutar `npx expo start` y verificar que la app arranca sin errores +2. Probar en iOS/Android: el tema respeta la configuración del sistema +3. Ir a Perfil > Modo de pantalla y cambiar entre Claro/Oscuro/Sistema +4. Verificar que el cambio es inmediato sin recargar la app +5. Cerrar y reabrir la app → la preferencia persiste +6. Verificar que todos los screens se ven bien en ambos modos (sin texto invisible, sin fondos transparentes) +7. Probar tablets ( responsive) diff --git a/apps/backend/server.js b/apps/backend/server.js index 48a9ac3..c3c06cc 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -512,7 +512,8 @@ app.get('/api/medicines/:medicineId/pharmacies', async (req, res) => { try { const nregistro = req.params.medicineId; // Ahora es el nregistro de CIMA - const pharmacies = await userDbAll(` + // First try to get pharmacies with specific medicine data + let pharmacies = await userDbAll(` SELECT p.id, p.name, @@ -529,6 +530,24 @@ app.get('/api/medicines/:medicineId/pharmacies', async (req, res) => { ORDER BY p.name `, [nregistro]); + // If no specific links exist, return all pharmacies as potential sellers + if (pharmacies.length === 0) { + pharmacies = await userDbAll(` + SELECT + p.id, + p.name, + p.address, + p.phone, + p.latitude, + p.longitude, + p.opening_hours, + NULL as price, + 0 as stock + FROM pharmacies p + ORDER BY p.name + `); + } + res.json(pharmacies); } catch (error) { console.error('Error fetching pharmacies:', error); diff --git a/apps/frontend-mobile/app.json b/apps/frontend-mobile/app.json index c0a3b5b..55b0a94 100644 --- a/apps/frontend-mobile/app.json +++ b/apps/frontend-mobile/app.json @@ -5,7 +5,7 @@ "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", - "userInterfaceStyle": "light", + "userInterfaceStyle": "automatic", "newArchEnabled": true, "splash": { "image": "./assets/splash.png", @@ -25,12 +25,29 @@ "backgroundColor": "#007AFF" }, "package": "com.farmafinder.app", - "googleServicesFile": "./google-services.json" + "googleServicesFile": "./google-services.json", + "config": { + "googleMaps": { + "apiKey": "" + } + } }, "plugins": [ "expo-router", ["expo-camera", {"cameraPermission": "Allow FarmaFinder to access your camera for scanning barcodes"}], - ["expo-notifications", {"icon": "./assets/notification-icon.png", "color": "#007AFF"}] + ["expo-notifications", {"icon": "./assets/notification-icon.png", "color": "#007AFF"}], + [ + "react-native-maps", + { + "locationAlwaysAndWhenInUsePermission": "Allow FarmaFinder to use your location to find nearby pharmacies." + } + ], + [ + "expo-location", + { + "locationAlwaysAndWhenInUsePermission": "Allow FarmaFinder to use your location to find nearby pharmacies." + } + ] ], "scheme": "farmafinder", "extra": { diff --git a/apps/frontend-mobile/app/(tabs)/_layout.tsx b/apps/frontend-mobile/app/(tabs)/_layout.tsx index 6aaf2c6..9aa8b4c 100644 --- a/apps/frontend-mobile/app/(tabs)/_layout.tsx +++ b/apps/frontend-mobile/app/(tabs)/_layout.tsx @@ -1,12 +1,16 @@ import { Tabs } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; -import { View, StyleSheet, Platform, useWindowDimensions } from 'react-native'; +import { View, StyleSheet, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { colors, shadows } from '../../constants/theme'; +import { useThemeContext } from '../../components/ThemeProvider'; +import { shadows } from '../../constants/theme'; const TABLET_MIN_WIDTH = 768; -function ScanIcon({ color, size }: { color: string; size: number }) { +// Standard Android navigation bar heights in dp +const ANDROID_NAV_BAR_HEIGHT = 48; + +function ScanIcon({ size }: { size: number }) { return ( @@ -20,7 +24,9 @@ export default function TabLayout() { const insets = useSafeAreaInsets(); const { width } = useWindowDimensions(); const isTablet = width >= TABLET_MIN_WIDTH; - const bottomPadding = Platform.OS === 'ios' ? insets.bottom : 8; + const { colors } = useThemeContext(); + // Use safe area insets if available, otherwise use standard Android nav bar height + const bottomPadding = insets.bottom > 0 ? insets.bottom : ANDROID_NAV_BAR_HEIGHT; return ( ( @@ -54,7 +67,7 @@ export default function TabLayout() { }} /> ( @@ -99,40 +112,6 @@ export default function TabLayout() { } const styles = StyleSheet.create({ - tabBar: { - backgroundColor: colors.card, - borderTopColor: colors.border, - paddingTop: 8, - }, - tabBarTablet: { - backgroundColor: colors.card, - borderTopColor: colors.border, - paddingTop: 10, - paddingHorizontal: 24, - }, - tabBarLabel: { - fontSize: 11, - fontWeight: '500', - }, - tabBarLabelTablet: { - fontSize: 13, - }, - 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, - }, - headerTitleTablet: { - fontSize: 20, - }, fabContainer: { position: 'absolute', top: -16, @@ -142,7 +121,7 @@ const styles = StyleSheet.create({ width: 52, height: 52, borderRadius: 26, - backgroundColor: colors.scanButton, + backgroundColor: '#2b5bb5', alignItems: 'center', justifyContent: 'center', }, diff --git a/apps/frontend-mobile/app/(tabs)/alerts.tsx b/apps/frontend-mobile/app/(tabs)/alerts.tsx index 68e85a7..7dafed3 100644 --- a/apps/frontend-mobile/app/(tabs)/alerts.tsx +++ b/apps/frontend-mobile/app/(tabs)/alerts.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from 'react'; import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { colors, spacing, borderRadius, shadows } from '../../constants/theme'; +import { useThemeContext } from '../../components/ThemeProvider'; +import { spacing, borderRadius, shadows } from '../../constants/theme'; import { LoadingSpinner } from '../../components/LoadingSpinner'; import api from '../../services/api'; @@ -21,6 +22,7 @@ interface NotificationItem { export default function AlertsScreen() { const { width } = useWindowDimensions(); const isTablet = width >= TABLET_MIN_WIDTH; + const { colors } = useThemeContext(); const [items, setItems] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -81,33 +83,33 @@ export default function AlertsScreen() { 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} > @@ -126,19 +128,19 @@ export default function AlertsScreen() { } return ( - + - Notificaciones Guardadas - + Notificaciones Guardadas + Recibe avisos cuando medicamentos sin stock se repongan {error && ( - - {error} + + {error} - Reintentar + Reintentar )} @@ -146,8 +148,8 @@ export default function AlertsScreen() { {!error && items.length === 0 && ( - Sin notificaciones - + Sin notificaciones + Toca la campana en una farmacia sin stock para recibir notificaciones cuando se reponga. @@ -169,7 +171,6 @@ export default function AlertsScreen() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.background, }, header: { paddingHorizontal: spacing.lg, @@ -184,14 +185,12 @@ const styles = StyleSheet.create({ title: { fontSize: 22, fontWeight: 'bold', - color: colors.text, }, titleTablet: { fontSize: 28, }, subtitle: { fontSize: 14, - color: colors.textSecondary, marginTop: spacing.xs, lineHeight: 20, }, @@ -212,7 +211,6 @@ const styles = StyleSheet.create({ item: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.card, borderRadius: borderRadius.lg, padding: spacing.md, ...shadows.card, @@ -224,7 +222,6 @@ const styles = StyleSheet.create({ itemName: { fontSize: 16, fontWeight: '600', - color: colors.text, lineHeight: 22, }, itemMeta: { @@ -234,7 +231,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: spacing.xs, - backgroundColor: colors.primaryContainer, borderRadius: borderRadius.full, paddingHorizontal: spacing.sm, paddingVertical: 3, @@ -243,17 +239,14 @@ const styles = StyleSheet.create({ 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, @@ -261,7 +254,6 @@ const styles = StyleSheet.create({ errorContainer: { margin: spacing.lg, padding: spacing.md, - backgroundColor: colors.dangerContainer, borderRadius: borderRadius.lg, alignItems: 'center', gap: spacing.sm, @@ -272,7 +264,6 @@ const styles = StyleSheet.create({ }, errorText: { fontSize: 14, - color: colors.danger, textAlign: 'center', }, retryButton: { @@ -282,7 +273,6 @@ const styles = StyleSheet.create({ retryText: { fontSize: 14, fontWeight: '600', - color: colors.primary, }, emptyContainer: { flex: 1, @@ -294,14 +284,12 @@ const styles = StyleSheet.create({ emptyTitle: { fontSize: 18, fontWeight: '600', - color: colors.text, }, emptyTitleTablet: { fontSize: 24, }, 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 deleted file mode 100644 index 3a1976d..0000000 --- a/apps/frontend-mobile/app/(tabs)/home.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Image, useWindowDimensions } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { useRouter } from 'expo-router'; -import { colors, spacing, borderRadius, shadows } from '../../constants/theme'; - -const TABLET_MIN_WIDTH = 768; - -export default function HomeScreen() { - const router = useRouter(); - const { width } = useWindowDimensions(); - const isTablet = width >= TABLET_MIN_WIDTH; - - 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, - }, - heroTablet: { - marginBottom: spacing.xl, - }, - logo: { - width: 120, - height: 120, - marginBottom: spacing.xs, - }, - logoTablet: { - width: 160, - height: 160, - }, - brandName: { - fontSize: 28, - fontWeight: 'bold', - color: colors.text, - letterSpacing: -0.5, - }, - brandNameTablet: { - fontSize: 36, - }, - description: { - fontSize: 16, - color: colors.textSecondary, - textAlign: 'center', - maxWidth: 260, - lineHeight: 24, - }, - descriptionTablet: { - fontSize: 18, - maxWidth: 400, - lineHeight: 28, - }, - cards: { - width: '100%', - maxWidth: 320, - gap: spacing.md, - }, - cardsTablet: { - maxWidth: 480, - }, - card: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - backgroundColor: colors.primaryContainer, - borderRadius: borderRadius.lg, - padding: spacing.md, - minHeight: 64, - ...shadows.card, - }, - cardTablet: { - padding: spacing.lg, - minHeight: 72, - }, - 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, - }, - cardLabelTablet: { - fontSize: 20, - }, - cardLabelScan: { - color: '#ffffff', - }, -}); diff --git a/apps/frontend-mobile/app/(tabs)/index.tsx b/apps/frontend-mobile/app/(tabs)/index.tsx index 4a89dd8..e932910 100644 --- a/apps/frontend-mobile/app/(tabs)/index.tsx +++ b/apps/frontend-mobile/app/(tabs)/index.tsx @@ -1,15 +1,9 @@ -import React, { useState, useEffect } from 'react'; -import { View, FlatList, StyleSheet, Text, TouchableOpacity, useWindowDimensions } from 'react-native'; +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Image, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import { SearchBar } from '../../components/SearchBar'; -import { MedicineCard } from '../../components/MedicineCard'; -import { LoadingSpinner } from '../../components/LoadingSpinner'; -import { useDebounce } from '../../hooks/useDebounce'; -import { searchMedicines } from '../../services/medicines'; -import { colors, spacing, borderRadius } from '../../constants/theme'; -import { Medicine } from '../../types'; -import { config } from '../../constants/config'; +import { useThemeContext } from '../../components/ThemeProvider'; +import { spacing, borderRadius, shadows } from '../../constants/theme'; const TABLET_MIN_WIDTH = 768; @@ -17,77 +11,51 @@ export default function HomeScreen() { const router = useRouter(); const { width } = useWindowDimensions(); const isTablet = width >= TABLET_MIN_WIDTH; - const [query, setQuery] = useState(''); - const [results, setResults] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const debouncedQuery = useDebounce(query, config.SEARCH_DEBOUNCE_MS); - - useEffect(() => { - if (debouncedQuery.length < 2) { - setResults([]); - return; - } - - const fetchResults = async () => { - setIsLoading(true); - setError(null); - try { - const data = await searchMedicines(debouncedQuery); - setResults(data); - } catch (err) { - setError('Error al buscar medicamentos'); - console.error(err); - } finally { - setIsLoading(false); - } - }; - - fetchResults(); - }, [debouncedQuery]); - - const handleSearch = (searchQuery: string) => { - setQuery(searchQuery); - }; + const { colors } = useThemeContext(); return ( - - - + + - router.push('/scanner')} - > - - + FarmaClic + + Encuentra tus medicamentos en farmacias cercanas + - {isLoading && } + + router.push('/(tabs)/search')} + > + + + + + Buscar Medicamento + + + - {error && ( - - {error} - - )} - - {!isLoading && !error && results.length === 0 && query.length >= 2 && ( - - No se encontraron medicamentos - - )} - - item.nregistro} - renderItem={({ item }) => } - contentContainerStyle={[styles.list, isTablet && styles.listTablet]} - showsVerticalScrollIndicator={false} - /> + router.push('/scanner')} + > + + + + + Escanear TSI + + + + ); } @@ -95,63 +63,101 @@ export default function HomeScreen() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.background, - }, - searchContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.lg, - maxWidth: 480, - alignSelf: 'center', - width: '100%', - }, - searchContainerTablet: { - maxWidth: 700, - alignSelf: 'center', - width: '100%', - }, - scannerButton: { - 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, + paddingHorizontal: spacing.lg, + gap: spacing.lg, }, - list: { - paddingBottom: spacing.xl, - }, - listTablet: { - maxWidth: 700, - alignSelf: 'center', - width: '100%', - }, - errorContainer: { - padding: spacing.md, - marginHorizontal: spacing.lg, - backgroundColor: colors.dangerContainer, - borderRadius: borderRadius.lg, - }, - errorContainerTablet: { - maxWidth: 700, - alignSelf: 'center', - }, - errorText: { - color: colors.danger, - textAlign: 'center', - fontSize: 14, - }, - emptyContainer: { - padding: spacing.xl, + hero: { alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.md, }, - emptyText: { - color: colors.textSecondary, + heroTablet: { + marginBottom: spacing.xl, + }, + logo: { + width: 120, + height: 120, + marginBottom: spacing.xs, + }, + logoTablet: { + width: 160, + height: 160, + }, + brandName: { + fontSize: 28, + fontWeight: 'bold', + letterSpacing: -0.5, + }, + brandNameTablet: { + fontSize: 36, + }, + description: { fontSize: 16, + textAlign: 'center', + maxWidth: 260, + lineHeight: 24, + }, + descriptionTablet: { + fontSize: 18, + maxWidth: 400, + lineHeight: 28, + }, + cards: { + width: '100%', + maxWidth: 320, + gap: spacing.md, + }, + cardsTablet: { + maxWidth: 480, + }, + card: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + borderRadius: borderRadius.lg, + padding: spacing.md, + minHeight: 64, + ...shadows.card, + }, + cardTablet: { + padding: spacing.lg, + minHeight: 72, + }, + cardScan: { + backgroundColor: '#2b5bb5', + }, + 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', + lineHeight: 24, + }, + cardLabelTablet: { + fontSize: 20, + }, + cardLabelScan: { + color: '#ffffff', }, }); diff --git a/apps/frontend-mobile/app/(tabs)/map.tsx b/apps/frontend-mobile/app/(tabs)/map.tsx index 12051e7..a9d783d 100644 --- a/apps/frontend-mobile/app/(tabs)/map.tsx +++ b/apps/frontend-mobile/app/(tabs)/map.tsx @@ -4,15 +4,17 @@ import MapView, { Marker } from 'react-native-maps'; import { useRouter } from 'expo-router'; import { getPharmacies } from '../../services/pharmacies'; import { LoadingSpinner } from '../../components/LoadingSpinner'; -import { colors, spacing, borderRadius } from '../../constants/theme'; +import { useThemeContext } from '../../components/ThemeProvider'; +import { spacing, borderRadius } from '../../constants/theme'; import { Pharmacy } from '../../types'; export default function MapScreen() { const router = useRouter(); + const { colors } = useThemeContext(); const [pharmacies, setPharmacies] = useState([]); const [isLoading, setIsLoading] = useState(true); const [region, setRegion] = useState({ - latitude: 40.4168, // Madrid default + latitude: 40.4168, longitude: -3.7038, latitudeDelta: 0.0922, longitudeDelta: 0.0421, @@ -24,7 +26,6 @@ export default function MapScreen() { const data = await getPharmacies(); setPharmacies(data); - // Center map on first pharmacy if available if (data.length > 0) { setRegion({ latitude: data[0].latitude, @@ -70,8 +71,8 @@ export default function MapScreen() { ))} - - + + {pharmacies.length} farmacias en el mapa @@ -91,7 +92,6 @@ const styles = StyleSheet.create({ bottom: spacing.lg, left: spacing.md, right: spacing.md, - backgroundColor: colors.card, borderRadius: borderRadius.lg, padding: spacing.sm + 4, alignItems: 'center', @@ -103,6 +103,5 @@ const styles = StyleSheet.create({ }, legendText: { fontSize: 14, - color: colors.text, }, }); diff --git a/apps/frontend-mobile/app/(tabs)/profile.tsx b/apps/frontend-mobile/app/(tabs)/profile.tsx index a2411d0..37b4848 100644 --- a/apps/frontend-mobile/app/(tabs)/profile.tsx +++ b/apps/frontend-mobile/app/(tabs)/profile.tsx @@ -4,7 +4,10 @@ import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { useAuth } from '../../hooks/useAuth'; -import { colors, spacing, borderRadius } from '../../constants/theme'; +import { useThemeContext } from '../../components/ThemeProvider'; +import { useThemeStore, ThemeMode } from '../../store/themeStore'; +import { spacing, borderRadius } from '../../constants/theme'; +import api from '../../services/api'; const TABLET_MIN_WIDTH = 768; @@ -26,6 +29,21 @@ const COLOR_CIRCLES = [ require('../../assets/avatars/color6.png'), ]; +function resolveAvatarUrl(url: string | null | undefined): number | null { + if (!url) return null; + const matchPreset = url.match(/^preset_avatar_(\d+)$/); + if (matchPreset) { + const idx = parseInt(matchPreset[1], 10); + if (idx >= 1 && idx <= 6) return AVATARS[idx - 1]; + } + const matchColor = url.match(/^color_circle_(\d+)$/); + if (matchColor) { + const idx = parseInt(matchColor[1], 10); + if (idx >= 1 && idx <= 6) return COLOR_CIRCLES[idx - 1]; + } + return null; +} + interface SearchHistoryItem { id: number; address: string; @@ -44,16 +62,26 @@ interface Address { created_at: string; } +const THEME_OPTIONS: { mode: ThemeMode; label: string; icon: string }[] = [ + { mode: 'system', label: 'Sistema', icon: 'phone-portrait-outline' }, + { mode: 'light', label: 'Claro', icon: 'sunny-outline' }, + { mode: 'dark', label: 'Oscuro', icon: 'moon-outline' }, +]; + export default function ProfileScreen() { const router = useRouter(); const { width } = useWindowDimensions(); const isTablet = width >= TABLET_MIN_WIDTH; const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth(); + const { colors, isDark } = useThemeContext(); + const themeMode = useThemeStore((s) => s.mode); + const setThemeMode = useThemeStore((s) => s.setMode); + const initialResolved = resolveAvatarUrl(user?.avatar_url); const [firstName, setFirstName] = useState(user?.first_name || ''); const [lastName, setLastName] = useState(user?.last_name || ''); - const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || ''); - const [avatarLocalSource, setAvatarLocalSource] = useState(null); + const [avatarUrl, setAvatarUrl] = useState(initialResolved ? '' : (user?.avatar_url || '')); + const [avatarLocalSource, setAvatarLocalSource] = useState(initialResolved); const [searchHistory, setSearchHistory] = useState([]); // Avatar modal state @@ -91,16 +119,20 @@ export default function ProfileScreen() { useEffect(() => { setFirstName(user?.first_name || ''); setLastName(user?.last_name || ''); - setAvatarUrl(user?.avatar_url || ''); + const resolved = resolveAvatarUrl(user?.avatar_url); + if (resolved) { + setAvatarLocalSource(resolved); + setAvatarUrl(''); + } else { + setAvatarLocalSource(null); + setAvatarUrl(user?.avatar_url || ''); + } }, [user]); async function loadSearchHistory() { try { - const res = await fetch('/api/search-history', { credentials: 'include' }); - if (res.ok) { - const data = await res.json(); - setSearchHistory(data); - } + const res = await api.get('/search-history'); + setSearchHistory(res.data); } catch (err) { console.error('Error loading search history:', err); } @@ -120,23 +152,14 @@ export default function ProfileScreen() { setConfigSaving(true); setConfigFeedback(null); try { - const res = await fetch('/api/users/me', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - first_name: configFirstName.trim() || null, - last_name: configLastName.trim() || null, - email: configEmail.trim() || null, - city: configCity.trim() || null, - address: configAddress.trim() || null, - }), + const res = await api.put('/users/me', { + first_name: configFirstName.trim() || null, + last_name: configLastName.trim() || null, + email: configEmail.trim() || null, + city: configCity.trim() || null, + address: configAddress.trim() || null, }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.error || 'Error al guardar'); - } - const updated = await res.json(); + const updated = res.data; setFirstName(updated.first_name || ''); setLastName(updated.last_name || ''); setConfigFeedback({ type: 'ok', text: 'Perfil guardado.' }); @@ -154,24 +177,21 @@ export default function ProfileScreen() { allowsEditing: true, aspect: [1, 1], quality: 0.8, + base64: true, }); if (!result.canceled && result.assets[0]) { - setAvatarUrl(result.assets[0].uri); - setShowAvatarModal(false); - try { - const res = await fetch('/api/users/me', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ avatar_url: result.assets[0].uri }), - }); - if (res.ok) { - const updated = await res.json(); - // Trigger auth refresh to update user state + const asset = result.assets[0]; + if (asset.base64) { + const dataUri = `data:${asset.mimeType};base64,${asset.base64}`; + setAvatarLocalSource(null); + setAvatarUrl(dataUri); + setShowAvatarModal(false); + try { + await api.put('/users/me', { avatar_url: dataUri }); + } catch (err) { + console.error('Error saving avatar:', err); } - } catch (err) { - console.error('Error saving avatar:', err); } } } @@ -187,41 +207,32 @@ export default function ProfileScreen() { allowsEditing: true, aspect: [1, 1], quality: 0.8, + base64: true, }); if (!result.canceled && result.assets[0]) { - setAvatarUrl(result.assets[0].uri); - setShowAvatarModal(false); - try { - const res = await fetch('/api/users/me', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ avatar_url: result.assets[0].uri }), - }); - if (res.ok) { - const updated = await res.json(); + const asset = result.assets[0]; + if (asset.base64) { + const dataUri = `data:${asset.mimeType};base64,${asset.base64}`; + setAvatarLocalSource(null); + setAvatarUrl(dataUri); + setShowAvatarModal(false); + try { + await api.put('/users/me', { avatar_url: dataUri }); + } catch (err) { + console.error('Error saving avatar:', err); } - } catch (err) { - console.error('Error saving avatar:', err); } } } async function handleSelectPresetAvatar(index: number) { const avatarSource = AVATARS[index]; - setAvatarUrl(avatarSource); + setAvatarUrl(''); + setAvatarLocalSource(avatarSource); setShowAvatarModal(false); try { - const res = await fetch('/api/users/me', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ avatar_url: `preset_avatar_${index + 1}` }), - }); - if (res.ok) { - const updated = await res.json(); - } + await api.put('/users/me', { avatar_url: `preset_avatar_${index + 1}` }); } catch (err) { console.error('Error saving avatar:', err); } @@ -229,18 +240,11 @@ export default function ProfileScreen() { async function handleSelectColor(index: number) { const colorSource = COLOR_CIRCLES[index]; - setAvatarUrl(colorSource); + setAvatarUrl(''); + setAvatarLocalSource(colorSource); setShowAvatarModal(false); try { - const res = await fetch('/api/users/me', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ avatar_url: `color_circle_${index + 1}` }), - }); - if (res.ok) { - const updated = await res.json(); - } + await api.put('/users/me', { avatar_url: `color_circle_${index + 1}` }); } catch (err) { console.error('Error saving avatar:', err); } @@ -248,13 +252,8 @@ export default function ProfileScreen() { async function handleDeleteSearch(id: number) { try { - const res = await fetch(`/api/search-history/${id}`, { - method: 'DELETE', - credentials: 'include', - }); - if (res.ok) { - setSearchHistory(prev => prev.filter(item => item.id !== id)); - } + await api.delete(`/search-history/${id}`); + setSearchHistory(prev => prev.filter(item => item.id !== id)); } catch (err) { console.error('Error deleting search:', err); } @@ -264,11 +263,8 @@ export default function ProfileScreen() { async function loadAddresses() { setAddressesLoading(true); try { - const res = await fetch('/api/addresses', { credentials: 'include' }); - if (res.ok) { - const data = await res.json(); - setAddresses(data); - } + const res = await api.get('/addresses'); + setAddresses(res.data); } catch (err) { console.error('Error loading addresses:', err); } finally { @@ -380,21 +376,32 @@ export default function ProfileScreen() { ); }; + const getThemeModeLabel = () => { + const opt = THEME_OPTIONS.find(o => o.mode === themeMode); + return opt?.label || 'Sistema'; + }; + + const getThemeModeIcon = (): 'phone-portrait-outline' | 'sunny-outline' | 'moon-outline' => { + if (themeMode === 'light') return 'sunny-outline'; + if (themeMode === 'dark') return 'moon-outline'; + return 'phone-portrait-outline'; + }; + if (isLoading) { return ( - - Cargando... + + Cargando... ); } if (!isAuthenticated) { return ( - + - Inicia Sesión - + Inicia Sesión + Inicia sesión para acceder a todas las funcionalidades router.push('/auth/register')} > - Crear cuenta nueva + Crear cuenta nueva @@ -415,11 +422,13 @@ export default function ProfileScreen() { } return ( - + {/* Avatar Section */} - - setShowAvatarModal(true)}> - {avatarUrl ? ( + + setShowAvatarModal(true)}> + {avatarLocalSource ? ( + + ) : avatarUrl ? ( ) : ( @@ -428,41 +437,77 @@ export default function ProfileScreen() { - Toca para cambiar foto + Toca para cambiar foto {/* Read-only Name Section */} - - - Nombre - {firstName || '—'} + + + Nombre + {firstName || '—'} - - Apellidos - {lastName || '—'} + + Apellidos + {lastName || '—'} {/* Menu Section */} - - + + {/* Theme Toggle */} + + + + Modo de pantalla + + + {THEME_OPTIONS.map((opt) => ( + setThemeMode(opt.mode)} + > + + + {opt.label} + + + ))} + + + + - Configuración + Configuración - + - Mis Direcciones + Mis Direcciones {searchHistory.length > 0 && ( - - Tus búsquedas recientes: + + Tus búsquedas recientes: {searchHistory.map((item) => ( - - {item.address} + + {item.address} handleDeleteSearch(item.id)} style={styles.searchDelete} @@ -477,51 +522,51 @@ export default function ProfileScreen() { {isAdmin && ( - Panel Admin + Panel Admin )} {/* Logout Button */} - + - Cerrar Sesión + Cerrar Sesión {/* Avatar Selection Modal */} - - - Cambiar Avatar + + + Cambiar Avatar setShowAvatarModal(false)}> {/* Tab Buttons */} - + setAvatarTab('presets')} > - Prediseñado + Prediseñado setAvatarTab('colors')} > - Colores + Colores setAvatarTab('upload')} > - Subir + Subir @@ -557,19 +602,19 @@ export default function ProfileScreen() { {avatarTab === 'upload' && ( - - + + - Tomar foto - Usa la cámara de tu dispositivo + Tomar foto + Usa la cámara de tu dispositivo - - + + - Elegir de galería - Selecciona una imagen existente + Elegir de galería + Selecciona una imagen existente )} @@ -581,80 +626,85 @@ export default function ProfileScreen() { {/* Config Modal */} - - - Configuración + + + Configuración setShowConfig(false)}> - Nombre + Nombre - Apellidos + Apellidos - Correo electrónico + Correo electrónico - Ciudad + Ciudad - Dirección + Dirección {configFeedback && ( - - {configFeedback.text} + + {configFeedback.text} )} setShowConfig(false)} disabled={configSaving} > - Cancelar + Cancelar - - - Mis Direcciones + + + Mis Direcciones { setShowAddresses(false); setShowAddressForm(false); }}> @@ -687,22 +737,24 @@ export default function ProfileScreen() { {showAddressForm ? ( - Dirección + Dirección - Etiqueta (opcional) + Etiqueta (opcional) @@ -716,20 +768,20 @@ export default function ProfileScreen() { size={22} color={formDefault ? colors.primary : colors.textSecondary} /> - Dirección predeterminada + Dirección predeterminada {formError ? ( - - {formError} + + {formError} ) : null} { setShowAddressForm(false); setFormError(''); }} disabled={formSaving} > - Cancelar + Cancelar {addressesLoading ? ( - Cargando direcciones... + Cargando direcciones... ) : ( {user?.address ? ( - + - Dirección principal - {user.address} - + Dirección principal + {user.address} + Predeterminada ) : null} {addresses.map((addr) => ( - + - {addr.label ? {addr.label} : null} - {addr.address} + {addr.label ? {addr.label} : null} + {addr.address} handleSetDefault(addr.id)}> - Establecer como predeterminada + Establecer como predeterminada @@ -782,9 +834,9 @@ export default function ProfileScreen() { ))} )} - + - Añadir más + Añadir más )} @@ -799,12 +851,10 @@ export default function ProfileScreen() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.background, }, loadingText: { textAlign: 'center', marginTop: spacing.xxl, - color: colors.textSecondary, }, authPrompt: { flex: 1, @@ -815,7 +865,6 @@ const styles = StyleSheet.create({ authTitle: { fontSize: 24, fontWeight: 'bold', - color: colors.text, marginTop: spacing.lg, }, authTitleTablet: { @@ -823,7 +872,6 @@ const styles = StyleSheet.create({ }, authSubtitle: { fontSize: 16, - color: colors.textSecondary, textAlign: 'center', marginTop: spacing.sm, marginBottom: spacing.xl, @@ -835,7 +883,6 @@ const styles = StyleSheet.create({ avatarSection: { alignItems: 'center', padding: spacing.xl, - backgroundColor: colors.card, }, avatarSectionTablet: { paddingVertical: spacing.xxl, @@ -844,7 +891,6 @@ const styles = StyleSheet.create({ width: 100, height: 100, borderRadius: 50, - backgroundColor: colors.primaryContainer, justifyContent: 'center', alignItems: 'center', overflow: 'hidden', @@ -877,12 +923,10 @@ const styles = StyleSheet.create({ }, editAvatarText: { marginTop: spacing.sm, - color: colors.textSecondary, fontSize: 14, }, infoSection: { padding: spacing.lg, - backgroundColor: colors.card, marginTop: spacing.sm, gap: spacing.md, }, @@ -892,16 +936,13 @@ const styles = StyleSheet.create({ width: '100%', }, infoCard: { - backgroundColor: colors.surfaceLow, padding: spacing.md, borderRadius: borderRadius.lg, borderWidth: 1, - borderColor: colors.border, }, infoLabel: { fontSize: 12, fontWeight: '600', - color: colors.textSecondary, textTransform: 'uppercase', letterSpacing: 0.05, marginBottom: 4, @@ -909,11 +950,9 @@ const styles = StyleSheet.create({ infoValue: { fontSize: 16, fontWeight: '500', - color: colors.text, }, menuSection: { marginTop: spacing.sm, - backgroundColor: colors.card, }, menuSectionTablet: { maxWidth: 600, @@ -925,23 +964,55 @@ const styles = StyleSheet.create({ alignItems: 'center', padding: spacing.md, borderBottomWidth: 1, - borderBottomColor: colors.border, }, menuText: { flex: 1, marginLeft: spacing.md, fontSize: 16, - color: colors.text, + }, + // Theme section + themeSection: { + padding: spacing.md, + borderBottomWidth: 1, + }, + themeHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + marginBottom: spacing.md, + }, + themeTitle: { + fontSize: 16, + fontWeight: '500', + }, + themeOptions: { + flexDirection: 'row', + gap: spacing.sm, + }, + themeOption: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm + 2, + paddingHorizontal: spacing.sm, + borderRadius: borderRadius.md, + borderWidth: 1, + }, + themeOptionText: { + fontSize: 13, + fontWeight: '500', + }, + themeOptionTextActive: { + fontWeight: '700', }, searchHistorySection: { padding: spacing.md, - backgroundColor: colors.surfaceLow, borderBottomWidth: 1, - borderBottomColor: colors.border, }, searchHistoryTitle: { fontSize: 14, - color: colors.textSecondary, marginBottom: spacing.sm, }, searchItem: { @@ -950,18 +1021,16 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', paddingVertical: spacing.sm, borderBottomWidth: 1, - borderBottomColor: colors.border, }, searchAddress: { flex: 1, fontSize: 14, - color: colors.text, }, searchDelete: { padding: spacing.xs, }, button: { - backgroundColor: colors.primary, + backgroundColor: '#7fbf8f', borderRadius: borderRadius.lg, paddingVertical: spacing.md, paddingHorizontal: spacing.xl, @@ -971,7 +1040,7 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.xl * 1.5, }, buttonText: { - color: colors.textInverse, + color: '#ffffff', fontSize: 16, fontWeight: '600', }, @@ -982,7 +1051,6 @@ const styles = StyleSheet.create({ marginTop: spacing.md, }, linkText: { - color: colors.primary, fontSize: 14, }, logoutButton: { @@ -991,10 +1059,8 @@ const styles = StyleSheet.create({ justifyContent: 'center', margin: spacing.xl, padding: spacing.md, - backgroundColor: colors.card, borderRadius: borderRadius.lg, borderWidth: 1, - borderColor: colors.danger, }, logoutButtonTablet: { maxWidth: 600, @@ -1003,7 +1069,6 @@ const styles = StyleSheet.create({ }, logoutText: { marginLeft: spacing.sm, - color: colors.danger, fontSize: 16, fontWeight: '500', }, @@ -1014,7 +1079,6 @@ const styles = StyleSheet.create({ justifyContent: 'flex-end', }, modalContent: { - backgroundColor: colors.card, borderTopLeftRadius: 20, borderTopRightRadius: 20, maxHeight: '90%', @@ -1025,12 +1089,10 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', padding: spacing.lg, borderBottomWidth: 1, - borderBottomColor: colors.border, }, modalTitle: { fontSize: 18, fontWeight: 'bold', - color: colors.text, }, modalBody: { padding: spacing.lg, @@ -1041,35 +1103,22 @@ const styles = StyleSheet.create({ modalLabel: { fontSize: 13, fontWeight: '600', - color: colors.textSecondary, marginBottom: spacing.xs, }, modalInput: { borderWidth: 1, - borderColor: colors.border, borderRadius: borderRadius.lg, padding: spacing.md, fontSize: 16, - color: colors.text, }, modalFeedback: { padding: spacing.md, borderRadius: borderRadius.lg, marginBottom: spacing.md, - }, - modalFeedbackOk: { - backgroundColor: '#eaf7ec', borderWidth: 1, - borderColor: '#cfead0', - }, - modalFeedbackErr: { - backgroundColor: colors.dangerContainer, - borderWidth: 1, - borderColor: '#fecaca', }, modalFeedbackText: { fontSize: 14, - color: colors.text, }, modalActions: { flexDirection: 'row', @@ -1082,14 +1131,12 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.lg, borderRadius: borderRadius.lg, borderWidth: 1, - borderColor: colors.border, }, modalCancelText: { fontSize: 16, - color: colors.text, }, modalSaveBtn: { - backgroundColor: colors.primary, + backgroundColor: '#7fbf8f', paddingVertical: spacing.md, paddingHorizontal: spacing.xl, borderRadius: borderRadius.lg, @@ -1100,13 +1147,12 @@ const styles = StyleSheet.create({ opacity: 0.6, }, modalSaveText: { - color: colors.textInverse, + color: '#ffffff', fontSize: 16, fontWeight: '600', }, // Avatar Modal styles avatarModalContent: { - backgroundColor: colors.card, borderTopLeftRadius: 20, borderTopRightRadius: 20, maxHeight: '80%', @@ -1114,7 +1160,6 @@ const styles = StyleSheet.create({ tabContainer: { flexDirection: 'row', borderBottomWidth: 1, - borderBottomColor: colors.border, }, tabButton: { flex: 1, @@ -1124,17 +1169,8 @@ const styles = StyleSheet.create({ paddingVertical: spacing.md, gap: spacing.xs, }, - tabButtonActive: { - borderBottomWidth: 2, - borderBottomColor: colors.primary, - }, tabText: { fontSize: 14, - color: colors.textSecondary, - }, - tabTextActive: { - color: colors.primary, - fontWeight: '600', }, tabContent: { padding: spacing.lg, @@ -1161,16 +1197,13 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', padding: spacing.md, - backgroundColor: colors.surfaceLow, borderRadius: borderRadius.lg, borderWidth: 1, - borderColor: colors.border, }, uploadIconContainer: { width: 56, height: 56, borderRadius: 28, - backgroundColor: colors.card, justifyContent: 'center', alignItems: 'center', marginRight: spacing.md, @@ -1178,18 +1211,15 @@ const styles = StyleSheet.create({ uploadOptionTitle: { fontSize: 16, fontWeight: '600', - color: colors.text, flex: 1, }, uploadOptionSubtitle: { fontSize: 13, - color: colors.textSecondary, flex: 1, }, // Addresses addressLoadingText: { textAlign: 'center', - color: colors.textSecondary, fontSize: 14, paddingVertical: spacing.lg, }, @@ -1201,14 +1231,8 @@ const styles = StyleSheet.create({ alignItems: 'flex-start', justifyContent: 'space-between', padding: spacing.md, - backgroundColor: colors.surfaceLow, borderRadius: borderRadius.lg, borderWidth: 1, - borderColor: colors.border, - }, - addressItemDefault: { - borderColor: colors.primary, - backgroundColor: '#eaf7ec', }, addressItemInfo: { flex: 1, @@ -1217,23 +1241,20 @@ const styles = StyleSheet.create({ addressLabel: { fontSize: 13, fontWeight: '700', - color: colors.primary, textTransform: 'uppercase', }, addressText: { fontSize: 14, - color: colors.text, }, addressBadge: { alignSelf: 'flex-start', - backgroundColor: colors.primary, borderRadius: borderRadius.full, paddingHorizontal: spacing.sm, paddingVertical: 2, marginTop: 4, }, addressBadgeText: { - color: colors.textInverse, + color: '#ffffff', fontSize: 11, fontWeight: '700', textTransform: 'uppercase', @@ -1241,7 +1262,6 @@ const styles = StyleSheet.create({ addressSetDefault: { fontSize: 12, fontWeight: '600', - color: colors.primary, marginTop: 4, textDecorationLine: 'underline', }, @@ -1265,14 +1285,12 @@ const styles = StyleSheet.create({ padding: spacing.md, marginTop: spacing.md, borderWidth: 2, - borderColor: colors.border, borderStyle: 'dashed', borderRadius: borderRadius.lg, }, addressAddBtnText: { fontSize: 15, fontWeight: '600', - color: colors.primary, }, addressDefaultCheckbox: { flexDirection: 'row', @@ -1282,6 +1300,5 @@ const styles = StyleSheet.create({ }, addressDefaultLabel: { fontSize: 14, - color: colors.text, }, }); diff --git a/apps/frontend-mobile/app/(tabs)/scan.tsx b/apps/frontend-mobile/app/(tabs)/scan.tsx index 8ddc659..7fcd15d 100644 --- a/apps/frontend-mobile/app/(tabs)/scan.tsx +++ b/apps/frontend-mobile/app/(tabs)/scan.tsx @@ -1,8 +1,10 @@ -import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions } from 'react-native'; +import React, { useState } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, TextInput, Alert, useWindowDimensions, ScrollView } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import { colors, spacing, borderRadius, shadows } from '../../constants/theme'; +import * as ImagePicker from 'expo-image-picker'; +import { useThemeContext } from '../../components/ThemeProvider'; +import { spacing, borderRadius, shadows } from '../../constants/theme'; const TABLET_MIN_WIDTH = 768; @@ -10,36 +12,174 @@ export default function ScanTabScreen() { const router = useRouter(); const { width } = useWindowDimensions(); const isTablet = width >= TABLET_MIN_WIDTH; + const { colors } = useThemeContext(); + const [manualNumber, setManualNumber] = useState(''); + const [selectedImage, setSelectedImage] = useState(null); + + const handleTakePhoto = async () => { + const { status } = await ImagePicker.requestCameraPermissionsAsync(); + if (status !== 'granted') { + Alert.alert( + 'Permiso requerido', + 'Necesitamos acceso a la cámara para tomar fotos del dispositivo.' + ); + return; + } + + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ['images'], + allowsEditing: true, + quality: 0.8, + }); + + if (!result.canceled && result.assets[0]) { + setSelectedImage(result.assets[0].uri); + } + }; + + const handleSelectFromGallery = async () => { + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== 'granted') { + Alert.alert( + 'Permiso requerido', + 'Necesitamos acceso a la galería para seleccionar fotos.' + ); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: true, + quality: 0.8, + }); + + if (!result.canceled && result.assets[0]) { + setSelectedImage(result.assets[0].uri); + } + }; + + const handleManualSubmit = () => { + const trimmed = manualNumber.trim(); + if (trimmed.length === 0) { + Alert.alert('Campo requerido', 'Por favor, introduce el número de la tarjeta.'); + return; + } + router.push(`/medicine/${trimmed}`); + }; return ( - + - + - Escanear TSI - + Escanear TSI + Escanea el código de barras de tu tarjeta sanitaria para encontrar tus medicamentos router.push('/scanner')} > - Iniciar escaneo + + Iniciar escaneo + + + {/* Separator */} + + + o + + + + {/* Option 2: Upload device photo */} + + + + + Subir foto del dispositivo + + + Toma o selecciona una foto del dispositivo sanitario + + + + + + + Tomar foto + + + + Galería + + + {selectedImage && ( + + + Foto seleccionada correctamente + + )} + + {/* Option 3: Enter card number manually */} + + + + + Introducir número manualmente + + + Escribe el número de tu tarjeta sanitaria + + + + + + + + + - + ); } const styles = StyleSheet.create({ - container: { + scrollContainer: { flex: 1, - backgroundColor: colors.background, - alignItems: 'center', - justifyContent: 'center', + }, + scrollContent: { + flexGrow: 1, + paddingVertical: spacing.xl, }, content: { alignItems: 'center', @@ -53,7 +193,6 @@ const styles = StyleSheet.create({ width: 100, height: 100, borderRadius: 50, - backgroundColor: colors.primaryContainer, alignItems: 'center', justifyContent: 'center', marginBottom: spacing.sm, @@ -66,14 +205,12 @@ const styles = StyleSheet.create({ title: { fontSize: 22, fontWeight: 'bold', - color: colors.text, }, titleTablet: { fontSize: 28, }, description: { fontSize: 15, - color: colors.textSecondary, textAlign: 'center', lineHeight: 22, maxWidth: 280, @@ -83,26 +220,128 @@ const styles = StyleSheet.create({ maxWidth: 420, lineHeight: 26, }, - button: { + primaryButton: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - backgroundColor: colors.scanButton, + backgroundColor: '#2b5bb5', borderRadius: borderRadius.lg, paddingVertical: spacing.md, paddingHorizontal: spacing.xl, - marginTop: spacing.md, + marginTop: spacing.sm, }, - buttonTablet: { + primaryButtonTablet: { paddingVertical: spacing.lg, paddingHorizontal: spacing.xl * 1.5, }, - buttonText: { + primaryButtonText: { fontSize: 17, fontWeight: '600', color: '#ffffff', }, - buttonTextTablet: { + primaryButtonTextTablet: { fontSize: 20, }, + separator: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + marginVertical: spacing.sm, + }, + separatorLine: { + flex: 1, + height: 1, + }, + separatorText: { + marginHorizontal: spacing.md, + fontSize: 14, + }, + optionCard: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + borderRadius: borderRadius.lg, + padding: spacing.md, + gap: spacing.md, + borderWidth: 1, + }, + optionCardTablet: { + padding: spacing.lg, + }, + optionTextContainer: { + flex: 1, + }, + optionTitle: { + fontSize: 16, + fontWeight: '600', + }, + optionTitleTablet: { + fontSize: 18, + }, + optionDescription: { + fontSize: 13, + marginTop: 2, + }, + optionDescriptionTablet: { + fontSize: 15, + }, + photoButtonsRow: { + flexDirection: 'row', + width: '100%', + gap: spacing.sm, + }, + photoButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + borderRadius: borderRadius.md, + paddingVertical: spacing.sm + 4, + borderWidth: 1, + }, + photoButtonTablet: { + paddingVertical: spacing.md, + }, + photoButtonText: { + fontSize: 14, + fontWeight: '500', + }, + imagePreviewContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.xs, + }, + imagePreviewText: { + fontSize: 13, + fontWeight: '500', + }, + manualInputRow: { + flexDirection: 'row', + width: '100%', + gap: spacing.sm, + }, + manualInput: { + flex: 1, + borderRadius: borderRadius.md, + borderWidth: 1, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 4, + fontSize: 15, + }, + manualInputTablet: { + fontSize: 18, + paddingVertical: spacing.md, + }, + manualSubmitButton: { + backgroundColor: '#7fbf8f', + borderRadius: borderRadius.md, + width: 48, + alignItems: 'center', + justifyContent: 'center', + }, + manualSubmitButtonTablet: { + width: 56, + }, }); diff --git a/apps/frontend-mobile/app/(tabs)/search.tsx b/apps/frontend-mobile/app/(tabs)/search.tsx new file mode 100644 index 0000000..7d5e4d3 --- /dev/null +++ b/apps/frontend-mobile/app/(tabs)/search.tsx @@ -0,0 +1,248 @@ +import React, { useState, useEffect } from 'react'; +import { View, FlatList, StyleSheet, Text, TouchableOpacity, useWindowDimensions } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useNavigation } from 'expo-router'; +import { SearchBar } from '../../components/SearchBar'; +import { MedicineCard } from '../../components/MedicineCard'; +import { LoadingSpinner } from '../../components/LoadingSpinner'; +import { useDebounce } from '../../hooks/useDebounce'; +import { useAuth } from '../../hooks/useAuth'; +import { useRecentSearches } from '../../hooks/useRecentSearches'; +import { searchMedicines } from '../../services/medicines'; +import { useThemeContext } from '../../components/ThemeProvider'; +import { spacing, borderRadius } from '../../constants/theme'; +import { Medicine } from '../../types'; +import { config } from '../../constants/config'; + +const TABLET_MIN_WIDTH = 768; + +const suggestions = [ + { name: 'Paracetamol', icon: 'medical' as const }, + { name: 'Ibuprofeno', icon: 'fitness' as const }, + { name: 'Aspirina', icon: 'heart' as const }, + { name: 'Omeprazol', icon: 'bandage' as const }, +]; + +export default function SearchScreen() { + const { width } = useWindowDimensions(); + const isTablet = width >= TABLET_MIN_WIDTH; + const { isAuthenticated } = useAuth(); + const { recentSearches, addSearch, removeSearch } = useRecentSearches(); + const { colors } = useThemeContext(); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const debouncedQuery = useDebounce(query, config.SEARCH_DEBOUNCE_MS); + const navigation = useNavigation(); + + useEffect(() => { + const parent = navigation.getParent(); + if (!parent) return; + const unsubscribe = parent.addListener('tabPress', () => { + setQuery(''); + setResults([]); + setIsLoading(false); + setError(null); + }); + return unsubscribe; + }, [navigation]); + + useEffect(() => { + if (debouncedQuery.length < 2) { + setResults([]); + return; + } + + const fetchResults = async () => { + setIsLoading(true); + setError(null); + try { + const data = await searchMedicines(debouncedQuery); + setResults(data); + } catch (err) { + setError('Error al buscar medicamentos'); + console.error(err); + } finally { + setIsLoading(false); + } + }; + + fetchResults(); + }, [debouncedQuery]); + + const handleSearch = (searchQuery: string) => { + setQuery(searchQuery); + if (searchQuery.trim()) addSearch(searchQuery); + }; + + const showSuggestions = !query && !isLoading && results.length === 0; + + return ( + + + + {showSuggestions && ( + <> + + Sugerencias + + {suggestions.map((s) => ( + handleSearch(s.name)} + activeOpacity={0.7} + > + + + + {s.name} + + ))} + + + + {isAuthenticated && recentSearches.length > 0 && ( + + Búsquedas recientes + {recentSearches.map((term) => ( + handleSearch(term)} + activeOpacity={0.7} + > + + {term} + removeSearch(term)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> + + + + ))} + + )} + + )} + + {isLoading && } + + {error && ( + + {error} + + )} + + {!isLoading && !error && results.length === 0 && query.length >= 2 && ( + + No se encontraron medicamentos + + )} + + item.nregistro} + renderItem={({ item }) => } + contentContainerStyle={[styles.list, isTablet && styles.listTablet]} + showsVerticalScrollIndicator={false} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + suggestionsSection: { + marginTop: spacing.sm, + alignSelf: 'center', + width: '80%', + }, + suggestionsSectionTablet: { + maxWidth: 700, + alignSelf: 'center', + width: '100%', + }, + sectionTitle: { + fontSize: 20, + fontWeight: '700', + marginBottom: spacing.md, + }, + suggestionsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + suggestionCard: { + width: '48%' as unknown as number, + borderRadius: borderRadius.lg, + borderWidth: 1, + padding: spacing.md, + gap: spacing.sm, + }, + suggestionIcon: { + width: 40, + height: 40, + borderRadius: borderRadius.md, + alignItems: 'center', + justifyContent: 'center', + }, + suggestionName: { + fontSize: 16, + fontWeight: '700', + }, + recentSection: { + marginTop: spacing.lg, + alignSelf: 'center', + width: '80%', + }, + recentSectionTablet: { + maxWidth: 700, + alignSelf: 'center', + width: '100%', + }, + recentItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.sm, + gap: spacing.sm, + }, + recentText: { + flex: 1, + fontSize: 15, + }, + list: { + paddingBottom: spacing.xl, + }, + listTablet: { + maxWidth: 700, + alignSelf: 'center', + width: '100%', + }, + errorContainer: { + padding: spacing.md, + marginHorizontal: spacing.lg, + borderRadius: borderRadius.lg, + }, + errorContainerTablet: { + maxWidth: 700, + alignSelf: 'center', + }, + errorText: { + textAlign: 'center', + fontSize: 14, + }, + emptyContainer: { + padding: spacing.xl, + alignItems: 'center', + }, + emptyText: { + fontSize: 16, + }, +}); diff --git a/apps/frontend-mobile/app/_layout.tsx b/apps/frontend-mobile/app/_layout.tsx index 6b105a4..2253c04 100644 --- a/apps/frontend-mobile/app/_layout.tsx +++ b/apps/frontend-mobile/app/_layout.tsx @@ -7,12 +7,13 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { useAuthStore } from '../store/authStore'; import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications'; -import { colors } from '../constants/theme'; +import { ThemeProvider, useThemeContext } from '../components/ThemeProvider'; const queryClient = new QueryClient(); -export default function RootLayout() { +function RootLayoutInner() { const { checkAuth } = useAuthStore(); + const { colors, isDark } = useThemeContext(); const notificationListener = useRef>(); const responseListener = useRef>(); @@ -35,6 +36,52 @@ export default function RootLayout() { }; }, []); + return ( + <> + + + + + + + + + + + ); +} + +export default function RootLayout() { return ( - - - - - - - - - + + + diff --git a/apps/frontend-mobile/app/auth/login.tsx b/apps/frontend-mobile/app/auth/login.tsx index 7d2dda1..aa44c67 100644 --- a/apps/frontend-mobile/app/auth/login.tsx +++ b/apps/frontend-mobile/app/auth/login.tsx @@ -12,7 +12,8 @@ import { import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useAuthStore } from '../../store/authStore'; -import { colors, spacing, borderRadius } from '../../constants/theme'; +import { useThemeContext } from '../../components/ThemeProvider'; +import { spacing, borderRadius } from '../../constants/theme'; import { isBiometricsAvailable, authenticateWithBiometrics, @@ -23,6 +24,7 @@ import { export default function LoginScreen() { const router = useRouter(); const { login } = useAuthStore(); + const { colors } = useThemeContext(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -55,7 +57,6 @@ export default function LoginScreen() { setIsLoading(true); try { await login(username, password); - // Save username for biometric login if (biometricsAvailable) { await saveBiometricCredentials(username); } @@ -77,8 +78,6 @@ export default function LoginScreen() { try { const authenticated = await authenticateWithBiometrics(); if (authenticated) { - // Use saved username with empty password for biometric login - // The backend should handle biometric authentication differently await login(biometricUsername, ''); router.replace('/(tabs)'); } else { @@ -93,17 +92,17 @@ export default function LoginScreen() { return ( - Iniciar Sesión - Ingresa tus credenciales para continuar + Iniciar Sesión + Ingresa tus credenciales para continuar - Usuario + Usuario - Contraseña + Contraseña - Iniciar con biometría + Iniciar con biometría )} @@ -150,7 +149,7 @@ export default function LoginScreen() { style={styles.linkButton} onPress={() => router.push('/auth/register')} > - ¿No tienes cuenta? Regístrate + ¿No tienes cuenta? Regístrate @@ -160,7 +159,6 @@ export default function LoginScreen() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.background, }, form: { flex: 1, @@ -170,12 +168,10 @@ const styles = StyleSheet.create({ title: { fontSize: 28, fontWeight: 'bold', - color: colors.text, marginBottom: spacing.sm, }, subtitle: { fontSize: 16, - color: colors.textSecondary, marginBottom: spacing.xl, }, inputContainer: { @@ -184,18 +180,15 @@ const styles = StyleSheet.create({ label: { fontSize: 14, fontWeight: '500', - color: colors.text, marginBottom: spacing.xs, }, input: { - backgroundColor: colors.card, borderRadius: borderRadius.md, padding: spacing.md, fontSize: 16, - color: colors.text, }, button: { - backgroundColor: colors.primary, + backgroundColor: '#7fbf8f', borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center', @@ -205,7 +198,7 @@ const styles = StyleSheet.create({ opacity: 0.6, }, buttonText: { - color: colors.textInverse, + color: '#ffffff', fontSize: 16, fontWeight: '600', }, @@ -214,22 +207,18 @@ const styles = StyleSheet.create({ alignItems: 'center', }, linkText: { - color: colors.primary, fontSize: 14, }, biometricButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - backgroundColor: colors.card, borderRadius: borderRadius.md, padding: spacing.md, marginTop: spacing.md, borderWidth: 1, - borderColor: colors.primary, }, biometricText: { - color: colors.primary, fontSize: 16, fontWeight: '600', marginLeft: spacing.sm, diff --git a/apps/frontend-mobile/app/auth/register.tsx b/apps/frontend-mobile/app/auth/register.tsx index eeffd21..9bd408a 100644 --- a/apps/frontend-mobile/app/auth/register.tsx +++ b/apps/frontend-mobile/app/auth/register.tsx @@ -11,10 +11,12 @@ import { } from 'react-native'; import { useRouter } from 'expo-router'; import { register } from '../../services/auth'; -import { colors, spacing, borderRadius } from '../../constants/theme'; +import { useThemeContext } from '../../components/ThemeProvider'; +import { spacing, borderRadius } from '../../constants/theme'; export default function RegisterScreen() { const router = useRouter(); + const { colors } = useThemeContext(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -44,17 +46,17 @@ export default function RegisterScreen() { return ( - Crear Cuenta - Regístrate para empezar + Crear Cuenta + Regístrate para empezar - Usuario + Usuario - Contraseña + Contraseña - Confirmar Contraseña + Confirmar Contraseña router.back()} > - ¿Ya tienes cuenta? Inicia sesión + ¿Ya tienes cuenta? Inicia sesión @@ -112,7 +114,6 @@ export default function RegisterScreen() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.background, }, form: { flex: 1, @@ -122,12 +123,10 @@ const styles = StyleSheet.create({ title: { fontSize: 28, fontWeight: 'bold', - color: colors.text, marginBottom: spacing.sm, }, subtitle: { fontSize: 16, - color: colors.textSecondary, marginBottom: spacing.xl, }, inputContainer: { @@ -136,18 +135,15 @@ const styles = StyleSheet.create({ label: { fontSize: 14, fontWeight: '500', - color: colors.text, marginBottom: spacing.xs, }, input: { - backgroundColor: colors.card, borderRadius: borderRadius.md, padding: spacing.md, fontSize: 16, - color: colors.text, }, button: { - backgroundColor: colors.primary, + backgroundColor: '#7fbf8f', borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center', @@ -157,7 +153,7 @@ const styles = StyleSheet.create({ opacity: 0.6, }, buttonText: { - color: colors.textInverse, + color: '#ffffff', fontSize: 16, fontWeight: '600', }, @@ -166,7 +162,6 @@ const styles = StyleSheet.create({ alignItems: 'center', }, linkText: { - color: colors.primary, fontSize: 14, }, }); diff --git a/apps/frontend-mobile/app/medicine/[id].tsx b/apps/frontend-mobile/app/medicine/[id].tsx index ffb8630..07da0c7 100644 --- a/apps/frontend-mobile/app/medicine/[id].tsx +++ b/apps/frontend-mobile/app/medicine/[id].tsx @@ -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(null); const [pharmacies, setPharmacies] = useState([]); 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(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 ( - - Medicamento no encontrado + + Medicamento no encontrado ); } return ( - - - {medicine.name} + + + {medicine.name} {medicine.stock != null && } - - - - - + + + + + - + - - - Farmacias ({pharmacies.length}) - + {locatedPharmacies.length > 0 && ( + + + + Mapa próximamente… + + + )} - {pharmacies.length === 0 ? ( - No hay farmacias con este medicamento + + + + Farmacias ({sortedPharmacies.length}) + + + + + {locating + ? 'Localizando…' + : sortByDistance + ? 'Distancia · Reset' + : 'Ordenar por distancia'} + + + + + {locationError && ( + + {locationError} + + Reintentar + + + )} + + {sortedPharmacies.length === 0 ? ( + No hay farmacias disponibles ) : ( - pharmacies.map((pharm) => ( - - router.push(`/pharmacy/${pharm.pharmacy_id}`)} - > - - {pharm.pharmacy?.name} - {pharm.pharmacy?.address} - - - {pharm.price.toFixed(2)} € - Stock: {pharm.stock} - - - {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 ( + handleDirections(pharm.pharmacy.latitude, pharm.pharmacy.longitude)} + style={styles.pharmacyCardContent} + onPress={() => router.push(`/pharmacy/${pharm.pharmacy_id || pharm.id}`)} > - - Cómo llegar + + + {pharm.pharmacy?.name || pharm.name} + {distanceKm != null && ( + {formatDistance(distanceKm)} + )} + + {pharm.pharmacy?.address || pharm.address} + + + {pharm.price != null ? ( + {pharm.price.toFixed(2)} € + ) : ( + Consultar precio + )} + {pharm.stock > 0 && ( + Stock: {pharm.stock} + )} + - )} - - )) + {lat != null && lon != null && ( + handleDirections(lat, lon)} + > + + Cómo llegar + + )} + + ); + }) )} ); } -function InfoRow({ label, value }: { label: string; value: string }) { +function InfoRow({ label, value, colors }: { label: string; value: string; colors: any }) { return ( - - {label} - {value} + + {label} + {value} ); } @@ -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, }, }); diff --git a/apps/frontend-mobile/app/pharmacy/[id].tsx b/apps/frontend-mobile/app/pharmacy/[id].tsx index 03c8d06..6aafcca 100644 --- a/apps/frontend-mobile/app/pharmacy/[id].tsx +++ b/apps/frontend-mobile/app/pharmacy/[id].tsx @@ -5,12 +5,14 @@ import { Ionicons } from '@expo/vector-icons'; import MapView, { Marker } from 'react-native-maps'; import { getPharmacy, getPharmacyMedicines } from '../../services/pharmacies'; import { LoadingSpinner } from '../../components/LoadingSpinner'; -import { colors, spacing, borderRadius } from '../../constants/theme'; +import { useThemeContext } from '../../components/ThemeProvider'; +import { spacing, borderRadius } from '../../constants/theme'; import { Pharmacy, PharmacyMedicine } from '../../types'; export default function PharmacyDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); + const { colors } = useThemeContext(); const [pharmacy, setPharmacy] = useState(null); const [medicines, setMedicines] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -55,40 +57,40 @@ export default function PharmacyDetailScreen() { if (!pharmacy) { return ( - - Farmacia no encontrada + + Farmacia no encontrada ); } return ( - - - {pharmacy.name} + + + {pharmacy.name} - + - Llamar + Llamar - Cómo llegar + Cómo llegar - + - {pharmacy.address} + {pharmacy.address} {pharmacy.phone && ( - {pharmacy.phone} + {pharmacy.phone} )} @@ -115,26 +117,26 @@ export default function PharmacyDetailScreen() { - + Medicamentos ({medicines.length}) {medicines.length === 0 ? ( - No hay medicamentos disponibles + No hay medicamentos disponibles ) : ( medicines.map((med) => ( router.push(`/medicine/${med.medicine_nregistro}`)} > - {med.medicine_name} - Reg: {med.medicine_nregistro} + {med.medicine_name} + Reg: {med.medicine_nregistro} - {med.price.toFixed(2)} € - Stock: {med.stock} + {med.price.toFixed(2)} € + Stock: {med.stock} )) @@ -147,24 +149,19 @@ export default function PharmacyDetailScreen() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.background, }, header: { padding: spacing.md, - backgroundColor: colors.card, }, name: { fontSize: 22, fontWeight: 'bold', - color: colors.text, }, actionsRow: { flexDirection: 'row', justifyContent: 'space-around', padding: spacing.md, - backgroundColor: colors.card, borderBottomWidth: 1, - borderBottomColor: colors.separator, }, actionButton: { flexDirection: 'row', @@ -173,12 +170,10 @@ const styles = StyleSheet.create({ }, actionText: { marginLeft: spacing.xs, - color: colors.primary, fontWeight: '500', }, infoSection: { padding: spacing.md, - backgroundColor: colors.card, marginTop: spacing.sm, }, infoRow: { @@ -190,7 +185,6 @@ const styles = StyleSheet.create({ flex: 1, marginLeft: spacing.sm, fontSize: 16, - color: colors.text, }, mapContainer: { height: 200, @@ -206,18 +200,15 @@ const styles = StyleSheet.create({ sectionTitle: { fontSize: 18, fontWeight: '600', - color: colors.text, marginBottom: spacing.md, }, noMedicines: { - color: colors.textSecondary, textAlign: 'center', padding: spacing.xl, }, medicineCard: { flexDirection: 'row', justifyContent: 'space-between', - backgroundColor: colors.card, borderRadius: borderRadius.md, padding: spacing.md, marginBottom: spacing.sm, @@ -228,11 +219,9 @@ const styles = StyleSheet.create({ medicineName: { fontSize: 16, fontWeight: '500', - color: colors.text, }, medicineNregistro: { fontSize: 12, - color: colors.textSecondary, marginTop: spacing.xs, }, medicineStock: { @@ -241,11 +230,9 @@ const styles = StyleSheet.create({ price: { fontSize: 16, fontWeight: '600', - color: colors.primary, }, stock: { fontSize: 12, - color: colors.textSecondary, marginTop: spacing.xs, }, errorContainer: { @@ -255,6 +242,5 @@ const styles = StyleSheet.create({ }, errorText: { fontSize: 16, - color: colors.textSecondary, }, }); diff --git a/apps/frontend-mobile/components/BarcodeScanner.tsx b/apps/frontend-mobile/components/BarcodeScanner.tsx index 2515d52..028bef1 100644 --- a/apps/frontend-mobile/components/BarcodeScanner.tsx +++ b/apps/frontend-mobile/components/BarcodeScanner.tsx @@ -2,7 +2,8 @@ import React, { useState } from 'react'; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; import { Ionicons } from '@expo/vector-icons'; -import { colors, spacing, borderRadius } from '../constants/theme'; +import { useThemeContext } from './ThemeProvider'; +import { spacing, borderRadius } from '../constants/theme'; interface BarcodeScannerProps { onBarcodeScanned: (barcode: string) => void; @@ -12,6 +13,7 @@ interface BarcodeScannerProps { export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProps) { const [permission, requestPermission] = useCameraPermissions(); const [scanned, setScanned] = useState(false); + const { colors } = useThemeContext(); if (!permission) { return ; @@ -19,17 +21,17 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp if (!permission.granted) { return ( - + - Permiso de cámara requerido - + Permiso de cámara requerido + Necesitamos acceso a la cámara para escanear códigos de barras - + Conceder permiso - Cancelar + Cancelar ); @@ -54,10 +56,10 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp - - - - + + + + @@ -72,7 +74,7 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp {scanned && ( setScanned(false)} > Escanear de nuevo @@ -92,30 +94,26 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: 'center', alignItems: 'center', - backgroundColor: colors.background, padding: spacing.xl, }, permissionTitle: { fontSize: 20, fontWeight: 'bold', - color: colors.text, marginTop: spacing.lg, }, permissionText: { fontSize: 16, - color: colors.textSecondary, textAlign: 'center', marginTop: spacing.sm, marginBottom: spacing.xl, }, permissionButton: { - backgroundColor: colors.primary, borderRadius: borderRadius.md, paddingVertical: spacing.md, paddingHorizontal: spacing.xl, }, permissionButtonText: { - color: colors.textInverse, + color: '#ffffff', fontSize: 16, fontWeight: '600', }, @@ -123,7 +121,6 @@ const styles = StyleSheet.create({ marginTop: spacing.md, }, cancelButtonText: { - color: colors.textSecondary, fontSize: 14, }, overlay: { @@ -143,7 +140,6 @@ const styles = StyleSheet.create({ position: 'absolute', width: 30, height: 30, - borderColor: colors.primary, }, topLeft: { top: 0, @@ -170,7 +166,7 @@ const styles = StyleSheet.create({ borderRightWidth: 3, }, instruction: { - color: colors.textInverse, + color: '#ffffff', fontSize: 14, textAlign: 'center', marginTop: spacing.xl, @@ -194,13 +190,12 @@ const styles = StyleSheet.create({ right: spacing.xl, }, scanAgainButton: { - backgroundColor: colors.primary, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center', }, scanAgainText: { - color: colors.textInverse, + color: '#ffffff', fontSize: 16, fontWeight: '600', }, diff --git a/apps/frontend-mobile/components/LoadingSpinner.tsx b/apps/frontend-mobile/components/LoadingSpinner.tsx index ad24212..4a536ef 100644 --- a/apps/frontend-mobile/components/LoadingSpinner.tsx +++ b/apps/frontend-mobile/components/LoadingSpinner.tsx @@ -1,16 +1,19 @@ import React from 'react'; import { View, ActivityIndicator, Text, StyleSheet } from 'react-native'; -import { colors, spacing } from '../constants/theme'; +import { useThemeContext } from './ThemeProvider'; +import { spacing } from '../constants/theme'; interface LoadingSpinnerProps { message?: string; } export function LoadingSpinner({ message = 'Cargando...' }: LoadingSpinnerProps) { + const { colors } = useThemeContext(); + return ( - {message} + {message} ); } @@ -25,6 +28,5 @@ const styles = StyleSheet.create({ message: { marginTop: spacing.md, fontSize: 16, - color: colors.textSecondary, }, }); diff --git a/apps/frontend-mobile/components/MedicineCard.tsx b/apps/frontend-mobile/components/MedicineCard.tsx index 2f077e2..993d4c0 100644 --- a/apps/frontend-mobile/components/MedicineCard.tsx +++ b/apps/frontend-mobile/components/MedicineCard.tsx @@ -2,7 +2,8 @@ import React from 'react'; import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions } from 'react-native'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; -import { colors, spacing, borderRadius } from '../constants/theme'; +import { useThemeContext } from './ThemeProvider'; +import { spacing, borderRadius } from '../constants/theme'; import { StockBadge } from './StockBadge'; import { Medicine } from '../types'; @@ -16,6 +17,7 @@ export function MedicineCard({ medicine }: MedicineCardProps) { const router = useRouter(); const { width } = useWindowDimensions(); const isTablet = width >= TABLET_MIN_WIDTH; + const { colors } = useThemeContext(); const handlePress = () => { router.push(`/medicine/${medicine.nregistro}`); @@ -23,32 +25,32 @@ export function MedicineCard({ medicine }: MedicineCardProps) { return ( - + {medicine.name} {medicine.stock != null && } - + {medicine.active_ingredient}{medicine.dosage ? ` - ${medicine.dosage}` : ''} - + {medicine.precio != null ? `${medicine.precio.toFixed(2)} €` : 'Sin precio'} - + {medicine.laboratory} @@ -59,7 +61,6 @@ export function MedicineCard({ medicine }: MedicineCardProps) { const styles = StyleSheet.create({ card: { - backgroundColor: colors.card, borderRadius: borderRadius.lg, padding: spacing.md, marginHorizontal: spacing.lg, @@ -86,7 +87,6 @@ const styles = StyleSheet.create({ flex: 1, fontSize: 16, fontWeight: '600', - color: colors.text, marginRight: spacing.sm, }, nameTablet: { @@ -94,7 +94,6 @@ const styles = StyleSheet.create({ }, principioActivo: { fontSize: 14, - color: colors.textSecondary, marginBottom: spacing.sm, }, footer: { @@ -109,7 +108,6 @@ const styles = StyleSheet.create({ price: { fontSize: 14, fontWeight: '600', - color: colors.primary, marginLeft: spacing.xs, }, priceTablet: { @@ -123,7 +121,6 @@ const styles = StyleSheet.create({ }, laboratorio: { fontSize: 12, - color: colors.textSecondary, marginLeft: spacing.xs, maxWidth: 120, }, diff --git a/apps/frontend-mobile/components/SearchBar.tsx b/apps/frontend-mobile/components/SearchBar.tsx index 09a7bc0..7f37650 100644 --- a/apps/frontend-mobile/components/SearchBar.tsx +++ b/apps/frontend-mobile/components/SearchBar.tsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; import { View, TextInput, StyleSheet, TouchableOpacity, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { colors, spacing, borderRadius } from '../constants/theme'; +import { useThemeContext } from './ThemeProvider'; +import { spacing, borderRadius } from '../constants/theme'; const TABLET_MIN_WIDTH = 768; @@ -20,6 +21,7 @@ export function SearchBar({ }: SearchBarProps) { const { width } = useWindowDimensions(); const isTablet = width >= TABLET_MIN_WIDTH; + const { colors } = useThemeContext(); const [localValue, setLocalValue] = useState(value || ''); const handleChange = (text: string) => { @@ -38,10 +40,10 @@ export function SearchBar({ }; return ( - + { - if (stock === 0) return styles.danger; - if (stock < 5) return styles.warning; - return styles.success; + const { colors, isDark } = useThemeContext(); + + const getBadgeColors = () => { + if (stock === 0) { + return { bg: isDark ? '#3a1a1a' : '#feecec', text: isDark ? '#ef9a9a' : '#b91c1c' }; + } + if (stock < 5) { + return { bg: isDark ? '#3a3010' : '#fff3cd', text: isDark ? '#ffd54f' : '#FF9500' }; + } + return { bg: isDark ? '#1a3a1c' : '#eaf7ec', text: isDark ? '#81c784' : '#34C759' }; }; - const getText = () => { - if (stock === 0) return 'Sin stock'; - if (stock < 5) return `Bajo (${stock})`; - return `Disponible (${stock})`; - }; + const badgeColors = getBadgeColors(); return ( - - {getText()} + + + {stock === 0 ? 'Sin stock' : stock < 5 ? `Bajo (${stock})` : `Disponible (${stock})`} + ); } @@ -32,15 +37,6 @@ const styles = StyleSheet.create({ paddingVertical: spacing.xs, borderRadius: borderRadius.sm, }, - success: { - backgroundColor: '#eaf7ec', - }, - warning: { - backgroundColor: '#fff3cd', - }, - danger: { - backgroundColor: '#feecec', - }, text: { fontSize: 12, fontWeight: '600', diff --git a/apps/frontend-mobile/components/ThemeProvider.tsx b/apps/frontend-mobile/components/ThemeProvider.tsx new file mode 100644 index 0000000..2affcfb --- /dev/null +++ b/apps/frontend-mobile/components/ThemeProvider.tsx @@ -0,0 +1,43 @@ +import React, { createContext, useContext, useEffect, useMemo } from 'react'; +import { useColorScheme } from 'react-native'; +import { useThemeStore } from '../store/themeStore'; +import { colors, darkColors } from '../constants/theme'; +import type { AppColors } from '../hooks/useThemeColor'; + +interface ThemeContextValue { + colors: AppColors; + isDark: boolean; +} + +const ThemeContext = createContext({ + colors, + isDark: false, +}); + +export function useThemeContext() { + return useContext(ThemeContext); +} + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const systemScheme = useColorScheme(); + const mode = useThemeStore((s) => s.mode); + const init = useThemeStore((s) => s.init); + + useEffect(() => { + init(); + }, []); + + const value = useMemo(() => { + const isDark = mode === 'dark' || (mode === 'system' && systemScheme === 'dark'); + return { + colors: isDark ? darkColors : colors, + isDark, + }; + }, [mode, systemScheme]); + + return ( + + {children} + + ); +} diff --git a/apps/frontend-mobile/constants/theme.ts b/apps/frontend-mobile/constants/theme.ts index 60ac6fb..d5899b7 100644 --- a/apps/frontend-mobile/constants/theme.ts +++ b/apps/frontend-mobile/constants/theme.ts @@ -44,6 +44,52 @@ export const colors = { accentWarm: '#f5a97a', }; +export const darkColors = { + // Primary - pastel green pharmacy palette + primary: '#7fbf8f', + primaryLight: '#2a5a35', + primaryDark: '#cfead0', + onPrimary: '#cfead0', + primaryContainer: '#1a3a1c', + onPrimaryContainer: '#cfead0', + + // Secondary - soft blue + secondary: '#a3b8ff', + secondaryContainer: '#1a2a4a', + + // Tertiary - soft lavender + tertiary: '#c9b3ff', + tertiaryContainer: '#2a1a4a', + + // Scan button - vivid blue + scanButton: '#4a7dd5', + scanButtonShadow: 'rgba(74, 125, 213, 0.32)', + + // Semantic + success: '#34C759', + danger: '#ef5350', + dangerContainer: '#3a1a1a', + warning: '#FF9500', + + // Surface + background: '#121212', + card: '#1e1e1e', + surfaceLow: '#2a2a2a', + surface: '#333333', + surfaceHigh: '#3a3a3a', + border: '#3a3a3a', + separator: '#3a3a3a', + + // Text + text: '#f0f0f0', + textSecondary: '#a0a0a0', + textInverse: '#111417', + textLink: '#7fbf8f', + + // Accent + accentWarm: '#f5a97a', +}; + export const spacing = { xs: 4, sm: 8, diff --git a/apps/frontend-mobile/hooks/useRecentSearches.ts b/apps/frontend-mobile/hooks/useRecentSearches.ts new file mode 100644 index 0000000..4bccc9e --- /dev/null +++ b/apps/frontend-mobile/hooks/useRecentSearches.ts @@ -0,0 +1,41 @@ +import { useState, useEffect, useCallback } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const STORAGE_KEY = 'recent_medicine_searches'; +const MAX_ITEMS = 5; + +export function useRecentSearches() { + const [recentSearches, setRecentSearches] = useState([]); + + useEffect(() => { + AsyncStorage.getItem(STORAGE_KEY).then((data) => { + if (data) setRecentSearches(JSON.parse(data)); + }); + }, []); + + const addSearch = useCallback(async (query: string) => { + const trimmed = query.trim(); + if (!trimmed) return; + setRecentSearches((prev) => { + const filtered = prev.filter((s) => s !== trimmed); + const next = [trimmed, ...filtered].slice(0, MAX_ITEMS); + AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + return next; + }); + }, []); + + const removeSearch = useCallback(async (query: string) => { + setRecentSearches((prev) => { + const next = prev.filter((s) => s !== query); + AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + return next; + }); + }, []); + + const clearAll = useCallback(async () => { + setRecentSearches([]); + AsyncStorage.removeItem(STORAGE_KEY); + }, []); + + return { recentSearches, addSearch, removeSearch, clearAll }; +} diff --git a/apps/frontend-mobile/hooks/useThemeColor.ts b/apps/frontend-mobile/hooks/useThemeColor.ts new file mode 100644 index 0000000..8d273a4 --- /dev/null +++ b/apps/frontend-mobile/hooks/useThemeColor.ts @@ -0,0 +1,19 @@ +import { useColorScheme } from 'react-native'; +import { colors, darkColors } from '../constants/theme'; +import { useThemeStore, ThemeMode } from '../store/themeStore'; + +export type AppColors = typeof colors; + +export function useThemeColor(): { colors: AppColors; isDark: boolean; mode: ThemeMode } { + const systemScheme = useColorScheme(); + const mode = useThemeStore((s) => s.mode); + + const isDark = + mode === 'dark' || (mode === 'system' && systemScheme === 'dark'); + + return { + colors: isDark ? darkColors : colors, + isDark, + mode, + }; +} diff --git a/apps/frontend-mobile/package.json b/apps/frontend-mobile/package.json index d98ba41..29d4827 100644 --- a/apps/frontend-mobile/package.json +++ b/apps/frontend-mobile/package.json @@ -16,6 +16,7 @@ "expo-image-picker": "~57.0.2", "expo-linking": "~57.0.1", "expo-local-authentication": "~57.0.0", + "expo-location": "~57.0.2", "expo-notifications": "~57.0.3", "expo-router": "~57.0.3", "expo-secure-store": "~57.0.0", diff --git a/apps/frontend-mobile/services/api.ts b/apps/frontend-mobile/services/api.ts index 592f0fb..15956d1 100644 --- a/apps/frontend-mobile/services/api.ts +++ b/apps/frontend-mobile/services/api.ts @@ -2,32 +2,43 @@ import axios from 'axios'; import * as SecureStore from 'expo-secure-store'; import { config } from '../constants/config'; +const SESSION_KEY = 'session_id'; + export const api = axios.create({ baseURL: config.API_BASE_URL, timeout: 10000, + withCredentials: true, headers: { 'Content-Type': 'application/json', }, }); -// Request interceptor - add auth token +// Request interceptor - restore session cookie from SecureStore api.interceptors.request.use( async (axiosConfig) => { - const token = await SecureStore.getItemAsync('auth_token'); - if (token) { - axiosConfig.headers.Authorization = `Bearer ${token}`; + const session = await SecureStore.getItemAsync(SESSION_KEY); + if (session) { + axiosConfig.headers.Cookie = session; } return axiosConfig; }, (error) => Promise.reject(error) ); -// Response interceptor - handle errors +// Response interceptor - capture and persist session cookie from responses api.interceptors.response.use( - (response) => response, + (response) => { + const setCookie = response.headers['set-cookie']; + if (setCookie) { + const cookieStr = Array.isArray(setCookie) ? setCookie[0] : setCookie; + const sessionId = cookieStr.split(';')[0]; + SecureStore.setItemAsync(SESSION_KEY, sessionId); + } + return response; + }, async (error) => { if (error.response?.status === 401) { - await SecureStore.deleteItemAsync('auth_token'); + await SecureStore.deleteItemAsync(SESSION_KEY); } return Promise.reject(error); } diff --git a/apps/frontend-mobile/services/auth.ts b/apps/frontend-mobile/services/auth.ts index 0ed8d4b..cfc44d6 100644 --- a/apps/frontend-mobile/services/auth.ts +++ b/apps/frontend-mobile/services/auth.ts @@ -2,42 +2,44 @@ import api from './api'; import * as SecureStore from 'expo-secure-store'; import { AuthResponse, User } from '../types'; +const USER_KEY = 'user_data'; + export async function login(username: string, password: string): Promise { const response = await api.post('/auth/login', { username, password }); - const { user, token } = response.data; + const { user } = response.data; - await SecureStore.setItemAsync('auth_token', token); - await SecureStore.setItemAsync('user', JSON.stringify(user)); + await SecureStore.setItemAsync(USER_KEY, JSON.stringify(user)); return response.data; } export async function logout(): Promise { await api.post('/auth/logout'); - await SecureStore.deleteItemAsync('auth_token'); - await SecureStore.deleteItemAsync('user'); + await SecureStore.deleteItemAsync(USER_KEY); } export async function checkAuth(): Promise { try { const response = await api.get('/auth/check'); - return response.data.user; + if (response.data.authenticated) { + return response.data.user; + } + return null; } catch { return null; } } export async function getStoredUser(): Promise { - const userStr = await SecureStore.getItemAsync('user'); + const userStr = await SecureStore.getItemAsync(USER_KEY); return userStr ? JSON.parse(userStr) : null; } export async function register(username: string, password: string): Promise { const response = await api.post('/auth/register', { username, password }); - const { user, token } = response.data; + const { user } = response.data; - await SecureStore.setItemAsync('auth_token', token); - await SecureStore.setItemAsync('user', JSON.stringify(user)); + await SecureStore.setItemAsync(USER_KEY, JSON.stringify(user)); return response.data; } diff --git a/apps/frontend-mobile/store/themeStore.ts b/apps/frontend-mobile/store/themeStore.ts new file mode 100644 index 0000000..b1d2fa7 --- /dev/null +++ b/apps/frontend-mobile/store/themeStore.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export type ThemeMode = 'light' | 'dark' | 'system'; + +interface ThemeState { + mode: ThemeMode; + setMode: (mode: ThemeMode) => Promise; + init: () => Promise; +} + +const STORAGE_KEY = '@theme_mode'; + +export const useThemeStore = create((set) => ({ + mode: 'system', + + setMode: async (mode: ThemeMode) => { + set({ mode }); + await AsyncStorage.setItem(STORAGE_KEY, mode); + }, + + init: async () => { + try { + const saved = await AsyncStorage.getItem(STORAGE_KEY); + if (saved === 'light' || saved === 'dark' || saved === 'system') { + set({ mode: saved }); + } + } catch {} + }, +})); diff --git a/apps/frontend-mobile/types/index.ts b/apps/frontend-mobile/types/index.ts index 6338cb7..a08fb62 100644 --- a/apps/frontend-mobile/types/index.ts +++ b/apps/frontend-mobile/types/index.ts @@ -24,15 +24,22 @@ export interface Pharmacy { phone: string; latitude: number; longitude: number; + opening_hours?: string; } export interface PharmacyMedicine { id: number; - pharmacy_id: number; - medicine_nregistro: string; - medicine_name: string; - price: number; - stock: number; + pharmacy_id?: number; + medicine_nregistro?: string; + medicine_name?: string; + name?: string; + address?: string; + phone?: string; + latitude?: number; + longitude?: number; + opening_hours?: string; + price?: number | null; + stock?: number; pharmacy?: Pharmacy; } diff --git a/package-lock.json b/package-lock.json index 1ac7e95..87160c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,6 +111,7 @@ "expo-image-picker": "~57.0.2", "expo-linking": "~57.0.1", "expo-local-authentication": "~57.0.0", + "expo-location": "~57.0.2", "expo-notifications": "~57.0.3", "expo-router": "~57.0.3", "expo-secure-store": "~57.0.0", @@ -792,6 +793,18 @@ "expo": "*" } }, + "apps/frontend-mobile/node_modules/expo-location": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-57.0.2.tgz", + "integrity": "sha512-yyize0BsBfZjgelBQdhWFH2JUh9OLJOUt7fxz73Y70nbRV2rXZ7A9r0J6jfAa9lXqH7y/GDsFLqecfTc/PcFUQ==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.11.1" + }, + "peerDependencies": { + "expo": "*" + } + }, "apps/frontend-mobile/node_modules/expo-manifests": { "version": "57.0.0", "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-57.0.0.tgz",