Files
FarmaFinder/docs/superpowers/plans/2026-07-06-farmafinder-mobile-implementation.md
2026-07-06 10:35:34 +02:00

86 KiB

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

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
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
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
{
  "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
module.exports = function(api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: ['react-native-reanimated/plugin'],
  };
};
  • Step 6: Configure tsconfig.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
cd /home/f80ans0/Projects/Webs/FarmaFinder/frontend-mobile
npx expo start --clear

Expected: Expo dev server starts without errors

  • Step 8: Commit
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

// 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 (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <QueryClientProvider client={queryClient}>
        <Stack>
          <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
          <Stack.Screen name="medicine/[id]" options={{ title: 'Medicamento' }} />
          <Stack.Screen name="pharmacy/[id]" options={{ title: 'Farmacia' }} />
          <Stack.Screen name="auth/login" options={{ title: 'Iniciar Sesión' }} />
          <Stack.Screen name="auth/register" options={{ title: 'Registrarse' }} />
        </Stack>
        <StatusBar style="auto" />
      </QueryClientProvider>
    </GestureHandlerRootView>
  );
}
  • Step 2: Create tab navigator
// frontend-mobile/app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#007AFF',
        tabBarInactiveTintColor: '#8E8E93',
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Buscar',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="search" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="map"
        options={{
          title: 'Mapa',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="map" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Perfil',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="person" size={size} color={color} />
          ),
        }}
      />
    </Tabs>
  );
}
  • Step 3: Create Home screen (placeholder)
// frontend-mobile/app/(tabs)/index.tsx
import { View, Text, StyleSheet } from 'react-native';

export default function HomeScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>FarmaFinder</Text>
      <Text style={styles.subtitle}>Busca medicamentos</Text>
    </View>
  );
}

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)
// frontend-mobile/app/(tabs)/map.tsx
import { View, Text, StyleSheet } from 'react-native';

export default function MapScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Mapa de Farmacias</Text>
    </View>
  );
}

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)
// frontend-mobile/app/(tabs)/profile.tsx
import { View, Text, StyleSheet } from 'react-native';

export default function ProfileScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Perfil</Text>
    </View>
  );
}

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
{
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web"
  }
}
  • Step 7: Verify navigation works
cd /home/f80ans0/Projects/Webs/FarmaFinder/frontend-mobile
npx expo start

Expected: App shows with 3 tabs (Buscar, Mapa, Perfil)

  • Step 8: Commit
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

// 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<T> {
  data: T;
  success: boolean;
  error?: string;
}
  • Step 2: Commit
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

// 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
// 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
// frontend-mobile/services/medicines.ts
import api from './api';
import { Medicine, PharmacyMedicine } from '../types';

export async function searchMedicines(query: string): Promise<Medicine[]> {
  const response = await api.get(`/medicines/search?q=${encodeURIComponent(query)}`);
  return response.data;
}

export async function getMedicine(nregistro: string): Promise<Medicine> {
  const response = await api.get(`/medicines/${nregistro}`);
  return response.data;
}

export async function getMedicinePharmacies(nregistro: string): Promise<PharmacyMedicine[]> {
  const response = await api.get(`/medicines/${nregistro}/pharmacies`);
  return response.data;
}
  • Step 4: Create pharmacies service
// frontend-mobile/services/pharmacies.ts
import api from './api';
import { Pharmacy } from '../types';

export async function getPharmacies(): Promise<Pharmacy[]> {
  const response = await api.get('/pharmacies');
  return response.data;
}

export async function getPharmacy(id: number): Promise<Pharmacy> {
  const response = await api.get(`/pharmacies/${id}`);
  return response.data;
}

export async function getPharmacyMedicines(id: number): Promise<any[]> {
  const response = await api.get(`/pharmacies/${id}/medicines`);
  return response.data;
}
  • Step 5: Create auth service
// 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<AuthResponse> {
  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<void> {
  await api.post('/auth/logout');
  await SecureStore.deleteItemAsync('auth_token');
  await SecureStore.deleteItemAsync('user');
}

export async function checkAuth(): Promise<User | null> {
  try {
    const response = await api.get('/auth/check');
    return response.data.user;
  } catch {
    return null;
  }
}

export async function getStoredUser(): Promise<User | null> {
  const userStr = await SecureStore.getItemAsync('user');
  return userStr ? JSON.parse(userStr) : null;
}

