From 0f27579ca067a14e488003551c5b92f84d92b41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 6 Jul 2026 10:35:34 +0200 Subject: [PATCH] docs: add FarmaFinder Mobile implementation plan --- ...07-06-farmafinder-mobile-implementation.md | 3417 +++++++++++++++++ 1 file changed, 3417 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-farmafinder-mobile-implementation.md diff --git a/docs/superpowers/plans/2026-07-06-farmafinder-mobile-implementation.md b/docs/superpowers/plans/2026-07-06-farmafinder-mobile-implementation.md new file mode 100644 index 0000000..4f01b25 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-farmafinder-mobile-implementation.md @@ -0,0 +1,3417 @@ +# FarmaFinder Mobile Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a React Native mobile frontend using Expo that provides native iOS/Android experience for FarmaFinder, with core features (medicine search, pharmacy map) plus native extras (push notifications, barcode scanner, biometrics). + +**Architecture:** Expo Router for file-based routing, Zustand for state management, TanStack Query for API caching, react-native-maps for pharmacy locations. Separate `frontend-mobile/` directory sharing only the backend API. + +**Tech Stack:** Expo SDK 52+, React Native 0.76+, Expo Router v4, Zustand, TanStack Query, Axios, react-native-maps, expo-camera, expo-notifications + +--- + +## File Structure Overview + +``` +FarmaFinder/ +├── frontend-mobile/ ← NEW +│ ├── app/ +│ │ ├── _layout.tsx ← Root layout (providers) +│ │ ├── (tabs)/ +│ │ │ ├── _layout.tsx ← Tab navigator +│ │ │ ├── index.tsx ← Home (search) +│ │ │ ├── map.tsx ← Map view +│ │ │ └── profile.tsx ← User profile +│ │ ├── medicine/[id].tsx ← Medicine detail +│ │ ├── pharmacy/[id].tsx ← Pharmacy detail +│ │ └── auth/ +│ │ ├── login.tsx ← Login screen +│ │ └── register.tsx ← Register screen +│ ├── components/ +│ │ ├── SearchBar.tsx ← Search input with debounce +│ │ ├── MedicineCard.tsx ← Medicine result card +│ │ ├── PharmacyMarker.tsx ← Custom map marker +│ │ ├── StockBadge.tsx ← Stock availability badge +│ │ └── LoadingSpinner.tsx ← Loading indicator +│ ├── services/ +│ │ ├── api.ts ← Axios client +│ │ ├── medicines.ts ← Medicine API calls +│ │ ├── pharmacies.ts ← Pharmacy API calls +│ │ └── auth.ts ← Auth API calls +│ ├── store/ +│ │ ├── authStore.ts ← Auth state (Zustand) +│ │ └── searchStore.ts ← Search state (Zustand) +│ ├── hooks/ +│ │ ├── useDebounce.ts ← Debounce hook +│ │ └── useAuth.ts ← Auth hook +│ ├── constants/ +│ │ ├── theme.ts ← Colors, spacing +│ │ └── config.ts ← API URLs, settings +│ ├── types/ +│ │ └── index.ts ← TypeScript types +│ ├── assets/ +│ │ └── icon.png ← App icon +│ ├── app.json ← Expo config +│ ├── eas.json ← EAS Build config +│ └── package.json ← Dependencies +├── frontend/ ← EXISTING (no changes) +├── backend/ ← EXISTING (no changes) +└── package.json ← MODIFY (add mobile scripts) +``` + +--- + +## Task 1: Initialize Expo Project + +**Files:** +- Create: `frontend-mobile/` (entire directory structure) +- Create: `frontend-mobile/package.json` +- Create: `frontend-mobile/app.json` +- Create: `frontend-mobile/tsconfig.json` +- Create: `frontend-mobile/babel.config.js` + +- [ ] **Step 1: Create project directory and initialize** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +mkdir -p frontend-mobile +cd frontend-mobile +npx create-expo-app@latest . --template blank-typescript +``` + +- [ ] **Step 2: Install core dependencies** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder/frontend-mobile +npx expo install expo-router react-native-safe-area-context react-native-screens expo-linking expo-constants expo-status-bar +npx expo install react-native-gesture-handler react-native-reanimated +``` + +- [ ] **Step 3: Install additional dependencies** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder/frontend-mobile +npm install zustand @tanstack/react-query axios +npm install react-native-maps +npx expo install expo-camera expo-notifications expo-local-authentication expo-secure-store @react-native-async-storage/async-storage +``` + +- [ ] **Step 4: Configure app.json** + +```json +{ + "expo": { + "name": "FarmaFinder", + "slug": "farmafinder", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "newArchEnabled": true, + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#007AFF" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.farmafinder.app" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#007AFF" + }, + "package": "com.farmafinder.app" + }, + "plugins": [ + "expo-router", + [ + "expo-camera", + { + "cameraPermission": "Allow FarmaFinder to access your camera for scanning barcodes" + } + ], + [ + "expo-notifications", + { + "icon": "./assets/notification-icon.png", + "color": "#007AFF" + } + ] + ], + "scheme": "farmafinder" + } +} +``` + +- [ ] **Step 5: Configure babel.config.js** + +```javascript +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: ['react-native-reanimated/plugin'], + }; +}; +``` + +- [ ] **Step 6: Configure tsconfig.json** + +```json +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] +} +``` + +- [ ] **Step 7: Verify project compiles** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder/frontend-mobile +npx expo start --clear +``` + +Expected: Expo dev server starts without errors + +- [ ] **Step 8: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/ +git commit -m "feat: initialize Expo project for mobile frontend" +``` + +--- + +## Task 2: Setup Navigation Structure + +**Files:** +- Create: `frontend-mobile/app/_layout.tsx` +- Create: `frontend-mobile/app/(tabs)/_layout.tsx` +- Create: `frontend-mobile/app/(tabs)/index.tsx` +- Create: `frontend-mobile/app/(tabs)/map.tsx` +- Create: `frontend-mobile/app/(tabs)/profile.tsx` + +- [ ] **Step 1: Create root layout** + +```typescript +// frontend-mobile/app/_layout.tsx +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; + +const queryClient = new QueryClient(); + +export default function RootLayout() { + return ( + + + + + + + + + + + + + ); +} +``` + +- [ ] **Step 2: Create tab navigator** + +```typescript +// frontend-mobile/app/(tabs)/_layout.tsx +import { Tabs } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; + +export default function TabLayout() { + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} +``` + +- [ ] **Step 3: Create Home screen (placeholder)** + +```typescript +// frontend-mobile/app/(tabs)/index.tsx +import { View, Text, StyleSheet } from 'react-native'; + +export default function HomeScreen() { + return ( + + FarmaFinder + Busca medicamentos + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#F2F2F7', + }, + title: { + fontSize: 28, + fontWeight: 'bold', + color: '#1C1C1E', + }, + subtitle: { + fontSize: 16, + color: '#8E8E93', + marginTop: 8, + }, +}); +``` + +- [ ] **Step 4: Create Map screen (placeholder)** + +```typescript +// frontend-mobile/app/(tabs)/map.tsx +import { View, Text, StyleSheet } from 'react-native'; + +export default function MapScreen() { + return ( + + Mapa de Farmacias + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#F2F2F7', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + color: '#1C1C1E', + }, +}); +``` + +- [ ] **Step 5: Create Profile screen (placeholder)** + +```typescript +// frontend-mobile/app/(tabs)/profile.tsx +import { View, Text, StyleSheet } from 'react-native'; + +export default function ProfileScreen() { + return ( + + Perfil + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#F2F2F7', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + color: '#1C1C1E', + }, +}); +``` + +- [ ] **Step 6: Update package.json with scripts** + +```json +{ + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web" + } +} +``` + +- [ ] **Step 7: Verify navigation works** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder/frontend-mobile +npx expo start +``` + +Expected: App shows with 3 tabs (Buscar, Mapa, Perfil) + +- [ ] **Step 8: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/ +git commit -m "feat: setup navigation structure with Expo Router" +``` + +--- + +## Task 3: Create TypeScript Types + +**Files:** +- Create: `frontend-mobile/types/index.ts` + +- [ ] **Step 1: Create types file** + +```typescript +// frontend-mobile/types/index.ts +export interface Medicine { + nregistro: string; + nombre: string; + principioActivo: string; + laboratorio: string; + formaFarmaceutica: string; + precio: number | null; + stock: number; + disponibilidad: 'disponible' | 'sin_stock' | 'bajo_stock'; +} + +export interface Pharmacy { + id: number; + name: string; + address: string; + phone: string; + latitude: number; + longitude: number; +} + +export interface PharmacyMedicine { + id: number; + pharmacy_id: number; + medicine_nregistro: string; + medicine_name: string; + price: number; + stock: number; + pharmacy?: Pharmacy; +} + +export interface User { + id: number; + username: string; + is_admin: boolean; +} + +export interface AuthResponse { + user: User; + token: string; +} + +export interface SearchResponse { + medicines: Medicine[]; + total: number; +} + +export interface ApiResponse { + data: T; + success: boolean; + error?: string; +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/types/ +git commit -m "feat: add TypeScript types for mobile app" +``` + +--- + +## Task 4: Create API Client and Services + +**Files:** +- Create: `frontend-mobile/constants/config.ts` +- Create: `frontend-mobile/services/api.ts` +- Create: `frontend-mobile/services/medicines.ts` +- Create: `frontend-mobile/services/pharmacies.ts` +- Create: `frontend-mobile/services/auth.ts` + +- [ ] **Step 1: Create config constants** + +```typescript +// frontend-mobile/constants/config.ts +import Constants from 'expo-constants'; + +const ENV = { + development: { + API_BASE_URL: 'http://localhost:3001/api', + }, + production: { + API_BASE_URL: 'https://your-production-api.com/api', + }, +}; + +const environment = Constants.expoConfig?.extra?.environment || (__DEV__ ? 'development' : 'production'); + +export const config = { + API_BASE_URL: ENV[environment as keyof typeof ENV]?.API_BASE_URL || ENV.development.API_BASE_URL, + SEARCH_DEBOUNCE_MS: 300, + CACHE_STALE_TIME: 5 * 60 * 1000, // 5 minutes + CACHE_CACHE_TIME: 30 * 60 * 1000, // 30 minutes +}; +``` + +- [ ] **Step 2: Create API client** + +```typescript +// frontend-mobile/services/api.ts +import axios from 'axios'; +import * as SecureStore from 'expo-secure-store'; +import { config } from '../constants/config'; + +export const api = axios.create({ + baseURL: config.API_BASE_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor - add auth token +api.interceptors.request.use( + async (axiosConfig) => { + const token = await SecureStore.getItemAsync('auth_token'); + if (token) { + axiosConfig.headers.Authorization = `Bearer ${token}`; + } + return axiosConfig; + }, + (error) => Promise.reject(error) +); + +// Response interceptor - handle errors +api.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response?.status === 401) { + await SecureStore.deleteItemAsync('auth_token'); + } + return Promise.reject(error); + } +); + +export default api; +``` + +- [ ] **Step 3: Create medicines service** + +```typescript +// frontend-mobile/services/medicines.ts +import api from './api'; +import { Medicine, PharmacyMedicine } from '../types'; + +export async function searchMedicines(query: string): Promise { + const response = await api.get(`/medicines/search?q=${encodeURIComponent(query)}`); + return response.data; +} + +export async function getMedicine(nregistro: string): Promise { + const response = await api.get(`/medicines/${nregistro}`); + return response.data; +} + +export async function getMedicinePharmacies(nregistro: string): Promise { + const response = await api.get(`/medicines/${nregistro}/pharmacies`); + return response.data; +} +``` + +- [ ] **Step 4: Create pharmacies service** + +```typescript +// frontend-mobile/services/pharmacies.ts +import api from './api'; +import { Pharmacy } from '../types'; + +export async function getPharmacies(): Promise { + const response = await api.get('/pharmacies'); + return response.data; +} + +export async function getPharmacy(id: number): Promise { + const response = await api.get(`/pharmacies/${id}`); + return response.data; +} + +export async function getPharmacyMedicines(id: number): Promise { + const response = await api.get(`/pharmacies/${id}/medicines`); + return response.data; +} +``` + +- [ ] **Step 5: Create auth service** + +```typescript +// frontend-mobile/services/auth.ts +import api from './api'; +import * as SecureStore from 'expo-secure-store'; +import { AuthResponse, User } from '../types'; + +export async function login(username: string, password: string): Promise { + const response = await api.post('/auth/login', { username, password }); + const { user, token } = response.data; + + await SecureStore.setItemAsync('auth_token', token); + await SecureStore.setItemAsync('user', 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'); +} + +export async function checkAuth(): Promise { + try { + const response = await api.get('/auth/check'); + return response.data.user; + } catch { + return null; + } +} + +export async function getStoredUser(): Promise { + const userStr = await SecureStore.getItemAsync('user'); + 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; + + await SecureStore.setItemAsync('auth_token', token); + await SecureStore.setItemAsync('user', JSON.stringify(user)); + + return response.data; +} +``` + +- [ ] **Step 6: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/ +git commit -m "feat: add API client and services for medicines, pharmacies, auth" +``` + +--- + +## Task 5: Create State Management (Zustand) + +**Files:** +- Create: `frontend-mobile/store/authStore.ts` +- Create: `frontend-mobile/store/searchStore.ts` +- Create: `frontend-mobile/hooks/useAuth.ts` + +- [ ] **Step 1: Create auth store** + +```typescript +// frontend-mobile/store/authStore.ts +import { create } from 'zustand'; +import { User } from '../types'; +import * as authService from '../services/auth'; + +interface AuthState { + user: User | null; + isLoading: boolean; + isAuthenticated: boolean; + login: (username: string, password: string) => Promise; + logout: () => Promise; + checkAuth: () => Promise; +} + +export const useAuthStore = create((set) => ({ + user: null, + isLoading: true, + isAuthenticated: false, + + login: async (username: string, password: string) => { + const { user } = await authService.login(username, password); + set({ user, isAuthenticated: true }); + }, + + logout: async () => { + await authService.logout(); + set({ user: null, isAuthenticated: false }); + }, + + checkAuth: async () => { + set({ isLoading: true }); + const user = await authService.checkAuth() || await authService.getStoredUser(); + set({ + user, + isAuthenticated: !!user, + isLoading: false + }); + }, +})); +``` + +- [ ] **Step 2: Create search store** + +```typescript +// frontend-mobile/store/searchStore.ts +import { create } from 'zustand'; +import { Medicine } from '../types'; + +interface SearchState { + query: string; + results: Medicine[]; + isLoading: boolean; + error: string | null; + setQuery: (query: string) => void; + setResults: (results: Medicine[]) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + clearResults: () => void; +} + +export const useSearchStore = create((set) => ({ + query: '', + results: [], + isLoading: false, + error: null, + + setQuery: (query) => set({ query }), + setResults: (results) => set({ results, isLoading: false }), + setLoading: (isLoading) => set({ isLoading }), + setError: (error) => set({ error, isLoading: false }), + clearResults: () => set({ results: [], query: '', error: null }), +})); +``` + +- [ ] **Step 3: Create useAuth hook** + +```typescript +// frontend-mobile/hooks/useAuth.ts +import { useEffect } from 'react'; +import { useAuthStore } from '../store/authStore'; + +export function useAuth() { + const { user, isLoading, isAuthenticated, login, logout, checkAuth } = useAuthStore(); + + useEffect(() => { + checkAuth(); + }, []); + + return { + user, + isLoading, + isAuthenticated, + login, + logout, + isAdmin: user?.is_admin ?? false, + }; +} +``` + +- [ ] **Step 4: Create useDebounce hook** + +```typescript +// frontend-mobile/hooks/useDebounce.ts +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} +``` + +- [ ] **Step 5: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/ +git commit -m "feat: add Zustand stores and custom hooks" +``` + +--- + +## Task 6: Create Constants and Theme + +**Files:** +- Create: `frontend-mobile/constants/theme.ts` + +- [ ] **Step 1: Create theme constants** + +```typescript +// frontend-mobile/constants/theme.ts +export const colors = { + // Primary + primary: '#007AFF', + primaryLight: '#4DA3FF', + primaryDark: '#0056CC', + + // Semantic + success: '#34C759', + danger: '#FF3B30', + warning: '#FF9500', + + // Neutral + background: '#F2F2F7', + card: '#FFFFFF', + border: '#E5E5EA', + separator: '#C6C6C8', + + // Text + text: '#1C1C1E', + textSecondary: '#8E8E93', + textInverse: '#FFFFFF', + textLink: '#007AFF', +}; + +export const spacing = { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, + xxl: 48, +}; + +export const borderRadius = { + sm: 8, + md: 12, + lg: 16, + xl: 24, +}; + +export const typography = { + title: { + fontSize: 28, + fontWeight: 'bold' as const, + }, + heading: { + fontSize: 20, + fontWeight: '600' as const, + }, + body: { + fontSize: 16, + fontWeight: 'normal' as const, + }, + caption: { + fontSize: 12, + fontWeight: 'normal' as const, + }, +}; +``` + +- [ ] **Step 2: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/constants/ +git commit -m "feat: add theme constants (colors, spacing, typography)" +``` + +--- + +## Task 7: Create UI Components + +**Files:** +- Create: `frontend-mobile/components/SearchBar.tsx` +- Create: `frontend-mobile/components/MedicineCard.tsx` +- Create: `frontend-mobile/components/StockBadge.tsx` +- Create: `frontend-mobile/components/LoadingSpinner.tsx` + +- [ ] **Step 1: Create SearchBar component** + +```typescript +// frontend-mobile/components/SearchBar.tsx +import React, { useState } from 'react'; +import { View, TextInput, StyleSheet, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { colors, spacing, borderRadius } from '../constants/theme'; + +interface SearchBarProps { + placeholder?: string; + onSearch: (query: string) => void; + value?: string; + onChangeText?: (text: string) => void; +} + +export function SearchBar({ + placeholder = 'Buscar medicamentos...', + onSearch, + value, + onChangeText +}: SearchBarProps) { + const [localValue, setLocalValue] = useState(value || ''); + + const handleChange = (text: string) => { + setLocalValue(text); + onChangeText?.(text); + }; + + const handleSubmit = () => { + onSearch(localValue); + }; + + const handleClear = () => { + setLocalValue(''); + onChangeText?.(''); + onSearch(''); + }; + + return ( + + + + {localValue.length > 0 && ( + + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.card, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + marginHorizontal: spacing.md, + marginVertical: spacing.sm, + }, + icon: { + marginRight: spacing.sm, + }, + input: { + flex: 1, + fontSize: 16, + color: colors.text, + paddingVertical: spacing.xs, + }, + clearButton: { + marginLeft: spacing.sm, + }, +}); +``` + +- [ ] **Step 2: Create MedicineCard component** + +```typescript +// frontend-mobile/components/MedicineCard.tsx +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { colors, spacing, borderRadius } from '../constants/theme'; +import { StockBadge } from './StockBadge'; +import { Medicine } from '../types'; + +interface MedicineCardProps { + medicine: Medicine; +} + +export function MedicineCard({ medicine }: MedicineCardProps) { + const router = useRouter(); + + const handlePress = () => { + router.push(`/medicine/${medicine.nregistro}`); + }; + + return ( + + + + {medicine.nombre} + + + + + + {medicine.principioActivo} + + + + + + + {medicine.precio ? `${medicine.precio.toFixed(2)} €` : 'Sin precio'} + + + + + + + {medicine.laboratorio} + + + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.card, + borderRadius: borderRadius.md, + padding: spacing.md, + marginHorizontal: spacing.md, + marginVertical: spacing.xs, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: spacing.xs, + }, + name: { + flex: 1, + fontSize: 16, + fontWeight: '600', + color: colors.text, + marginRight: spacing.sm, + }, + principioActivo: { + fontSize: 14, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + footer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + priceContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + price: { + fontSize: 14, + fontWeight: '600', + color: colors.text, + marginLeft: spacing.xs, + }, + labContainer: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + justifyContent: 'flex-end', + }, + laboratorio: { + fontSize: 12, + color: colors.textSecondary, + marginLeft: spacing.xs, + maxWidth: 120, + }, +}); +``` + +- [ ] **Step 3: Create StockBadge component** + +```typescript +// frontend-mobile/components/StockBadge.tsx +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { colors, borderRadius, spacing } from '../constants/theme'; + +interface StockBadgeProps { + stock: number; +} + +export function StockBadge({ stock }: StockBadgeProps) { + const getBadgeStyle = () => { + if (stock === 0) return styles.danger; + if (stock < 5) return styles.warning; + return styles.success; + }; + + const getText = () => { + if (stock === 0) return 'Sin stock'; + if (stock < 5) return `Bajo (${stock})`; + return `Disponible (${stock})`; + }; + + return ( + + {getText()} + + ); +} + +const styles = StyleSheet.create({ + badge: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.sm, + }, + success: { + backgroundColor: '#D4EDDA', + }, + warning: { + backgroundColor: '#FFF3CD', + }, + danger: { + backgroundColor: '#F8D7DA', + }, + text: { + fontSize: 12, + fontWeight: '600', + }, +}); +``` + +- [ ] **Step 4: Create LoadingSpinner component** + +```typescript +// frontend-mobile/components/LoadingSpinner.tsx +import React from 'react'; +import { View, ActivityIndicator, Text, StyleSheet } from 'react-native'; +import { colors, spacing } from '../constants/theme'; + +interface LoadingSpinnerProps { + message?: string; +} + +export function LoadingSpinner({ message = 'Cargando...' }: LoadingSpinnerProps) { + return ( + + + {message} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.xl, + }, + message: { + marginTop: spacing.md, + fontSize: 16, + color: colors.textSecondary, + }, +}); +``` + +- [ ] **Step 5: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/components/ +git commit -m "feat: add UI components (SearchBar, MedicineCard, StockBadge, LoadingSpinner)" +``` + +--- + +## Task 8: Implement Home Screen (Search) + +**Files:** +- Modify: `frontend-mobile/app/(tabs)/index.tsx` + +- [ ] **Step 1: Implement search functionality** + +```typescript +// frontend-mobile/app/(tabs)/index.tsx +import React, { useState, useEffect } from 'react'; +import { View, FlatList, StyleSheet, Text } from 'react-native'; +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 } from '../../constants/theme'; +import { Medicine } from '../../types'; +import { config } from '../../constants/config'; + +export default function HomeScreen() { + 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); + }; + + return ( + + + + {isLoading && } + + {error && ( + + {error} + + )} + + {!isLoading && !error && results.length === 0 && query.length >= 2 && ( + + No se encontraron medicamentos + + )} + + item.nregistro} + renderItem={({ item }) => } + contentContainerStyle={styles.list} + showsVerticalScrollIndicator={false} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + list: { + paddingBottom: spacing.xl, + }, + errorContainer: { + padding: spacing.md, + marginHorizontal: spacing.md, + backgroundColor: '#F8D7DA', + borderRadius: 8, + }, + errorText: { + color: '#721C24', + textAlign: 'center', + }, + emptyContainer: { + padding: spacing.xl, + alignItems: 'center', + }, + emptyText: { + color: colors.textSecondary, + fontSize: 16, + }, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/app/\(tabs\)/index.tsx +git commit -m "feat: implement home screen with medicine search" +``` + +--- + +## Task 9: Implement Medicine Detail Screen + +**Files:** +- Create: `frontend-mobile/app/medicine/[id].tsx` + +- [ ] **Step 1: Create medicine detail screen** + +```typescript +// frontend-mobile/app/medicine/[id].tsx +import React, { useEffect, useState } 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 { getMedicine, getMedicinePharmacies } from '../../services/medicines'; +import { StockBadge } from '../../components/StockBadge'; +import { LoadingSpinner } from '../../components/LoadingSpinner'; +import { colors, spacing, borderRadius } from '../../constants/theme'; +import { Medicine, PharmacyMedicine } from '../../types'; + +export default function MedicineDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const [medicine, setMedicine] = useState(null); + const [pharmacies, setPharmacies] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!id) return; + + const fetchMedicine = async () => { + try { + const [medData, pharmData] = await Promise.all([ + getMedicine(id), + getMedicinePharmacies(id), + ]); + setMedicine(medData); + setPharmacies(pharmData); + } catch (error) { + console.error('Error fetching medicine:', error); + } finally { + setIsLoading(false); + } + }; + + fetchMedicine(); + }, [id]); + + if (isLoading) { + return ; + } + + if (!medicine) { + return ( + + Medicamento no encontrado + + ); + } + + return ( + + + {medicine.nombre} + + + + + + + + + + + + + + Farmacias ({pharmacies.length}) + + + {pharmacies.length === 0 ? ( + No hay farmacias con este medicamento + ) : ( + pharmacies.map((pharm) => ( + router.push(`/pharmacy/${pharm.pharmacy_id}`)} + > + + {pharm.pharmacy?.name} + {pharm.pharmacy?.address} + + + {pharm.price.toFixed(2)} € + Stock: {pharm.stock} + + + )) + )} + + + ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + padding: spacing.md, + 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.md, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.separator, + }, + infoLabel: { + fontSize: 14, + color: colors.textSecondary, + }, + infoValue: { + fontSize: 14, + color: colors.text, + fontWeight: '500', + }, + pharmaciesSection: { + marginTop: spacing.sm, + padding: spacing.md, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.text, + marginBottom: spacing.md, + }, + noPharmacies: { + color: colors.textSecondary, + textAlign: 'center', + padding: spacing.xl, + }, + pharmacyCard: { + flexDirection: 'row', + justifyContent: 'space-between', + backgroundColor: colors.card, + borderRadius: borderRadius.md, + padding: spacing.md, + marginBottom: spacing.sm, + }, + pharmacyInfo: { + flex: 1, + }, + pharmacyName: { + fontSize: 16, + fontWeight: '600', + color: colors.text, + }, + pharmacyAddress: { + fontSize: 14, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + pharmacyStock: { + alignItems: 'flex-end', + }, + price: { + fontSize: 16, + fontWeight: '600', + color: colors.primary, + }, + stock: { + fontSize: 12, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + errorText: { + fontSize: 16, + color: colors.textSecondary, + }, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/app/medicine/ +git commit -m "feat: implement medicine detail screen with pharmacies list" +``` + +--- + +## Task 10: Implement Map Screen + +**Files:** +- Modify: `frontend-mobile/app/(tabs)/map.tsx` + +- [ ] **Step 1: Install react-native-maps types** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder/frontend-mobile +npm install --save-dev @types/react-native-maps +``` + +- [ ] **Step 2: Implement map screen** + +```typescript +// frontend-mobile/app/(tabs)/map.tsx +import React, { useEffect, useState } from 'react'; +import { View, StyleSheet, Text } from 'react-native'; +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 } from '../../constants/theme'; +import { Pharmacy } from '../../types'; + +export default function MapScreen() { + const router = useRouter(); + const [pharmacies, setPharmacies] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [region, setRegion] = useState({ + latitude: 40.4168, // Madrid default + longitude: -3.7038, + latitudeDelta: 0.0922, + longitudeDelta: 0.0421, + }); + + useEffect(() => { + const fetchPharmacies = async () => { + try { + const data = await getPharmacies(); + setPharmacies(data); + + // Center map on first pharmacy if available + if (data.length > 0) { + setRegion({ + latitude: data[0].latitude, + longitude: data[0].longitude, + latitudeDelta: 0.0922, + longitudeDelta: 0.0421, + }); + } + } catch (error) { + console.error('Error fetching pharmacies:', error); + } finally { + setIsLoading(false); + } + }; + + fetchPharmacies(); + }, []); + + if (isLoading) { + return ; + } + + return ( + + + {pharmacies.map((pharmacy) => ( + router.push(`/pharmacy/${pharmacy.id}`)} + /> + ))} + + + + + {pharmacies.length} farmacias en el mapa + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + map: { + flex: 1, + }, + legend: { + position: 'absolute', + bottom: spacing.lg, + left: spacing.md, + right: spacing.md, + backgroundColor: colors.card, + borderRadius: 8, + padding: spacing.sm, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + legendText: { + fontSize: 14, + color: colors.text, + }, +}); +``` + +- [ ] **Step 3: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/app/\(tabs\)/map.tsx +git commit -m "feat: implement map screen with pharmacy markers" +``` + +--- + +## Task 11: Implement Pharmacy Detail Screen + +**Files:** +- Create: `frontend-mobile/app/pharmacy/[id].tsx` + +- [ ] **Step 1: Create pharmacy detail screen** + +```typescript +// frontend-mobile/app/pharmacy/[id].tsx +import React, { useEffect, useState } 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 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 { Pharmacy, PharmacyMedicine } from '../../types'; + +export default function PharmacyDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const [pharmacy, setPharmacy] = useState(null); + const [medicines, setMedicines] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!id) return; + + const fetchPharmacy = async () => { + try { + const [pharmData, medData] = await Promise.all([ + getPharmacy(Number(id)), + getPharmacyMedicines(Number(id)), + ]); + setPharmacy(pharmData); + setMedicines(medData); + } catch (error) { + console.error('Error fetching pharmacy:', error); + } finally { + setIsLoading(false); + } + }; + + fetchPharmacy(); + }, [id]); + + const handleCall = () => { + if (pharmacy?.phone) { + Linking.openURL(`tel:${pharmacy.phone}`); + } + }; + + const handleDirections = () => { + if (pharmacy) { + const url = `https://www.google.com/maps/dir/?api=1&destination=${pharmacy.latitude},${pharmacy.longitude}`; + Linking.openURL(url); + } + }; + + if (isLoading) { + return ; + } + + if (!pharmacy) { + return ( + + Farmacia no encontrada + + ); + } + + return ( + + + {pharmacy.name} + + + + + + Llamar + + + + + Cómo llegar + + + + + + + {pharmacy.address} + + + {pharmacy.phone && ( + + + {pharmacy.phone} + + )} + + + + + + + + + + + Medicamentos ({medicines.length}) + + + {medicines.length === 0 ? ( + No hay medicamentos disponibles + ) : ( + medicines.map((med) => ( + router.push(`/medicine/${med.medicine_nregistro}`)} + > + + {med.medicine_name} + Reg: {med.medicine_nregistro} + + + {med.price.toFixed(2)} € + Stock: {med.stock} + + + )) + )} + + + ); +} + +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', + alignItems: 'center', + padding: spacing.sm, + }, + actionText: { + marginLeft: spacing.xs, + color: colors.primary, + fontWeight: '500', + }, + infoSection: { + padding: spacing.md, + backgroundColor: colors.card, + marginTop: spacing.sm, + }, + infoRow: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: spacing.sm, + }, + infoText: { + flex: 1, + marginLeft: spacing.sm, + fontSize: 16, + color: colors.text, + }, + mapContainer: { + height: 200, + marginTop: spacing.sm, + }, + map: { + flex: 1, + }, + medicinesSection: { + marginTop: spacing.sm, + padding: spacing.md, + }, + 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, + }, + medicineInfo: { + flex: 1, + }, + medicineName: { + fontSize: 16, + fontWeight: '500', + color: colors.text, + }, + medicineNregistro: { + fontSize: 12, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + medicineStock: { + alignItems: 'flex-end', + }, + price: { + fontSize: 16, + fontWeight: '600', + color: colors.primary, + }, + stock: { + fontSize: 12, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + errorText: { + fontSize: 16, + color: colors.textSecondary, + }, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/app/pharmacy/ +git commit -m "feat: implement pharmacy detail screen with map and medicines" +``` + +--- + +## Task 12: Implement Auth Screens + +**Files:** +- Create: `frontend-mobile/app/auth/login.tsx` +- Create: `frontend-mobile/app/auth/register.tsx` +- Modify: `frontend-mobile/app/(tabs)/profile.tsx` + +- [ ] **Step 1: Create login screen** + +```typescript +// frontend-mobile/app/auth/login.tsx +import React, { useState } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + KeyboardAvoidingView, + Platform, + Alert +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { useAuthStore } from '../../store/authStore'; +import { colors, spacing, borderRadius } from '../../constants/theme'; + +export default function LoginScreen() { + const router = useRouter(); + const { login } = useAuthStore(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleLogin = async () => { + if (!username || !password) { + Alert.alert('Error', 'Por favor ingresa usuario y contraseña'); + return; + } + + setIsLoading(true); + try { + await login(username, password); + router.replace('/(tabs)'); + } catch (error) { + Alert.alert('Error', 'Credenciales incorrectas'); + } finally { + setIsLoading(false); + } + }; + + return ( + + + Iniciar Sesión + Ingresa tus credenciales para continuar + + + Usuario + + + + + Contraseña + + + + + + {isLoading ? 'Ingresando...' : 'Iniciar Sesión'} + + + + router.push('/auth/register')} + > + ¿No tienes cuenta? Regístrate + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + form: { + flex: 1, + justifyContent: 'center', + padding: spacing.xl, + }, + title: { + fontSize: 28, + fontWeight: 'bold', + color: colors.text, + marginBottom: spacing.sm, + }, + subtitle: { + fontSize: 16, + color: colors.textSecondary, + marginBottom: spacing.xl, + }, + inputContainer: { + marginBottom: spacing.md, + }, + 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, + borderRadius: borderRadius.md, + padding: spacing.md, + alignItems: 'center', + marginTop: spacing.md, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: colors.textInverse, + fontSize: 16, + fontWeight: '600', + }, + linkButton: { + marginTop: spacing.lg, + alignItems: 'center', + }, + linkText: { + color: colors.primary, + fontSize: 14, + }, +}); +``` + +- [ ] **Step 2: Create register screen** + +```typescript +// frontend-mobile/app/auth/register.tsx +import React, { useState } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + KeyboardAvoidingView, + Platform, + Alert +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { register } from '../../services/auth'; +import { colors, spacing, borderRadius } from '../../constants/theme'; + +export default function RegisterScreen() { + const router = useRouter(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleRegister = async () => { + if (!username || !password || !confirmPassword) { + Alert.alert('Error', 'Por favor completa todos los campos'); + return; + } + + if (password !== confirmPassword) { + Alert.alert('Error', 'Las contraseñas no coinciden'); + return; + } + + setIsLoading(true); + try { + await register(username, password); + router.replace('/(tabs)'); + } catch (error) { + Alert.alert('Error', 'No se pudo crear la cuenta'); + } finally { + setIsLoading(false); + } + }; + + return ( + + + Crear Cuenta + Regístrate para empezar + + + Usuario + + + + + Contraseña + + + + + Confirmar Contraseña + + + + + + {isLoading ? 'Creando cuenta...' : 'Crear Cuenta'} + + + + router.back()} + > + ¿Ya tienes cuenta? Inicia sesión + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + form: { + flex: 1, + justifyContent: 'center', + padding: spacing.xl, + }, + title: { + fontSize: 28, + fontWeight: 'bold', + color: colors.text, + marginBottom: spacing.sm, + }, + subtitle: { + fontSize: 16, + color: colors.textSecondary, + marginBottom: spacing.xl, + }, + inputContainer: { + marginBottom: spacing.md, + }, + 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, + borderRadius: borderRadius.md, + padding: spacing.md, + alignItems: 'center', + marginTop: spacing.md, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: colors.textInverse, + fontSize: 16, + fontWeight: '600', + }, + linkButton: { + marginTop: spacing.lg, + alignItems: 'center', + }, + linkText: { + color: colors.primary, + fontSize: 14, + }, +}); +``` + +- [ ] **Step 3: Update profile screen** + +```typescript +// frontend-mobile/app/(tabs)/profile.tsx +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useAuth } from '../../hooks/useAuth'; +import { colors, spacing, borderRadius } from '../../constants/theme'; + +export default function ProfileScreen() { + const router = useRouter(); + const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth(); + + const handleLogout = async () => { + Alert.alert( + 'Cerrar Sesión', + '¿Estás seguro que deseas cerrar sesión?', + [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Cerrar Sesión', + style: 'destructive', + onPress: async () => { + await logout(); + router.replace('/auth/login'); + } + }, + ] + ); + }; + + if (isLoading) { + return ( + + Cargando... + + ); + } + + if (!isAuthenticated) { + return ( + + + + Inicia Sesión + + Inicia sesión para acceder a todas las funcionalidades + + router.push('/auth/login')} + > + Iniciar Sesión + + router.push('/auth/register')} + > + Crear cuenta nueva + + + + ); + } + + return ( + + + + + + {user?.username} + {isAdmin && Administrador} + + + + {isAdmin && ( + + + Panel Admin + + + )} + + + + Favoritos + + + + + + Notificaciones + + + + + + Ayuda + + + + + + + Cerrar Sesión + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + loadingText: { + textAlign: 'center', + marginTop: spacing.xxl, + color: colors.textSecondary, + }, + authPrompt: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.xl, + }, + authTitle: { + fontSize: 24, + fontWeight: 'bold', + color: colors.text, + marginTop: spacing.lg, + }, + authSubtitle: { + fontSize: 16, + color: colors.textSecondary, + textAlign: 'center', + marginTop: spacing.sm, + marginBottom: spacing.xl, + }, + header: { + alignItems: 'center', + padding: spacing.xl, + backgroundColor: colors.card, + }, + avatar: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: colors.background, + justifyContent: 'center', + alignItems: 'center', + }, + username: { + fontSize: 20, + fontWeight: 'bold', + color: colors.text, + marginTop: spacing.md, + }, + adminBadge: { + fontSize: 12, + color: colors.primary, + backgroundColor: '#E3F2FD', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.sm, + marginTop: spacing.sm, + }, + menuSection: { + marginTop: spacing.md, + backgroundColor: colors.card, + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + padding: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.separator, + }, + menuText: { + flex: 1, + marginLeft: spacing.md, + fontSize: 16, + color: colors.text, + }, + button: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + }, + buttonText: { + color: colors.textInverse, + fontSize: 16, + fontWeight: '600', + }, + linkButton: { + marginTop: spacing.md, + }, + linkText: { + color: colors.primary, + fontSize: 14, + }, + logoutButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + margin: spacing.xl, + padding: spacing.md, + backgroundColor: colors.card, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.danger, + }, + logoutText: { + marginLeft: spacing.sm, + color: colors.danger, + fontSize: 16, + fontWeight: '500', + }, +}); +``` + +- [ ] **Step 4: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/ +git commit -m "feat: implement auth screens (login, register) and profile screen" +``` + +--- + +## Task 13: Configure Root Layout with Auth Check + +**Files:** +- Modify: `frontend-mobile/app/_layout.tsx` + +- [ ] **Step 1: Update root layout with auth provider** + +```typescript +// frontend-mobile/app/_layout.tsx +import { useEffect } from 'react'; +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { useAuthStore } from '../store/authStore'; + +const queryClient = new QueryClient(); + +export default function RootLayout() { + const { checkAuth } = useAuthStore(); + + useEffect(() => { + checkAuth(); + }, []); + + return ( + + + + + + + + + + + + + ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/app/_layout.tsx +git commit -m "feat: add auth check on app startup" +``` + +--- + +## Task 14: Configure EAS Build + +**Files:** +- Create: `frontend-mobile/eas.json` +- Create: `frontend-mobile/.gitignore` + +- [ ] **Step 1: Create EAS configuration** + +```json +{ + "cli": { + "version": ">= 12.0.0" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": {} + }, + "submit": { + "production": { + "ios": { + "appleId": "", + "ascAppId": "", + "appleTeamId": "" + }, + "android": { + "serviceAccountKeyPath": "./google-service-account.json" + } + } + } +} +``` + +- [ ] **Step 2: Create .gitignore for mobile** + +```gitignore +# Expo +.expo/ +dist/ +web-build/ + +# Native +ios/ +android/ +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# env files +.env +.env.local +.env.production + +# EAS +eas-cli.json + +# Dependencies +node_modules/ + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +*.apk +*.aab +*.ipa +``` + +- [ ] **Step 3: Update root package.json with mobile scripts** + +```json +{ + "scripts": { + "dev": "npm-run-all --parallel dev:backend dev:frontend", + "dev:backend": "npm run dev --prefix backend", + "dev:frontend": "npm run dev --prefix frontend", + "dev:mobile": "cd frontend-mobile && npx expo start", + "dev:mobile:android": "cd frontend-mobile && npx expo start --android", + "dev:mobile:ios": "cd frontend-mobile && npx expo start --ios", + "start": "npm-run-all --parallel start:backend start:frontend", + "start:backend": "npm start --prefix backend", + "start:frontend": "npm run preview --prefix frontend", + "install:all": "npm install && npm install --prefix backend && npm install --prefix frontend && npm install --prefix frontend-mobile", + "build:web": "npm run build --prefix frontend", + "build:mobile": "cd frontend-mobile && eas build", + "submit:android": "cd frontend-mobile && eas submit --platform android", + "submit:ios": "cd frontend-mobile && eas submit --platform ios", + "cap:sync": "npm run build:web && cap sync", + "cap:copy": "npm run build:web && cap copy", + "cap:open:android": "cap open android", + "cap:open:ios": "cap open ios", + "cap:run:android": "npm run build:web && cap run android", + "cap:run:ios": "npm run build:web && cap run ios" + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/eas.json frontend-mobile/.gitignore package.json +git commit -m "feat: configure EAS Build and add mobile scripts" +``` + +--- + +## Task 15: Add Push Notifications (Native Extra) + +**Files:** +- Create: `frontend-mobile/services/notifications.ts` +- Modify: `frontend-mobile/app/_layout.tsx` + +- [ ] **Step 1: Create notifications service** + +```typescript +// frontend-mobile/services/notifications.ts +import * as Notifications from 'expo-notifications'; +import * as Device from 'expo-device'; +import { Platform } from 'react-native'; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: true, + }), +}); + +export async function registerForPushNotifications() { + if (!Device.isDevice) { + console.log('Push notifications require a physical device'); + return null; + } + + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + + if (existingStatus !== 'granted') { + const { status } = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + + if (finalStatus !== 'granted') { + console.log('Failed to get push token for push notification'); + return null; + } + + const token = await Notifications.getExpoPushTokenAsync(); + + if (Platform.OS === 'android') { + Notifications.setNotificationChannelAsync('default', { + name: 'default', + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#007AFF', + }); + } + + return token.data; +} + +export async function scheduleMedicineAvailabilityNotification( + medicineName: string, + pharmacyName: string +) { + await Notifications.scheduleNotificationAsync({ + content: { + title: 'Medicamento disponible', + body: `${medicineName} está disponible en ${pharmacyName}`, + data: { type: 'medicine_availability' }, + }, + trigger: null, // Immediate + }); +} + +export async function addNotificationListener( + handler: (notification: Notifications.Notification) => void +) { + return Notifications.addNotificationReceivedListener(handler); +} + +export async function addNotificationResponseListener( + handler: (response: Notifications.NotificationResponse) => void +) { + return Notifications.addNotificationResponseReceivedListener(handler); +} +``` + +- [ ] **Step 2: Update root layout to initialize notifications** + +```typescript +// frontend-mobile/app/_layout.tsx (add to imports and useEffect) +import { useEffect, useRef } from 'react'; +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import * as Notifications from 'expo-notifications'; +import { useAuthStore } from '../store/authStore'; +import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications'; + +const queryClient = new QueryClient(); + +export default function RootLayout() { + const { checkAuth } = useAuthStore(); + const notificationListener = useRef(); + const responseListener = useRef(); + + useEffect(() => { + checkAuth(); + + // Setup push notifications + registerForPushNotifications(); + + notificationListener.current = addNotificationListener((notification) => { + console.log('Notification received:', notification); + }); + + responseListener.current = addNotificationResponseListener((response) => { + console.log('Notification clicked:', response); + }); + + return () => { + if (notificationListener.current) { + Notifications.removeNotificationSubscription(notificationListener.current); + } + if (responseListener.current) { + Notifications.removeNotificationSubscription(responseListener.current); + } + }; + }, []); + + // ... rest of component remains the same +} +``` + +- [ ] **Step 3: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/ +git commit -m "feat: add push notifications support" +``` + +--- + +## Task 16: Add Barcode Scanner (Native Extra) + +**Files:** +- Create: `frontend-mobile/components/BarcodeScanner.tsx` +- Create: `frontend-mobile/app/scanner.tsx` + +- [ ] **Step 1: Create barcode scanner component** + +```typescript +// frontend-mobile/components/BarcodeScanner.tsx +import React, { useState, useEffect } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { CameraView, CameraType, useCameraPermissions } from 'expo-camera'; +import { Ionicons } from '@expo/vector-icons'; +import { colors, spacing, borderRadius } from '../constants/theme'; + +interface BarcodeScannerProps { + onBarcodeScanned: (barcode: string) => void; + onClose: () => void; +} + +export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProps) { + const [permission, requestPermission] = useCameraPermissions(); + const [scanned, setScanned] = useState(false); + + if (!permission) { + return ; + } + + if (!permission.granted) { + return ( + + + Permiso de cámara requerido + + Necesitamos acceso a la cámara para escanear códigos de barras + + + Conceder permiso + + + Cancelar + + + ); + } + + const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => { + if (scanned) return; + setScanned(true); + onBarcodeScanned(data); + }; + + return ( + + + + + + + + + + + + + Apunta la cámara al código de barras del medicamento + + + + + + + + {scanned && ( + + setScanned(false)} + > + Escanear de nuevo + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000', + }, + permissionContainer: { + 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, + fontSize: 16, + fontWeight: '600', + }, + cancelButton: { + marginTop: spacing.md, + }, + cancelButtonText: { + color: colors.textSecondary, + fontSize: 14, + }, + overlay: { + flex: 1, + backgroundColor: 'transparent', + justifyContent: 'center', + alignItems: 'center', + }, + scannerFrame: { + width: 250, + height: 250, + borderWidth: 2, + borderColor: 'transparent', + position: 'relative', + }, + corner: { + position: 'absolute', + width: 30, + height: 30, + borderColor: colors.primary, + }, + topLeft: { + top: 0, + left: 0, + borderTopWidth: 3, + borderLeftWidth: 3, + }, + topRight: { + top: 0, + right: 0, + borderTopWidth: 3, + borderRightWidth: 3, + }, + bottomLeft: { + bottom: 0, + left: 0, + borderBottomWidth: 3, + borderLeftWidth: 3, + }, + bottomRight: { + bottom: 0, + right: 0, + borderBottomWidth: 3, + borderRightWidth: 3, + }, + instruction: { + color: colors.textInverse, + fontSize: 14, + textAlign: 'center', + marginTop: spacing.xl, + backgroundColor: 'rgba(0,0,0,0.6)', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.sm, + }, + closeButton: { + position: 'absolute', + top: spacing.xl, + right: spacing.xl, + backgroundColor: 'rgba(0,0,0,0.6)', + borderRadius: 20, + padding: spacing.sm, + }, + scannedOverlay: { + position: 'absolute', + bottom: spacing.xxl, + left: spacing.xl, + right: spacing.xl, + }, + scanAgainButton: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + padding: spacing.md, + alignItems: 'center', + }, + scanAgainText: { + color: colors.textInverse, + fontSize: 16, + fontWeight: '600', + }, +}); +``` + +- [ ] **Step 2: Create scanner screen** + +```typescript +// frontend-mobile/app/scanner.tsx +import React, { useState } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { useRouter } from 'expo-router'; +import { BarcodeScanner } from '../components/BarcodeScanner'; +import { searchMedicines } from '../services/medicines'; + +export default function ScannerScreen() { + const router = useRouter(); + const [isSearching, setIsSearching] = useState(false); + + const handleBarcodeScanned = async (barcode: string) => { + setIsSearching(true); + try { + // Try to find medicine by barcode/registration number + const results = await searchMedicines(barcode); + if (results.length > 0) { + router.push(`/medicine/${results[0].nregistro}`); + } else { + // Fallback: try direct lookup + router.push(`/medicine/${barcode}`); + } + } catch (error) { + console.error('Error searching medicine:', error); + router.push(`/medicine/${barcode}`); + } finally { + setIsSearching(false); + } + }; + + const handleClose = () => { + router.back(); + }; + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); +``` + +- [ ] **Step 3: Add scanner button to home screen** + +```typescript +// Add to frontend-mobile/app/(tabs)/index.tsx - add import and button +import { Ionicons } from '@expo/vector-icons'; +import { TouchableOpacity } from 'react-native'; + +// Add this button next to SearchBar + router.push('/scanner')} +> + + + +// Add to styles +scannerButton: { + position: 'absolute', + right: spacing.md, + top: spacing.md, + backgroundColor: colors.card, + borderRadius: 20, + padding: spacing.sm, +}, +``` + +- [ ] **Step 4: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/ +git commit -m "feat: add barcode scanner for medicine lookup" +``` + +--- + +## Task 17: Add Biometrics (Native Extra) + +**Files:** +- Create: `frontend-mobile/services/biometrics.ts` +- Modify: `frontend-mobile/app/auth/login.tsx` + +- [ ] **Step 1: Create biometrics service** + +```typescript +// frontend-mobile/services/biometrics.ts +import * as LocalAuthentication from 'expo-local-authentication'; +import * as SecureStore from 'expo-secure-store'; + +export async function isBiometricsAvailable(): Promise { + const compatible = await LocalAuthentication.hasHardwareAsync(); + const enrolled = await LocalAuthentication.isEnrolledAsync(); + return compatible && enrolled; +} + +export async function authenticateWithBiometrics(): Promise { + try { + const result = await LocalAuthentication.authenticateAsync({ + promptMessage: 'Inicia sesión con biometría', + cancelLabel: 'Cancelar', + disableDeviceFallback: false, + }); + return result.success; + } catch (error) { + console.error('Biometric authentication error:', error); + return false; + } +} + +export async function saveBiometricCredentials(username: string): Promise { + await SecureStore.setItemAsync('biometric_username', username); +} + +export async function getBiometricUsername(): Promise { + return await SecureStore.getItemAsync('biometric_username'); +} + +export async function clearBiometricCredentials(): Promise { + await SecureStore.deleteItemAsync('biometric_username'); +} +``` + +- [ ] **Step 2: Update login screen with biometrics** + +```typescript +// Add to frontend-mobile/app/auth/login.tsx +import { + isBiometricsAvailable, + authenticateWithBiometrics, + saveBiometricCredentials, + getBiometricUsername +} from '../../services/biometrics'; + +// Add state +const [biometricsAvailable, setBiometricsAvailable] = useState(false); +const [biometricUsername, setBiometricUsername] = useState(null); + +// Add useEffect +useEffect(() => { + checkBiometrics(); +}, []); + +const checkBiometrics = async () => { + const available = await isBiometricsAvailable(); + setBiometricsAvailable(available); + if (available) { + const savedUsername = await getBiometricUsername(); + setBiometricUsername(savedUsername); + } +}; + +const handleBiometricLogin = async () => { + const success = await authenticateWithBiometrics(); + if (success && biometricUsername) { + setIsLoading(true); + try { + // Use stored password or implement token-based auth + await login(biometricUsername, ''); // You may need to adjust this + router.replace('/(tabs)'); + } catch (error) { + Alert.alert('Error', 'Error al autenticar con biometría'); + } finally { + setIsLoading(false); + } + } +}; + +// After successful login, save for biometrics +const handleLogin = async () => { + // ... existing login logic + if (success) { + await saveBiometricCredentials(username); + } +}; + +// Add biometric button in the form +{biometricsAvailable && biometricUsername && ( + + + + Iniciar sesión con biometría + + +)} + +// Add styles +biometricButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginTop: spacing.lg, + padding: spacing.md, + backgroundColor: colors.background, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.primary, +}, +biometricText: { + marginLeft: spacing.sm, + color: colors.primary, + fontSize: 16, + fontWeight: '500', +}, +``` + +- [ ] **Step 3: Commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add frontend-mobile/ +git commit -m "feat: add biometric authentication support" +``` + +--- + +## Task 18: Test and Verify + +**Files:** +- None (testing only) + +- [ ] **Step 1: Run Expo development server** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder/frontend-mobile +npx expo start --clear +``` + +Expected: Dev server starts, QR code displayed + +- [ ] **Step 2: Test on iOS Simulator (if Mac)** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder/frontend-mobile +npx expo start --ios +``` + +Expected: App launches in simulator + +- [ ] **Step 3: Test on Android Emulator** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder/frontend-mobile +npx expo start --android +``` + +Expected: App launches in emulator + +- [ ] **Step 4: Verify navigation** + +- [ ] Tab navigation works (Home, Map, Profile) +- [ ] Medicine search returns results +- [ ] Medicine detail shows pharmacies +- [ ] Map shows pharmacy markers +- [ ] Pharmacy detail shows medicines +- [ ] Login/logout works + +- [ ] **Step 5: Final commit** + +```bash +cd /home/f80ans0/Projects/Webs/FarmaFinder +git add . +git commit -m "feat: complete FarmaFinder Mobile MVP with native extras" +``` + +--- + +## Summary + +| Task | Description | Files | +|------|-------------|-------| +| 1 | Initialize Expo project | package.json, app.json, tsconfig.json, babel.config.js | +| 2 | Setup navigation | app/_layout.tsx, (tabs)/_layout.tsx, screen files | +| 3 | Create types | types/index.ts | +| 4 | Create API services | services/api.ts, medicines.ts, pharmacies.ts, auth.ts | +| 5 | Create state management | store/authStore.ts, searchStore.ts, hooks/useAuth.ts | +| 6 | Create theme | constants/theme.ts | +| 7 | Create UI components | components/*.tsx | +| 8 | Implement Home screen | app/(tabs)/index.tsx | +| 9 | Implement Medicine detail | app/medicine/[id].tsx | +| 10 | Implement Map screen | app/(tabs)/map.tsx | +| 11 | Implement Pharmacy detail | app/pharmacy/[id].tsx | +| 12 | Implement Auth screens | app/auth/login.tsx, register.tsx, profile.tsx | +| 13 | Configure root layout | app/_layout.tsx | +| 14 | Configure EAS Build | eas.json, .gitignore, package.json | +| 15 | Add Push Notifications | services/notifications.ts | +| 16 | Add Barcode Scanner | components/BarcodeScanner.tsx, app/scanner.tsx | +| 17 | Add Biometrics | services/biometrics.ts, login.tsx | +| 18 | Test and verify | Manual testing | + +--- + +## Next Steps After Implementation + +1. **Configure EAS Build**: Set up Expo account and EAS +2. **Test on real devices**: Use Expo Go or development build +3. **Configure push notifications**: Set up FCM/APNs +4. **Add app icons and splash screens**: Design assets +5. **Configure deep links**: Set up URL scheme +6. **Submit to App Store**: Follow Apple guidelines +7. **Submit to Google Play**: Follow Google guidelines