Guía de Desarrollo
BioSello en React Native (JavaScript)
Aprende a construir la app móvil de trazabilidad cárnica desde cero — desde los fundamentos de React Native hasta el despliegue del APK final.
📗 5 módulos básicos
📒 4 módulos intermedios
📕 6 módulos avanzados
01
Intro React Native
Qué es, por qué usarlo, instalación del entorno.
02
Estructura del Proyecto
Organización de carpetas y archivos del proyecto BioSello.
03
Componentes y Hooks
Props, state, useState, useEffect y hooks personalizados.
04
Navegación
React Navigation: Stack, Tab y Drawer para BioSello.
05
Estilos
StyleSheet, Flexbox, temas y diseño del dashboard.
06
Formularios
Registro de animales, guías de tránsito y validaciones.
07
Consumo de APIs
fetch, Axios, manejo de errores y estados de carga.
08
Almacenamiento
AsyncStorage, Zustand y caché offline para lotes.
09
Arquitectura
Patrones, separación de responsabilidades y clean code.
10
Backend y MariaDB
Conexión a la BD biosello y diseño del schema.
11
API REST
Node.js + Express: endpoints para animales, lotes y guías.
12
Autenticación JWT
Login, roles admin/empleado/cliente y sesiones seguras.
13
Códigos QR
Generación, escaneo y trazabilidad por lote.
14
Realidad Aumentada
ViroReact / AR para visualizar info del lote en cámara.
15
Despliegue y APK
Build, firma y publicación de la app BioSello.
01
¿Qué es React Native?
React Native es un framework de Meta que permite construir aplicaciones móviles nativas para Android e iOS usando JavaScript y la misma lógica de React. En lugar de generar una WebView, compila componentes que mapean directamente a widgets nativos del SO — por eso las apps se sienten fluidas.
BioSello: Usaremos React Native para que inspectores, empleados de rastro y clientes puedan acceder a la trazabilidad cárnica desde su celular, escaneando el código QR del lote y viendo todos los datos del animal.
React Native vs alternativas
| Criterio | React Native | Flutter | Nativo Android/iOS |
|---|---|---|---|
| Lenguaje | JavaScript / TypeScript | Dart | Kotlin / Swift |
| Rendimiento | Muy bueno (bridge) | Excelente | Excelente |
| Curva aprendizaje | Baja (si conoces React) | Media | Alta |
| Comunidad | Enorme | Grande | Grande |
| Código compartido | ~90 % | ~95 % | 0 % |
Instalación del entorno
Node.js LTS — Descarga desde
nodejs.org. Verifica con node -v y npm -v.React Native CLI —
npm install -g react-native-cliAndroid Studio — Instala el SDK de Android, configura
ANDROID_HOME en tu PATH.JDK 17 — Necesario para compilar el proyecto Android.
Crear el proyecto — Ejecuta el comando de abajo.
bash — Terminal
# Crear el proyecto BioSello npx react-native@latest init BioSello # Entrar al proyecto cd BioSello # Correr en Android (con emulador o dispositivo conectado) npx react-native run-android
Nota: Si usas Windows, asegúrate de que las variables
ANDROID_HOME y JAVA_HOME estén configuradas. Si usas Mac, puedes ejecutar npx react-native run-ios también.Hola BioSello — primer componente
App.jsx
import React from 'react'; import { View, Text, StyleSheet } from 'react-native'; export default function App() { return ( <View style={styles.container}> <Text style={styles.title}>🐄 BioSello</Text> <Text style={styles.sub}>Trazabilidad cárnica en tu mano</Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#0d1117' }, title: { fontSize: 32, fontWeight: 'bold', color: '#3fb950' }, sub: { fontSize: 16, color: '#8b949e', marginTop: 8 }, });
✏️ Ejercicio 01
Tu primer componente BioSello
- Crea la pantalla de inicio mostrando el número de arete
1301226566y la especie BOVINO. - Agrega un botón que muestre en consola "Animal registrado".
- Cambia el color de fondo según el sexo: verde para HEMBRA, azul para MACHO.
02
Árbol de archivos recomendado
Estructura de carpetas — BioSello/
BioSello/ ├── android/ # Código nativo Android ├── ios/ # Código nativo iOS ├── src/ │ ├── api/ # Llamadas HTTP al backend │ │ ├── animales.js │ │ ├── lotes.js │ │ ├── auth.js │ │ └── client.ts # Instancia Axios configurada │ ├── components/ # Componentes reutilizables │ │ ├── AnimalCard.jsx │ │ ├── LoteCard.jsx │ │ ├── QRScanner.jsx │ │ └── AlertaBadge.jsx │ ├── screens/ # Una pantalla = un archivo │ │ ├── LoginScreen.jsx │ │ ├── DashboardScreen.jsx │ │ ├── AnimalDetailScreen.jsx │ │ ├── LoteFormScreen.jsx │ │ ├── GuiaTransitoScreen.jsx │ │ └── QRResultScreen.jsx │ ├── navigation/ # Configuración de rutas │ │ ├── AppNavigator.jsx │ │ ├── AuthNavigator.jsx │ │ └── TabNavigator.jsx │ ├── store/ # Estado global (Zustand) │ │ ├── authStore.js │ │ └── loteStore.js │ ├── hooks/ # Hooks personalizados │ │ ├── useAnimales.js │ │ └── useAlerts.js │ ├── utils/ # Funciones utilitarias │ │ ├── validators.js │ │ └── formatters.js │ ├── constants/ │ │ └── theme.ts # Colores y tipografía │ └── types/ │ └── index.ts # Tipos TypeScript del dominio ├── App.jsx ├── package.json └── babel.config.js
Tipos del dominio BioSello
src/constants/shapes.js
// En JavaScript puro no hay interfaces. // Documentamos la forma de los objetos con JSDoc: /** * @typedef {Object} Animal * @property {number} id_animal * @property {string} num_arete * @property {'BOVINO'|'PORCINO'|'OVINO'|'CAPRINO'|'EQUINO'} especie * @property {'MACHO'|'HEMBRA'} sexo * @property {'VAQUILLA'|'VACA'|'TORETE'|'TORO'|'BECERRO'|'BECERRA'|'BUEY'} clasificacion * @property {number} meses_edad * @property {boolean} arete_faltante * @property {string} [foto_url] * @property {number} id_origen * @property {number} id_propietario * @property {string} created_at */ /** * @typedef {Object} Lote * @property {number} id_lote * @property {string} codigo_lote * @property {string} tipo_corte * @property {number} peso_kg * @property {string} fecha_ingreso * @property {string} fecha_vencimiento * @property {'activo'|'procesado'|'vendido'|'caducado'} estado * @property {number} [id_animal] * @property {string} [foto_url] */ /** * @typedef {Object} Usuario * @property {number} id_usuario * @property {string} nombre * @property {string} email * @property {'admin'|'empleado'|'cliente'} perfil * @property {boolean} activo */
✏️ Ejercicio 02
Crea la estructura en tu proyecto
- Ejecuta
mkdir -p src/{api,components,screens,navigation,store,hooks,utils,constants,types} - Crea el archivo
src/constants/shapes.jscon los comentarios JSDoc mostrados. - Agrega los typedef JSDoc de
PropietarioyOrigenbasándote en el schema de la BD.
03
Props — AnimalCard
Las props son los parámetros que un componente padre le pasa a un hijo. Son de solo lectura.
src/components/AnimalCard.jsx
import React from 'react'; import { View, Text, Image, StyleSheet, TouchableOpacity } from 'react-native'; import { Animal } from '../constants/shapes'; // Las props se pasan sin tipado estático en JS export default function AnimalCard({ animal, onPress }) { const esHembra = animal.sexo === 'HEMBRA'; return ( <TouchableOpacity style={styles.card} onPress={onPress}> <View style={[styles.badge, esHembra ? styles.hembra : styles.macho]}> <Text style={styles.badgeText}>{animal.sexo}</Text> </View> <Text style={styles.arete}>🏷 {animal.num_arete}</Text> <Text style={styles.info}>{animal.especie} · {animal.clasificacion}</Text> <Text style={styles.edad}>{animal.meses_edad} meses</Text> </TouchableOpacity> ); } const styles = StyleSheet.create({ card: { backgroundColor: '#161b22', borderRadius: 10, padding: 16, marginBottom: 12, borderWidth: 1, borderColor: '#30363d' }, badge: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 3, borderRadius: 20, marginBottom: 8 }, hembra: { backgroundColor: 'rgba(63,185,80,.2)' }, macho: { backgroundColor: 'rgba(88,166,255,.2)' }, badgeText: { fontSize: 11, fontWeight: '700', color: '#e6edf3' }, arete: { fontSize: 18, fontWeight: 'bold', color: '#e6edf3' }, info: { fontSize: 14, color: '#8b949e', marginTop: 4 }, edad: { fontSize: 13, color: '#3fb950', marginTop: 6 }, });
useState — Formulario de arete
Uso de useState
import React, { useState } from 'react'; import { View, TextInput, Text, Button, StyleSheet } from 'react-native'; export default function BuscarAnimal() { // [valor actual, función para actualizar] const [arete, setArete] = useState(''); const [error, setError] = useState(null); const validar = () => { if (arete.length < 10) { setError('El número de arete debe tener al menos 10 caracteres'); } else { setError(null); // llamar API de búsqueda... } }; return ( <View style={styles.container}> <TextInput style={[styles.input, error && styles.inputError]} placeholder="Ej: 1301226566" placeholderTextColor="#8b949e" value={arete} onChangeText={setArete} keyboardType="numeric" /> {error && <Text style={styles.error}>{error}</Text>} <Button title="Buscar animal" onPress={validar} color="#3fb950" /> </View> ); } const styles = StyleSheet.create({ container: { padding: 16 }, input: { borderWidth: 1, borderColor: '#30363d', borderRadius: 8, padding: 12, color: '#e6edf3', backgroundColor: '#161b22', marginBottom: 8 }, inputError:{ borderColor: '#f78166' }, error: { color: '#f78166', fontSize: 12, marginBottom: 8 }, });
useEffect — Cargar lotes al montar
useEffect para carga de datos
import React, { useState, useEffect } from 'react'; import { View, FlatList, ActivityIndicator } from 'react-native'; import { Lote } from '../constants/shapes'; import { getLotes } from '../api/lotes'; import LoteCard from '../components/LoteCard'; export default function DashboardScreen() { const [lotes, setLotes] = useState([]); const [cargando, setCargando] = useState(true); // Se ejecuta al montar el componente useEffect(() => { const cargarDatos = async () => { try { const data = await getLotes(); setLotes(data); } catch(e) { console.error('Error cargando lotes', e); } finally { setCargando(false); } }; cargarDatos(); }, []); // [] = solo se ejecuta una vez if (cargando) return <ActivityIndicator color="#3fb950" />; return ( <FlatList data={lotes} keyExtractor={item => String(item.id_lote)} renderItem={({ item }) => <LoteCard lote={item} />} /> ); }
Hook personalizado — useAnimales
src/hooks/useAnimales.ts
import { useState, useEffect, useCallback } from 'react'; import { Animal } from '../constants/shapes'; import { getAnimales } from '../api/animales'; export function useAnimales() { const [animales, setAnimales] = useState([]); const [cargando, setCargando] = useState(false); const [error, setError] = useState(null); const cargar = useCallback(async () => { setCargando(true); try { const data = await getAnimales(); setAnimales(data); setError(null); } catch { setError('No se pudo conectar al servidor'); } finally { setCargando(false); } }, []); useEffect(() => { cargar(); }, [cargar]); return { animales, cargando, error, recargar: cargar }; } // Uso en un componente: // const { animales, cargando, error, recargar } = useAnimales();
✏️ Ejercicio 03
Construye LoteCard
- Crea
src/components/LoteCard.jsxque muestre: código de lote, tipo de corte, peso en kg y estado. - Colorea el badge de estado: verde=activo, amarillo=procesado, azul=vendido, rojo=caducado.
- Crea un hook
useLotesensrc/hooks/useLotes.js.
04
Instalación
bash
npm install @react-navigation/native @react-navigation/native-stack @react-navigation/bottom-tabs npm install react-native-screens react-native-safe-area-context
Flujo de navegación BioSello
src/navigation/AppNavigator.jsx
import React from 'react'; import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { useAuthStore } from '../store/authStore'; // Pantallas import LoginScreen from '../screens/LoginScreen'; import DashboardScreen from '../screens/DashboardScreen'; import AnimalDetailScreen from '../screens/AnimalDetailScreen'; import LoteFormScreen from '../screens/LoteFormScreen'; import QRResultScreen from '../screens/QRResultScreen'; // Tipado de rutas export type RootStackParamList = { Login: undefined; Tabs: undefined; AnimalDetail: { id_animal }; LoteForm: { id_lote? }; QRResult: { codigo_lote }; }; const Stack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); function MainTabs() { return ( <Tab.Navigator screenOptions={{ tabBarStyle: { backgroundColor: '#161b22' }, tabBarActiveTintColor: '#3fb950' }}> <Tab.Screen name="Dashboard" component={DashboardScreen} options={{ tabBarLabel: 'Inicio', tabBarIcon: () => '🏠' }} /> <Tab.Screen name="Animales" component={AnimalDetailScreen} options={{ tabBarLabel: 'Animales', tabBarIcon: () => '🐄' }} /> <Tab.Screen name="QR" component={QRResultScreen} options={{ tabBarLabel: 'Escanear', tabBarIcon: () => '📷' }} /> </Tab.Navigator> ); } export default function AppNavigator() { const usuario = useAuthStore(s => s.usuario); return ( <NavigationContainer> <Stack.Navigator screenOptions={{ headerStyle: { backgroundColor: '#161b22' }, headerTintColor: '#e6edf3' }}> {!usuario ? ( <Stack.Screen name="Login" component={LoginScreen} options={{ headerShown: false }} /> ) : ( <> <Stack.Screen name="Tabs" component={MainTabs} options={{ headerShown: false }} /> <Stack.Screen name="AnimalDetail" component={AnimalDetailScreen} options={{ title: 'Detalle del Animal' }} /> <Stack.Screen name="LoteForm" component={LoteFormScreen} options={{ title: 'Registrar Lote' }} /> <Stack.Screen name="QRResult" component={QRResultScreen} options={{ title: 'Resultado QR' }} /> </> )} </Stack.Navigator> </NavigationContainer> ); }
Navegar y pasar parámetros
Uso en una pantalla
import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { RootStackParamList } from '../navigation/AppNavigator'; export default function AnimalRow({ animal }: { animal: Animal }) { const nav = useNavigation(); return ( <TouchableOpacity onPress={() => nav.navigate('AnimalDetail', { id_animal: animal.id_animal })}> <Text>{animal.num_arete}</Text> </TouchableOpacity> ); }
05
Sistema de tema — constants/theme.ts
src/constants/theme.ts
export const colors = { bg: '#0d1117', surface: '#161b22', border: '#30363d', accent: '#3fb950', // verde BioSello blue: '#58a6ff', danger: '#f78166', gold: '#d29922', text: '#e6edf3', muted: '#8b949e', }; export const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, }; export const typography = { h1: { fontSize: 28, fontWeight: 'bold', color: colors.text }, h2: { fontSize: 22, fontWeight: '700', color: colors.text }, body: { fontSize: 15, color: colors.text }, mono: { fontFamily: 'monospace', fontSize: 13, color: colors.blue }, };
Flexbox en React Native
En React Native el eje principal por defecto es column (vertical), a diferencia de la web donde es row. Todo es Flexbox — no existe CSS Grid.
Dashboard layout con Flexbox
const styles = StyleSheet.create({ screen: { flex: 1, backgroundColor: colors.bg }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: spacing.md, borderBottomWidth: 1, borderColor: colors.border }, statsRow: { flexDirection: 'row', gap: 12, padding: spacing.md }, statCard: { flex: 1, backgroundColor: colors.surface, borderRadius: 10, padding: spacing.md, alignItems: 'center' }, statNum: { fontSize: 28, fontWeight: 'bold', color: colors.accent }, statLabel: { fontSize: 11, color: colors.muted, marginTop: 4, textAlign: 'center' }, });
✏️ Ejercicio 05
Dashboard de BioSello
- Crea un dashboard con 3 tarjetas de estadísticas: Animales registrados, Lotes activos, Alertas pendientes.
- Usa el sistema de tema de
theme.tsen lugar de valores hardcoded. - Agrega un
FlatListdebajo con los últimos 5 lotes.
06
Formulario — Nuevo Animal
src/screens/NuevoAnimalScreen.jsx
import React, { useState } from 'react'; import { View, TextInput, Text, ScrollView, TouchableOpacity, StyleSheet } from 'react-native'; import { colors, spacing } from '../constants/theme'; import { crearAnimal } from '../api/animales'; const ESPECIES = ['BOVINO', 'PORCINO', 'OVINO', 'CAPRINO', 'EQUINO']; const SEXOS = ['MACHO', 'HEMBRA']; export default function NuevoAnimalScreen() { const [form, setForm] = useState({ num_arete: '', especie: 'BOVINO', sexo: 'HEMBRA', meses_edad: '', arete_faltante: false, }); const [errores, setErrores] = useState({}); const validar = () => { const e = {}; if (!form.num_arete || form.num_arete.length < 10) e.num_arete = 'Arete inválido (mín. 10 dígitos)'; if (!form.meses_edad || isNaN(Number(form.meses_edad))) e.meses_edad = 'Edad requerida'; return e; }; const guardar = async () => { const e = validar(); if (Object.keys(e).length) { setErrores(e); return; } await crearAnimal({ ...form, meses_edad: Number(form.meses_edad) }); }; return ( <ScrollView style={s.screen}> <Text style={s.label}>Número de Arete *</Text> <TextInput style={[s.input, errores.num_arete && s.inputErr]} value={form.num_arete} onChangeText={v => setForm(f => ({...f, num_arete: v}))} placeholder="1301226566" placeholderTextColor={colors.muted} keyboardType="numeric" /> {errores.num_arete && <Text style={s.err}>{errores.num_arete}</Text>} <Text style={s.label}>Especie</Text> <View style={s.chips}> {ESPECIES.map(esp => ( <TouchableOpacity key={esp} style={[s.chip, form.especie===esp && s.chipActive]} onPress={() => setForm(f => ({...f, especie:esp}))}> <Text style={form.especie===esp ? s.chipTxtActive : s.chipTxt}>{esp}</Text> </TouchableOpacity> ))} </View> <TouchableOpacity style={s.btn} onPress={guardar}> <Text style={s.btnTxt}>Registrar Animal</Text> </TouchableOpacity> </ScrollView> ); } const s = StyleSheet.create({ screen: { flex: 1, backgroundColor: colors.bg, padding: spacing.md }, label: { color: colors.muted, fontSize: 12, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.8, marginTop: spacing.md, marginBottom: 6 }, input: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 8, padding: spacing.md, color: colors.text, fontSize: 15 }, inputErr: { borderColor: colors.danger }, err: { color: colors.danger, fontSize: 12, marginTop: 4 }, chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 }, chip: { paddingHorizontal: 12, paddingVertical: 7, borderRadius: 20, borderWidth: 1, borderColor: colors.border }, chipActive: { backgroundColor: colors.accent, borderColor: colors.accent }, chipTxt: { color: colors.muted, fontSize: 13 }, chipTxtActive:{ color: '#000', fontWeight: '700', fontSize: 13 }, btn: { backgroundColor: colors.accent, borderRadius: 10, padding: spacing.md, alignItems: 'center', marginTop: spacing.lg }, btnTxt: { color: '#000', fontWeight: '700', fontSize: 16 }, });
07
Cliente Axios configurado
src/api/client.ts
import axios from 'axios'; import AsyncStorage from '@react-native-async-storage/async-storage'; export const api = axios.create({ baseURL: __DEV__ ? 'http://192.168.1.100:3000/api' : 'https://biosello.app/api', timeout: 10000, headers: { 'Content-Type': 'application/json' }, }); // Interceptor: agrega JWT a cada petición api.interceptors.request.use(async config => { const token = await AsyncStorage.getItem('biosello_token'); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); // Interceptor: redirige al login si el token expiró api.interceptors.response.use( r => r, async err => { if (err.response?.status === 401) { await AsyncStorage.removeItem('biosello_token'); // navegar al login... } return Promise.reject(err); } );
Módulo de animales
src/api/animales.ts
import { api } from './client'; import { Animal } from '../constants/shapes'; export const getAnimales = async () => { const { data } = await api.get('/animales'); return data; }; export const getAnimalByArete = async (num_arete): Promise<Animal> => { const { data } = await api.get(`/animales/arete/${num_arete}`); return data; }; export const crearAnimal = async (payload): Promise<Animal> => { const { data } = await api.post('/animales', payload); return data; };
08
Zustand — Auth Store
src/store/authStore.ts
import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Usuario } from '../constants/shapes'; export const useAuthStore = create()( persist( (set) => ({ usuario: null, token: null, login: (usuario, token) => set({ usuario, token }), logout: () => set({ usuario: null, token: null }), }), { name: 'biosello-auth', storage: createJSONStorage(() => AsyncStorage), } ) );
Ventaja: Con
persist, el usuario permanece logueado aunque cierre la app. El token se guarda automáticamente en AsyncStorage.09
Capas de la aplicación
| Capa | Responsabilidad | Archivos BioSello |
|---|---|---|
| Presentación | Renderizar UI, recibir eventos del usuario | screens/, components/ |
| Dominio | Lógica de negocio, validaciones, tipos | hooks/, utils/, types/ |
| Datos | Llamadas HTTP, caché, persistencia | api/, store/ |
Reglas de oro
- Un archivo = una responsabilidad.
AnimalCard.tsxsolo renderiza un animal. - No lógica de negocio en las pantallas. Mueve validaciones a
utils/validators.ts. - Nunca hagas fetch directamente en un componente. Usa hooks (
useAnimales) o el móduloapi/. - Documenta con JSDoc. Cada objeto de respuesta del servidor debe tener su typedef en
constants/shapes.js. - Valida en runtime. Sin compilador, verifica los datos que llegan del servidor antes de usarlos.
Validadores reutilizables
src/utils/validators.ts
export const validarArete = (arete) => { if (!/^\d{10,13}$/.test(arete)) return 'El arete debe tener 10–13 dígitos numéricos'; return null; }; export const validarFolio = (folio) => { if (!folio.trim()) return 'El folio de guía es obligatorio'; if (folio.length > 20) return 'Máximo 20 caracteres'; return null; };
10
Schema de la BD biosello
animal
id_animalPK
num_aretevarchar(30)
especieenum
sexoenum
clasificacionenum
meses_edadsmallint
id_origenFK
id_propietarioFK
lote
id_lotePK
codigo_lotevarchar(50)
tipo_cortevarchar
peso_kgdecimal
fecha_vencimientodate
estadoenum
id_animalFK
guia_transito
id_guiaPK
folio_guiavarchar(20)
motivo_movilizacionenum
fecha_expediciondate
fecha_vencimientodate
id_propietarioFK
id_rastroFK
usuario
id_usuarioPK
nombrevarchar
emailvarchar
contrasena_hashvarchar
perfilenum
codigo_qr
id_qrPK
id_loteFK
url_destinovarchar
fecha_generadodatetime
alerta
id_alertaPK
tipoenum
id_loteFK
id_usuario_destinoFK
leidatinyint
Pool de conexión (Node.js)
backend/src/db.js
import mysql from 'mysql2/promise'; import dotenv from 'dotenv'; dotenv.config(); export const pool = mysql.createPool({ host: process.env.DB_HOST ?? 'localhost', user: process.env.DB_USER ?? 'root', password: process.env.DB_PASSWORD ?? '', database: process.env.DB_NAME ?? 'biosello', port: Number(process.env.DB_PORT) || 3306, waitForConnections: true, connectionLimit: 10, charset: 'utf8', }); // Función helper export async function query(sql, values?[]) { const [rows] = await pool.execute(sql, values); return rows; }
11
Estructura del backend
backend/
backend/ ├── src/ │ ├── db.js │ ├── app.js │ ├── routes/ │ │ ├── auth.routes.js │ │ ├── animales.routes.js │ │ ├── lotes.routes.js │ │ ├── guias.routes.js │ │ └── qr.routes.js │ ├── controllers/ │ │ ├── animales.controller.js │ │ └── lotes.controller.js │ └── middleware/ │ └── auth.middleware.js ├── .env └── package.json
app.js — Servidor Express
backend/src/app.js
import express from 'express'; import cors from 'cors'; import authRoutes from './routes/auth.routes'; import animalRoutes from './routes/animales.routes'; import loteRoutes from './routes/lotes.routes'; import guiaRoutes from './routes/guias.routes'; import qrRoutes from './routes/qr.routes'; const app = express(); app.use(cors()); app.use(express.json()); app.use('/api/auth', authRoutes); app.use('/api/animales', animalRoutes); app.use('/api/lotes', loteRoutes); app.use('/api/guias', guiaRoutes); app.use('/api/qr', qrRoutes); app.listen(3000, () => console.log('🐄 BioSello API corriendo en :3000'));
Controlador de Animales
backend/src/controllers/animales.controller.js
import { Request, Response } from 'express'; import { query } from '../db'; import { Animal } from '../constants/shapes'; export const getAnimales = async (req, res) => { const animales = await query( `SELECT a.*, p.nombre_propietario, o.localidad_origen FROM animal a JOIN propietario p ON p.id_propietario = a.id_propietario JOIN origen o ON o.id_origen = a.id_origen ORDER BY a.created_at DESC` ); res.json(animales); }; export const getAnimalByArete = async (req, res) => { const { num_arete } = req.params; const [animal] = await query( 'SELECT * FROM animal WHERE num_arete = ?', [num_arete] ); if (!animal) return res.status(404).json({ error: 'Animal no encontrado' }); res.json(animal); }; export const crearAnimal = async (req, res) => { const { num_arete, especie, sexo, clasificacion, meses_edad, id_origen, id_propietario } = req.body; const [result] = await query( 'INSERT INTO animal (num_arete,especie,sexo,clasificacion,meses_edad,id_origen,id_propietario) VALUES (?,?,?,?,?,?,?)', [num_arete, especie, sexo, clasificacion, meses_edad, id_origen, id_propietario] ); res.status(201).json({ id_animal: result.insertId }); };
Tabla de endpoints
| Método | Ruta | Descripción | Auth |
|---|---|---|---|
| POST | /api/auth/login | Login con email/password | — |
| GET | /api/animales | Listar todos los animales | JWT |
| GET | /api/animales/arete/:num | Buscar animal por arete | JWT |
| POST | /api/animales | Registrar animal | JWT admin/empleado |
| GET | /api/lotes | Listar lotes | JWT |
| POST | /api/lotes | Crear lote | JWT empleado |
| GET | /api/lotes/:id/qr | Generar QR del lote | JWT |
| GET | /api/guias | Listar guías de tránsito | JWT |
| GET | /api/qr/scan/:codigo | Datos públicos del lote (sin auth) | — |
12
Login con bcrypt + JWT
backend/src/routes/auth.routes.js
import express from 'express'; import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import { query } from '../db'; const router = express.Router(); const JWT_SECRET = process.env.JWT_SECRET ?? 'biosello_secret'; router.post('/login', async (req, res) => { const { email, password } = req.body; const [usuario] = await query( 'SELECT * FROM usuario WHERE email = ? AND activo = 1', [email] ); if (!usuario) return res.status(401).json({ error: 'Credenciales inválidas' }); const ok = await bcrypt.compare(password, usuario.contrasena_hash); if (!ok) return res.status(401).json({ error: 'Credenciales inválidas' }); const token = jwt.sign( { id_usuario: usuario.id_usuario, perfil: usuario.perfil }, JWT_SECRET, { expiresIn: '8h' } ); // Guardar sesión en BD await query( 'INSERT INTO sesion (id_usuario, token, fecha_expira) VALUES (?,?,DATE_ADD(NOW(),INTERVAL 8 HOUR))', [usuario.id_usuario, token] ); res.json({ token, usuario: { id_usuario: usuario.id_usuario, nombre: usuario.nombre, perfil: usuario.perfil } }); }); export default router;
Middleware de autenticación
backend/src/middleware/auth.middleware.js
import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; const JWT_SECRET = process.env.JWT_SECRET ?? 'biosello_secret'; // Requiere JWT válido export const auth = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Token requerido' }); try { req.user = jwt.verify(token, JWT_SECRET); next(); } catch { res.status(401).json({ error: 'Token inválido' }); } }; // Requiere rol específico export const requireRole = (...roles[]) => (req, res, next) => { const user = req.user; if (!roles.includes(user?.perfil)) return res.status(403).json({ error: 'Sin permiso' }); next(); }; // Uso: router.post('/animales', auth, requireRole('admin','empleado'), crearAnimal);
13
Generar QR en el backend
backend/src/routes/qr.routes.js
import express from 'express'; import QRCode from 'qrcode'; import { query } from '../db'; import { auth } from '../middleware/auth.middleware'; const router = express.Router(); // Genera QR para un lote y lo guarda en BD router.post('/generar/:id_lote', auth, async (req, res) => { const { id_lote } = req.params; const url = `https://biosello.app/scan/${id_lote}`; const qrBase64 = await QRCode.toDataURL(url, { errorCorrectionLevel: 'H', width: 400 }); const user = req.user; const [result] = await query( 'INSERT INTO codigo_qr (id_lote, url_destino, generado_por) VALUES (?,?,?)', [id_lote, url, user.id_usuario] ); res.json({ id_qr: result.insertId, url, qrBase64 }); }); // Endpoint público para cuando alguien escanea el QR router.get('/scan/:id_lote', async (req, res) => { const { id_lote } = req.params; // Registrar el escaneo const [qr] = await query('SELECT id_qr FROM codigo_qr WHERE id_lote=? ORDER BY fecha_generado DESC LIMIT 1',[id_lote]); if (qr) await query('INSERT INTO escaneo(id_qr,ip,dispositivo) VALUES(?,?,?)',[qr.id_qr, req.ip, req.headers['user-agent']]); const [lote] = await query( `SELECT l.*, a.num_arete, a.especie, a.clasificacion, p.nombre_propietario, r.nombre_rastro FROM lote l LEFT JOIN animal a ON a.id_animal = l.id_animal LEFT JOIN propietario p ON p.id_propietario = a.id_propietario LEFT JOIN guia_animal ga ON ga.id_animal = a.id_animal LEFT JOIN guia_transito gt ON gt.id_guia = ga.id_guia LEFT JOIN rastro r ON r.id_rastro = gt.id_rastro WHERE l.id_lote = ?`, [id_lote] ); if (!lote) return res.status(404).json({ error: 'Lote no encontrado' }); res.json(lote); }); export default router;
Escanear QR en React Native
src/screens/QRScannerScreen.jsx
// npm install react-native-vision-camera import React, { useState } from 'react'; import { StyleSheet, View, Text, TouchableOpacity } from 'react-native'; import { Camera, useCameraDevices, useCodeScanner } from 'react-native-vision-camera'; import { useNavigation } from '@react-navigation/native'; export default function QRScannerScreen() { const devices = useCameraDevices(); const device = devices.back; const nav = useNavigation(); const [scanned, setScanned] = useState(false); const codeScanner = useCodeScanner({ codeTypes: ['qr'], onCodeScanned: (codes) => { if (scanned || !codes[0]?.value) return; setScanned(true); const url = codes[0].value; const id_lote = url.split('/').pop(); nav.navigate('QRResult', { codigo_lote: id_lote }); }, }); if (!device) return <Text>Cargando cámara…</Text>; return ( <View style={s.container}> <Camera style={StyleSheet.absoluteFill} device={device} isActive={!scanned} codeScanner={codeScanner} /> <View style={s.overlay}> <View style={s.frame} /> <Text style={s.hint}>Apunta al código QR del lote</Text> {scanned && <TouchableOpacity style={s.btn} onPress={() => setScanned(false)}><Text>Escanear otro</Text></TouchableOpacity>} </View> </View> ); } const s = StyleSheet.create({ container: { flex: 1, backgroundColor: '#000' }, overlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'center', alignItems: 'center' }, frame: { width: 240, height: 240, borderWidth: 2, borderColor: '#3fb950', borderRadius: 12 }, hint: { color: '#fff', marginTop: 20, fontSize: 14 }, btn: { marginTop: 20, padding: 12, backgroundColor: '#3fb950', borderRadius: 8 }, });
14
Complejidad alta. La RA en React Native requiere compilación nativa. Considera dos enfoques: ViroReact (AR 3D completo) o VisionCamera + canvas overlay (más sencillo y confiable para datos textuales).
Enfoque 1: Canvas Overlay (Recomendado para BioSello)
Usa react-native-vision-camera con react-native-skia para dibujar información del lote sobre la cámara en tiempo real, cuando detectas el QR.
bash — Instalación
npm install react-native-vision-camera @shopify/react-native-skia npm install vision-camera-code-scanner
src/screens/ARLoteScreen.jsx
import React, { useState } from 'react'; import { StyleSheet, View } from 'react-native'; import { Camera, useCameraDevices } from 'react-native-vision-camera'; import { Canvas, RoundedRect, Text, Fill, useFont } from '@shopify/react-native-skia'; import { Lote } from '../constants/shapes'; export default function ARLoteScreen({ lote }) { const devices = useCameraDevices(); const device = devices.back; const font = useFont(require('../assets/fonts/Inter-Bold.ttf'), 14); if (!device) return null; return ( <View style={s.container}> <Camera style={StyleSheet.absoluteFill} device={device} isActive={true} /> {lote && ( <Canvas style={StyleSheet.absoluteFill}> <{/* Panel semi-transparente */} <RoundedRect x={20} y={100} width={300} height={160} r={12} color="rgba(13,17,23,0.85)" /> <{/* Datos del lote sobre la cámara */} <Text x={36} y={130} text={`🏷 ${lote.codigo_lote}`} font={font} color="#3fb950" /> <Text x={36} y={155} text={`Corte: ${lote.tipo_corte}`} font={font} color="#e6edf3" /> <Text x={36} y={180} text={`Peso: ${lote.peso_kg} kg`} font={font} color="#e6edf3" /> <Text x={36} y={205} text={`Vence: ${lote.fecha_vencimiento}`} font={font} color={lote.estado === 'caducado' ? '#f78166' : '#3fb950'} /> <Text x={36} y={230} text={`Estado: ${lote.estado.toUpperCase()}`} font={font} color="#8b949e" /> </Canvas> )} </View> ); } const s = StyleSheet.create({ container: { flex: 1 } });
Enfoque 2: ViroReact (AR 3D)
bash
npm install @viro-community/react-viro
Escena AR con objeto 3D sobre lote
import { ViroARScene, ViroText, ViroNode, ViroARImageMarker, ViroARTrackingTargets } from '@viro-community/react-viro'; // Registrar la imagen del QR como marker ViroARTrackingTargets.createTargets({ bioselloQR: { source: require('../assets/qr_target.png'), orientation: 'Up', physicalWidth: 0.1 }, }); export function ARLoteScene({ lote }) { return ( <ViroARScene> <ViroARImageMarker target="bioselloQR"> <ViroNode position={[0, 0.1, 0]}> <ViroText text={`${lote.tipo_corte}\n${lote.peso_kg} kg\n${lote.estado}`} position={[0, 0, 0]} scale={[0.5, 0.5, 0.5]} style={{ fontFamily: 'Arial', fontSize: 20, color: '#3fb950' }} /> </ViroNode> </ViroARImageMarker> </ViroARScene> ); }
15
1. Generar keystore de firma
bash — Solo se hace una vez
keytool -genkeypair -v \ -keystore android/app/biosello-release.keystore \ -alias biosello \ -keyalg RSA -keysize 2048 \ -validity 10000
2. Configurar gradle
android/gradle.properties
BIOSELLO_UPLOAD_STORE_FILE=biosello-release.keystore BIOSELLO_UPLOAD_KEY_ALIAS=biosello BIOSELLO_UPLOAD_STORE_PASSWORD=tu_password BIOSELLO_UPLOAD_KEY_PASSWORD=tu_password
android/app/build.gradle
android {
...
signingConfigs {
release {
storeFile file(BIOSELLO_UPLOAD_STORE_FILE)
storePassword BIOSELLO_UPLOAD_STORE_PASSWORD
keyAlias BIOSELLO_UPLOAD_KEY_ALIAS
keyPassword BIOSELLO_UPLOAD_KEY_PASSWORD
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled enableProguardInReleaseBuilds
shrinkResources enableProguardInReleaseBuilds
}
}
}
3. Generar el APK / AAB
bash
# APK para distribución directa cd android && ./gradlew assembleRelease # AAB para Google Play (recomendado) cd android && ./gradlew bundleRelease # El APK queda en: # android/app/build/outputs/apk/release/app-release.apk
Checklist antes de publicar BioSello
| Ítem | Comando / Acción | Estado |
|---|---|---|
| Sin errores ESLint | npx eslint src/ | ✅ |
| Tests unitarios | npx jest | ✅ |
| Variables de entorno prod | .env.production con URL real | ✅ |
| Permisos Android | CAMERA, INTERNET en AndroidManifest | ✅ |
| Icono y splash screen | npx react-native-asset | ✅ |
| versionCode incrementado | build.gradle | ✅ |
| ProGuard activado | minifyEnabled true | ✅ |
| SSL en el backend | HTTPS con certificado válido | ✅ |
Distribución interna: Para el rastro de Huejutla y los inspectores, puedes compartir el APK directamente por WhatsApp o subirlo a Firebase App Distribution sin necesitar Google Play.