export async function register(username: string, password: string): Promise<AuthResponse> {
  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
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

// 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<void>;
  logout: () => Promise<void>;
  checkAuth: () => Promise<void>;
}

export const useAuthStore = create<AuthState>((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
// 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<SearchState>((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
// 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
// frontend-mobile/hooks/useDebounce.ts
import { useState, useEffect } from 'react';

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return debouncedValue;
}
  • Step 5: Commit
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

// 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
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

// 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 (
    <View style={styles.container}>
      <Ionicons name="search" size={20} color={colors.textSecondary} style={styles.icon} />
      <TextInput
        style={styles.input}
        placeholder={placeholder}
        placeholderTextColor={colors.textSecondary}
        value={localValue}
        onChangeText={handleChange}
        onSubmitEditing={handleSubmit}
        returnKeyType="search"
        autoCorrect={false}
      />
      {localValue.length > 0 && (
        <TouchableOpacity onPress={handleClear} style={styles.clearButton}>
          <Ionicons name="close-circle" size={20} color={colors.textSecondary} />
        </TouchableOpacity>
      )}
    </View>
  );
}

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
// 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 (
    <TouchableOpacity style={styles.card} onPress={handlePress} activeOpacity={0.7}>
      <View style={styles.header}>
        <Text style={styles.name} numberOfLines={2}>
          {medicine.nombre}
        </Text>
        <StockBadge stock={medicine.stock} />
      </View>
      
      <Text style={styles.principioActivo} numberOfLines={1}>
        {medicine.principioActivo}
      </Text>
      
      <View style={styles.footer}>
        <View style={styles.priceContainer}>
          <Ionicons name="pricetag" size={14} color={colors.textSecondary} />
          <Text style={styles.price}>
            {medicine.precio ? `${medicine.precio.toFixed(2)} €` : 'Sin precio'}
          </Text>
        </View>
        
        <View style={styles.labContainer}>
          <Ionicons name="business" size={14} color={colors.textSecondary} />
          <Text style={styles.laboratorio} numberOfLines={1}>
            {medicine.laboratorio}
          </Text>
        </View>
      </View>
    </TouchableOpacity>
  );
}

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
// 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 (
    <View style={[styles.badge, getBadgeStyle()]}>
      <Text style={styles.text}>{getText()}</Text>
    </View>
  );
}

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
// 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 (
    <View style={styles.container}>
      <ActivityIndicator size="large" color={colors.primary} />
      <Text style={styles.message}>{message}</Text>
    </View>
  );
}

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
cd /home/f80ans0/Projects/Webs/FarmaFinder
git add frontend-mobile/components/
git commit -m "feat: add UI components (SearchBar, MedicineCard, StockBadge, LoadingSpinner)"

Files:

  • Modify: frontend-mobile/app/(tabs)/index.tsx

  • Step 1: Implement search functionality

// 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<Medicine[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(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 (
    <View style={styles.container}>
      <SearchBar
        onSearch={handleSearch}
        value={query}
        onChangeText={setQuery}
      />
      
      {isLoading && <LoadingSpinner message="Buscando medicamentos..." />}
      
      {error && (
        <View style={styles.errorContainer}>
          <Text style={styles.errorText}>{error}</Text>
        </View>
      )}
      
      {!isLoading && !error && results.length === 0 && query.length >= 2 && (
        <View style={styles.emptyContainer}>
          <Text style={styles.emptyText}>No se encontraron medicamentos</Text>
        </View>
      )}
      
      <FlatList
        data={results}
        keyExtractor={(item) => item.nregistro}
        renderItem={({ item }) => <MedicineCard medicine={item} />}
        contentContainerStyle={styles.list}
        showsVerticalScrollIndicator={false}
      />
    </View>
  );
}

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
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

