CultivaField — Arquitectura React Native

App móvil offline-first para gestión de cultivos · Expo SDK 52 + TypeScript

React Native Expo Router Offline-First TypeScript EAS Build
6
Patrones de producción
4
Módulos nativos integrados
60fps
Animaciones (Reanimated)
100%
Offline capability
Estructura del proyecto
📁 cultivafield/
📁 src/
📁 app/ // Expo Router (file-based)
📁 (auth)/
📄 login.tsx
📄 _layout.tsx
📁 (tabs)/
📄 _layout.tsx // Tab nav
📄 parcelas.tsx
📄 catalogo.tsx
📄 alertas.tsx
📄 perfil.tsx
📄 _layout.tsx // Root layout
📁 components/
📁 ui/ // Atoms reutilizables
📄 Button.tsx
📄 Card.tsx
📄 StatusBadge.tsx
📁 features/
📄 ParcelaCard.tsx
📄 AlertaItem.tsx
📄 ProductoCard.tsx
📁 hooks/
📄 useParcelas.ts
📄 useAlertas.ts
📄 useTheme.ts
📁 services/
📄 api.ts
📄 haptics.ts
📄 biometrics.ts
📄 notifications.ts
📁 stores/
📄 userStore.ts // Zustand
📁 providers/
📄 AuthProvider.tsx
📄 QueryProvider.tsx
📁 types/
📄 parcela.ts
📄 producto.ts
📄 alerta.ts
📁 assets/
📄 app.json
📄 eas.json
📄 tsconfig.json
app/_layout.tsx TypeScript
import { Stack } from 'expo-router' import { AuthProvider } from '@/providers/AuthProvider' import { QueryProvider } from '@/providers/QueryProvider' import { ThemeProvider } from '@/providers/ThemeProvider' export default function RootLayout() { return ( <QueryProvider> <AuthProvider> <ThemeProvider> <Stack screenOptions={{ headerShown: false }}> <Stack.Screen name="(tabs)" /> <Stack.Screen name="(auth)" /> <Stack.Screen name="modal" options={{ presentation: 'modal' }} /> </Stack> </ThemeProvider> </AuthProvider> </QueryProvider> ) }
app/(tabs)/_layout.tsx TypeScript
export default function TabLayout() { const { colors } = useTheme() return ( <Tabs screenOptions={{ tabBarActiveTintColor: colors.primary, tabBarStyle: { backgroundColor: colors.background }, }}> <Tabs.Screen name="parcelas" options={{ title: 'Parcelas', tabBarIcon: ({ color, size }) => <MapPin size={size} color={color} /> }} /> <Tabs.Screen name="catalogo" options={{ title: 'Catálogo', tabBarIcon: ({ color, size }) => <Package size={size} color={color} /> }} /> <Tabs.Screen name="alertas" options={{ title: 'Alertas', tabBarIcon: ({ color, size }) => <Bell size={size} color={color} /> }} /> </Tabs> ) }
Patrones de producción aplicados
🔐

Autenticación Biométrica

SecureStore + expo-local-authentication
services/biometrics.ts TS
export async function authenticateWithBiometrics() : Promise<boolean> { const hasHW = await hasHardwareAsync() if (!hasHW) return false const enrolled = await isEnrolledAsync() if (!enrolled) return false const result = await authenticateAsync({ promptMessage: 'Accede a CultivaField', fallbackLabel: 'Usar PIN', }) return result.success }
expo-local-auth expo-secure-store Face ID / Touch ID
📡

Offline-First con React Query

AsyncStorage persister + NetInfo sync
hooks/useParcelas.ts TS
// Sincroniza estado online via NetInfo onlineManager.setEventListener((setOnline) => { return NetInfo.addEventListener((state) => { setOnline(!!state.isConnected) }) }) export function useParcelas() { return useQuery({ queryKey: ['parcelas'], queryFn: api.getParcelas, gcTime: 1000 * 60 * 60 * 24, // 24h cache networkMode: 'offlineFirst', }) }
24h cache NetInfo sync AsyncStorage
📳

Feedback Háptico

expo-haptics con guard de plataforma
services/haptics.ts TS
export const haptics = { light: () => { if (Platform.OS !== 'web') impactAsync(ImpactFeedbackStyle.Light) }, success: () => { if (Platform.OS !== 'web') notificationAsync( NotificationFeedbackType.Success) }, error: () => { if (Platform.OS !== 'web') notificationAsync( NotificationFeedbackType.Error) }, }
expo-haptics Platform guard iOS + Android
🔔

