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