// 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<Medicine | null>(null);
  const [pharmacies, setPharmacies] = useState<PharmacyMedicine[]>([]);
  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 <LoadingSpinner message="Cargando medicamento..." />;
  }

  if (!medicine) {
    return (
      <View style={styles.errorContainer}>
        <Text style={styles.errorText}>Medicamento no encontrado</Text>
      </View>
    );
  }

  return (
    <ScrollView style={styles.container}>
      <View style={styles.header}>
        <Text style={styles.name}>{medicine.nombre}</Text>
        <StockBadge stock={medicine.stock} />
      </View>

      <View style={styles.infoSection}>
        <InfoRow label="Principio activo" value={medicine.principioActivo} />
        <InfoRow label="Laboratorio" value={medicine.laboratorio} />
        <InfoRow label="Forma farmacéutica" value={medicine.formaFarmaceutica} />
        <InfoRow 
          label="Precio" 
          value={medicine.precio ? `${medicine.precio.toFixed(2)} €` : 'No disponible'} 
        />
        <InfoRow label="Registro" value={medicine.nregistro} />
      </View>

      <View style={styles.pharmaciesSection}>
        <Text style={styles.sectionTitle}>
          Farmacias ({pharmacies.length})
        </Text>
        
        {pharmacies.length === 0 ? (
          <Text style={styles.noPharmacies}>No hay farmacias con este medicamento</Text>
        ) : (
          pharmacies.map((pharm) => (
            <TouchableOpacity
              key={pharm.id}
              style={styles.pharmacyCard}
              onPress={() => router.push(`/pharmacy/${pharm.pharmacy_id}`)}
            >
              <View style={styles.pharmacyInfo}>
                <Text style={styles.pharmacyName}>{pharm.pharmacy?.name}</Text>
                <Text style={styles.pharmacyAddress}>{pharm.pharmacy?.address}</Text>
              </View>
              <View style={styles.pharmacyStock}>
                <Text style={styles.price}>{pharm.price.toFixed(2)} </Text>
                <Text style={styles.stock}>Stock: {pharm.stock}</Text>
              </View>
            </TouchableOpacity>
          ))
        )}
      </View>
    </ScrollView>
  );
}

function InfoRow({ label, value }: { label: string; value: string }) {
  return (
    <View style={styles.infoRow}>
      <Text style={styles.infoLabel}>{label}</Text>
      <Text style={styles.infoValue}>{value}</Text>
    </View>
  );
}

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
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

cd /home/f80ans0/Projects/Webs/FarmaFinder/frontend-mobile
npm install --save-dev @types/react-native-maps
  • Step 2: Implement map screen
// 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<Pharmacy[]>([]);
  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 <LoadingSpinner message="Cargando farmacias..." />;
  }

  return (
    <View style={styles.container}>
      <MapView
        style={styles.map}
        region={region}
        onRegionChangeComplete={setRegion}
        showsUserLocation={true}
        showsMyLocationButton={true}
      >
        {pharmacies.map((pharmacy) => (
          <Marker
            key={pharmacy.id}
            coordinate={{
              latitude: pharmacy.latitude,
              longitude: pharmacy.longitude,
            }}
            title={pharmacy.name}
            description={pharmacy.address}
            onCalloutPress={() => router.push(`/pharmacy/${pharmacy.id}`)}
          />
        ))}
      </MapView>
      
      <View style={styles.legend}>
        <Text style={styles.legendText}>
          {pharmacies.length} farmacias en el mapa
        </Text>
      </View>
    </View>
  );
}

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
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

