# 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