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