// 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<Pharmacy | null>(null);
  const [medicines, setMedicines] = useState<PharmacyMedicine[]>([]);
  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 <LoadingSpinner message="Cargando farmacia..." />;
  }

  if (!pharmacy) {
    return (
      <View style={styles.errorContainer}>
        <Text style={styles.errorText}>Farmacia no encontrada</Text>
      </View>
    );
  }

  return (
    <ScrollView style={styles.container}>
      <View style={styles.header}>
        <Text style={styles.name}>{pharmacy.name}</Text>
      </View>

      <View style={styles.actionsRow}>
        <TouchableOpacity style={styles.actionButton} onPress={handleCall}>
          <Ionicons name="call" size={20} color={colors.primary} />
          <Text style={styles.actionText}>Llamar</Text>
        </TouchableOpacity>
        
        <TouchableOpacity style={styles.actionButton} onPress={handleDirections}>
          <Ionicons name="navigate" size={20} color={colors.primary} />
          <Text style={styles.actionText}>Cómo llegar</Text>
        </TouchableOpacity>
      </View>

      <View style={styles.infoSection}>
        <View style={styles.infoRow}>
          <Ionicons name="location" size={18} color={colors.textSecondary} />
          <Text style={styles.infoText}>{pharmacy.address}</Text>
        </View>
        
        {pharmacy.phone && (
          <View style={styles.infoRow}>
            <Ionicons name="call" size={18} color={colors.textSecondary} />
            <Text style={styles.infoText}>{pharmacy.phone}</Text>
          </View>
        )}
      </View>

      <View style={styles.mapContainer}>
        <MapView
          style={styles.map}
          initialRegion={{
            latitude: pharmacy.latitude,
            longitude: pharmacy.longitude,
            latitudeDelta: 0.01,
            longitudeDelta: 0.01,
          }}
          scrollEnabled={false}
        >
          <Marker
            coordinate={{
              latitude: pharmacy.latitude,
              longitude: pharmacy.longitude,
            }}
            title={pharmacy.name}
          />
        </MapView>
      </View>

      <View style={styles.medicinesSection}>
        <Text style={styles.sectionTitle}>
          Medicamentos ({medicines.length})
        </Text>
        
        {medicines.length === 0 ? (
          <Text style={styles.noMedicines}>No hay medicamentos disponibles</Text>
        ) : (
          medicines.map((med) => (
            <TouchableOpacity
              key={med.id}
              style={styles.medicineCard}
              onPress={() => router.push(`/medicine/${med.medicine_nregistro}`)}
            >
              <View style={styles.medicineInfo}>
                <Text style={styles.medicineName}>{med.medicine_name}</Text>
                <Text style={styles.medicineNregistro}>Reg: {med.medicine_nregistro}</Text>
              </View>
              <View style={styles.medicineStock}>
                <Text style={styles.price}>{med.price.toFixed(2)} </Text>
                <Text style={styles.stock}>Stock: {med.stock}</Text>
              </View>
            </TouchableOpacity>
          ))
        )}
      </View>
    </ScrollView>
  );
}

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
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