Push Notifications (Alertas)

expo-notifications + EAS Push
services/notifications.ts TS
Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, shouldPlaySound: true, shouldSetBadge: true, }), }) export async function registerForPush() { const { status } = await requestPermissionsAsync() if (status !== 'granted') return null return ( await getExpoPushTokenAsync({ projectId: Constants.expoConfig ?.extra?.eas?.projectId }) ).data }
EAS Push Android channel Badge support
⚡

FlashList — Catálogo de Productos

@shopify/flash-list + memo + useCallback
components/features/CatalogoList.tsx TS
const ProductoItem = memo(function ProductoItem({ item, onPress, }: { item: Producto; onPress: (id: string) => void }) { const handlePress = useCallback( () => onPress(item.id), [item.id, onPress] ) return ( <Pressable onPress={handlePress}> <Text>{item.nombre}</Text> <Text>{item.precio}€</Text> </Pressable> ) }) return <FlashList data={productos} renderItem={({ item }) => <ProductoItem item={item} .../>} estimatedItemSize={100} />
FlashList memo useCallback
🎨

Button — Animación 60fps

react-native-reanimated + spring
components/ui/Button.tsx TS
export function Button({ title, onPress }: ButtonProps) { const scale = useSharedValue(1) const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], })) const handlePressIn = () => { scale.value = withSpring(0.95) haptics.light() } return ( <AnimatedPressable style={[styles.button, animatedStyle]} onPress={onPress} onPressIn={handlePressIn} onPressOut={() => scale.value = withSpring(1)} > <Text>{title}</Text> </AnimatedPressable> ) }
Reanimated withSpring native thread
Decisión técnica: Expo Managed vs Bare React Native
Característica Expo Managed (elegido) Bare React Native Relevancia CultivaField
Complejidad de setup ✓ Baja ✗ Alta MVP rápido con equipo pequeño
Módulos nativos ✓ EAS Build + Config Plugins ~ Manual linking Biometría, haptics, notificaciones
OTA Updates ✓ Built-in (eas update) ✗ Manual (CodePush) Despliegue de alertas fitosanitarias urgentes
Build service ✓ EAS Build ~ CI/CD custom iOS + Android sin Mac requerido
Código nativo custom ~ Config plugins ✓ Acceso directo No necesario en fase MVP
Time to first build ✓ <1h ✗ 2-4h Iteración ágil con cliente
Pipeline EAS Build / CI-CD
💻
Dev
expo start
→
🧪
Preview
eas build --profile preview
→
🔍
QA / TestFlight
internal distribution APK
→
🚀
Production
eas build --profile production
→
🏪
Store Submit
eas submit --platform all
→
📡
OTA Update
eas update --branch production
autoIncrement: true iOS + Android simultáneo Internal APK para beta testers OTA para hotfixes urgentes
Mejores prácticas para CultivaField
✅ Hacer
✅Usar FlashList en lugar de FlatList para el catálogo de 500+ productos
✅Memoizar componentes de parcela con React.memo y callbacks
✅Usar Reanimated para animaciones de estado de cultivo en hilo nativo
✅Probar en dispositivos reales en campo (GPS, red variable)
✅Configurar error boundaries en cada pantalla principal
✅Usar StyleSheet.create para todos los estilos (no inline)
✅Persistir el cache de React Query con AsyncStorage (modo campo sin red)
❌ No hacer
❌No guardar tokens de API en AsyncStorage — usar expo-secure-store
❌No hacer fetch de datos directamente en el render — usar useQuery
❌No ignorar diferencias de plataforma — testear iOS y Android siempre
❌No usar estilos inline — degradan rendimiento del re-render
❌No omitir el guard Platform.OS !== 'web' en haptics/notificaciones
❌No hardcodear el projectId de EAS — leer desde expo-constants
❌No saltar los Android notification channels — requeridos en Android 8+
Stack de dependencias
Navegación
expo-router react-native-safe-area-context react-native-screens
Estado y datos
@tanstack/react-query react-query-persist-client zustand
Nativas / UX
expo-local-authentication expo-secure-store expo-haptics expo-notifications
Performance
@shopify/flash-list react-native-reanimated expo-fast-image
Storage / Network
@react-native-async-storage @react-native-community/netinfo
CI/CD
eas-cli eas-update expo-dev-client