// 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 (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
    >
      <View style={styles.form}>
        <Text style={styles.title}>Iniciar Sesión</Text>
        <Text style={styles.subtitle}>Ingresa tus credenciales para continuar</Text>

        <View style={styles.inputContainer}>
          <Text style={styles.label}>Usuario</Text>
          <TextInput
            style={styles.input}
            value={username}
            onChangeText={setUsername}
            placeholder="Tu usuario"
            placeholderTextColor={colors.textSecondary}
            autoCapitalize="none"
            autoCorrect={false}
          />
        </View>

        <View style={styles.inputContainer}>
          <Text style={styles.label}>Contraseña</Text>
          <TextInput
            style={styles.input}
            value={password}
            onChangeText={setPassword}
            placeholder="Tu contraseña"
            placeholderTextColor={colors.textSecondary}
            secureTextEntry
          />
        </View>

        <TouchableOpacity
          style={[styles.button, isLoading && styles.buttonDisabled]}
          onPress={handleLogin}
          disabled={isLoading}
        >
          <Text style={styles.buttonText}>
            {isLoading ? 'Ingresando...' : 'Iniciar Sesión'}
          </Text>
        </TouchableOpacity>

        <TouchableOpacity
          style={styles.linkButton}
          onPress={() => router.push('/auth/register')}
        >
          <Text style={styles.linkText}>¿No tienes cuenta? Regístrate</Text>
        </TouchableOpacity>
      </View>
    </KeyboardAvoidingView>
  );
}

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
// 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 (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
    >
      <View style={styles.form}>
        <Text style={styles.title}>Crear Cuenta</Text>
        <Text style={styles.subtitle}>Regístrate para empezar</Text>

        <View style={styles.inputContainer}>
          <Text style={styles.label}>Usuario</Text>
          <TextInput
            style={styles.input}
            value={username}
            onChangeText={setUsername}
            placeholder="Elige un usuario"
            placeholderTextColor={colors.textSecondary}
            autoCapitalize="none"
            autoCorrect={false}
          />
        </View>

        <View style={styles.inputContainer}>
          <Text style={styles.label}>Contraseña</Text>
          <TextInput
            style={styles.input}
            value={password}
            onChangeText={setPassword}
            placeholder="Elige una contraseña"
            placeholderTextColor={colors.textSecondary}
            secureTextEntry
          />
        </View>

        <View style={styles.inputContainer}>
          <Text style={styles.label}>Confirmar Contraseña</Text>
          <TextInput
            style={styles.input}
            value={confirmPassword}
            onChangeText={setConfirmPassword}
            placeholder="Repite la contraseña"
            placeholderTextColor={colors.textSecondary}
            secureTextEntry
          />
        </View>

        <TouchableOpacity
          style={[styles.button, isLoading && styles.buttonDisabled]}
          onPress={handleRegister}
          disabled={isLoading}
        >
          <Text style={styles.buttonText}>
            {isLoading ? 'Creando cuenta...' : 'Crear Cuenta'}
          </Text>
        </TouchableOpacity>

        <TouchableOpacity
          style={styles.linkButton}
          onPress={() => router.back()}
        >
          <Text style={styles.linkText}>¿Ya tienes cuenta? Inicia sesión</Text>
        </TouchableOpacity>
      </View>
    </KeyboardAvoidingView>
  );
}

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
// 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 (
      <View style={styles.container}>
        <Text style={styles.loadingText}>Cargando...</Text>
      </View>
    );
  }

  if (!isAuthenticated) {
    return (
      <View style={styles.container}>
        <View style={styles.authPrompt}>
          <Ionicons name="person-outline" size={64} color={colors.textSecondary} />
          <Text style={styles.authTitle}>Inicia Sesión</Text>
          <Text style={styles.authSubtitle}>
            Inicia sesión para acceder a todas las funcionalidades
          </Text>
          <TouchableOpacity
            style={styles.button}
            onPress={() => router.push('/auth/login')}
          >
            <Text style={styles.buttonText}>Iniciar Sesión</Text>
          </TouchableOpacity>
          <TouchableOpacity
            style={styles.linkButton}
            onPress={() => router.push('/auth/register')}
          >
            <Text style={styles.linkText}>Crear cuenta nueva</Text>
          </TouchableOpacity>
        </View>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <View style={styles.header}>
        <View style={styles.avatar}>
          <Ionicons name="person" size={40} color={colors.primary} />
        </View>
        <Text style={styles.username}>{user?.username}</Text>
        {isAdmin && <Text style={styles.adminBadge}>Administrador</Text>}
      </View>

      <View style={styles.menuSection}>
        {isAdmin && (
          <TouchableOpacity style={styles.menuItem}>
            <Ionicons name="settings" size={24} color={colors.text} />
            <Text style={styles.menuText}>Panel Admin</Text>
            <Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
          </TouchableOpacity>
        )}
        
        <TouchableOpacity style={styles.menuItem}>
          <Ionicons name="heart" size={24} color={colors.text} />
          <Text style={styles.menuText}>Favoritos</Text>
          <Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
        </TouchableOpacity>
        
        <TouchableOpacity style={styles.menuItem}>
          <Ionicons name="notifications" size={24} color={colors.text} />
          <Text style={styles.menuText}>Notificaciones</Text>
          <Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
        </TouchableOpacity>
        
        <TouchableOpacity style={styles.menuItem}>
          <Ionicons name="help-circle" size={24} color={colors.text} />
          <Text style={styles.menuText}>Ayuda</Text>
          <Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
        </TouchableOpacity>
      </View>

      <TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
        <Ionicons name="log-out" size={20} color={colors.danger} />
        <Text style={styles.logoutText}>Cerrar Sesión</Text>
      </TouchableOpacity>
    </View>
  );
}

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
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

// 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 (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <QueryClientProvider client={queryClient}>
        <Stack>
          <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
          <Stack.Screen 
            name="medicine/[id]" 
            options={{ 
              title: 'Medicamento',
              headerTintColor: '#007AFF',
            }} 
          />
          <Stack.Screen 
            name="pharmacy/[id]" 
            options={{ 
              title: 'Farmacia',
              headerTintColor: '#007AFF',
            }} 
          />
          <Stack.Screen 
            name="auth/login" 
            options={{ 
              title: 'Iniciar Sesión',
              headerShown: false,
            }} 
          />
          <Stack.Screen 
            name="auth/register" 
            options={{ 
              title: 'Registrarse',
              headerShown: false,
            }} 
          />
        </Stack>
        <StatusBar style="auto" />
      </QueryClientProvider>
    </GestureHandlerRootView>
  );
}
  • Step 2: Commit
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

{
  "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
# 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
{
  "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
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

// 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
// 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<Notifications.Subscription>();
  const responseListener = useRef<Notifications.Subscription>();

  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
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

// 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 <View style={styles.container} />;
  }

  if (!permission.granted) {
    return (
      <View style={styles.permissionContainer}>
        <Ionicons name="camera" size={64} color={colors.textSecondary} />
        <Text style={styles.permissionTitle}>Permiso de cámara requerido</Text>
        <Text style={styles.permissionText}>
          Necesitamos acceso a la cámara para escanear códigos de barras
        </Text>
        <TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
          <Text style={styles.permissionButtonText}>Conceder permiso</Text>
        </TouchableOpacity>
        <TouchableOpacity style={styles.cancelButton} onPress={onClose}>
          <Text style={styles.cancelButtonText}>Cancelar</Text>
        </TouchableOpacity>
      </View>
    );
  }

  const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => {
    if (scanned) return;
    setScanned(true);
    onBarcodeScanned(data);
  };

  return (
    <View style={styles.container}>
      <CameraView
        style={StyleSheet.absoluteFillObject}
        facing="back"
        barcodeScannerSettings={{
          barcodeTypes: ['ean13', 'ean8', 'upc_a', 'upc_e'],
        }}
        onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
      />
      
      <View style={styles.overlay}>
        <View style={styles.scannerFrame}>
          <View style={styles.corner} style={[styles.corner, styles.topLeft]} />
          <View style={[styles.corner, styles.topRight]} />
          <View style={[styles.corner, styles.bottomLeft]} />
          <View style={[styles.corner, styles.bottomRight]} />
        </View>
        
        <Text style={styles.instruction}>
          Apunta la cámara al código de barras del medicamento
        </Text>
        
        <TouchableOpacity style={styles.closeButton} onPress={onClose}>
          <Ionicons name="close" size={24} color={colors.textInverse} />
        </TouchableOpacity>
      </View>
      
      {scanned && (
        <View style={styles.scannedOverlay}>
          <TouchableOpacity 
            style={styles.scanAgainButton}
            onPress={() => setScanned(false)}
          >
            <Text style={styles.scanAgainText}>Escanear de nuevo</Text>
          </TouchableOpacity>
        </View>
      )}
    </View>
  );
}

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
// 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 (
    <View style={styles.container}>
      <BarcodeScanner
        onBarcodeScanned={handleBarcodeScanned}
        onClose={handleClose}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
});
  • Step 3: Add scanner button to home screen
// 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
<TouchableOpacity 
  style={styles.scannerButton}
  onPress={() => router.push('/scanner')}
>
  <Ionicons name="scan" size={24} color={colors.primary} />
</TouchableOpacity>

// Add to styles
scannerButton: {
  position: 'absolute',
  right: spacing.md,
  top: spacing.md,
  backgroundColor: colors.card,
  borderRadius: 20,
  padding: spacing.sm,
},
  • Step 4: Commit
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

// frontend-mobile/services/biometrics.ts
import * as LocalAuthentication from 'expo-local-authentication';
import * as SecureStore from 'expo-secure-store';

export async function isBiometricsAvailable(): Promise<boolean> {
  const compatible = await LocalAuthentication.hasHardwareAsync();
  const enrolled = await LocalAuthentication.isEnrolledAsync();
  return compatible && enrolled;
}

export async function authenticateWithBiometrics(): Promise<boolean> {
  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<void> {
  await SecureStore.setItemAsync('biometric_username', username);
}

export async function getBiometricUsername(): Promise<string | null> {
  return await SecureStore.getItemAsync('biometric_username');
}

export async function clearBiometricCredentials(): Promise<void> {
  await SecureStore.deleteItemAsync('biometric_username');
}
  • Step 2: Update login screen with biometrics
// 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<string | null>(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 && (
  <TouchableOpacity 
    style={styles.biometricButton}
    onPress={handleBiometricLogin}
  >
    <Ionicons name="finger-print" size={24} color={colors.primary} />
    <Text style={styles.biometricText}>
      Iniciar sesión con biometría
    </Text>
  </TouchableOpacity>
)}

// 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
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

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)
cd /home/f80ans0/Projects/Webs/FarmaFinder/frontend-mobile
npx expo start --ios

Expected: App launches in simulator

  • Step 3: Test on Android Emulator
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

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