1 Visión general del módulo
expo-cultiva-scanner permite a FincaApp leer códigos QR de etiquetas físicas en parcelas agrícolas
accediendo directamente a las APIs de cámara nativas iOS y Android, sin depender de librerías JS que pierden rendimiento.
getDeviceInfo()
Función síncrona. Devuelve marca y modelo del dispositivo del agricultor.
startScan()
AsyncFunction en hilo de fondo. Inicia el motor de detección QR nativo.
onQRDetected
Evento nativo → JS con code y timestamp en tiempo real.
ScannerView
Vista nativa con prop torchEnabled para linterna del dispositivo.
Config Plugin
Inyecta permisos de cámara en Info.plist y AndroidManifest automáticamente.
Autolinking
Registro en expo-module.config.json para vinculación automática.
FincaApp UI
TypeScript bindings
AVFoundation QR
ML Kit Barcode
2 Scaffolding del módulo
Usamos create-expo-module en modo local (solo para FincaApp) con las features exactas que necesitamos.
--local \
--platform ios,android \
--features AsyncFunction,Event,View,ViewEvent \
cultiva-scanner
Local module — vive en modules/cultiva-scanner/ dentro del repo de FincaApp. Sin package.json propio.
--features genera código de ejemplo para AsyncFunction, Event, View y ViewEvent (que implica View, correcto).
Reemplazamos el código generado con la implementación real de AVFoundation (iOS) y ML Kit (Android).
3 Swift — iOS Module
El módulo Swift usa el DSL de Expo Modules Core. La función startScan() corre en hilo de fondo y dispara eventos al detectar QR.
import ExpoModulesCore
import AVFoundation
public class CultivaScannerModule: Module {
private var captureSession: AVCaptureSession?
public func definition() -> ModuleDefinition {
Name("CultivaScanner")
// Eventos declarados antes de usarlos
Events("onQRDetected", "onScanError")
// Constante: versión del motor de detección
Constant("ENGINE_VERSION") { "avfoundation-1.0" }
// Función síncrona: info del dispositivo
Function("getDeviceInfo") { () -> [String: String] in
return [
"brand": "Apple",
"model": UIDevice.current.model,
"systemVersion": UIDevice.current.systemVersion
]
}
// AsyncFunction: inicia el escaneo QR en hilo de fondo
AsyncFunction("startScan") { () -> Void in
let session = AVCaptureSession()
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device)
else {
throw Exception(name: "CameraUnavailable",
description: "No se pudo acceder a la cámara")
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
self.captureSession = session
session.startRunning()
}
// AsyncFunction: detiene el escaneo
AsyncFunction("stopScan") { () -> Void in
self.captureSession?.stopRunning()
self.captureSession = nil
}
// Ciclo de vida: limpiar al cerrar el módulo
OnDestroy {
self.captureSession?.stopRunning()
}
}
}
// Delegate: recibe resultados QR desde AVFoundation
extension CultivaScannerModule: AVCaptureMetadataOutputObjectsDelegate {
public func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput objects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
guard let qr = objects.first as? AVMetadataMachineReadableCodeObject,
let value = qr.stringValue else { return }
sendEvent("onQRDetected", [
"code": value,
"timestamp": Date().timeIntervalSince1970 * 1000
])
}
}
startScan() es AsyncFunction (no bloquea el hilo JS).
El delegate de AVFoundation llama a sendEvent desde el main thread, que es correcto para UI.
4 Kotlin — Android Module
La versión Android usa ML Kit Barcode Scanning. El AsyncFunction Kotlin soporta coroutines nativas.
package expo.modules.cultivascanner
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import android.os.Build
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScanner
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GcodeBarcodeScanning
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.tasks.await
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
class CultivaScannerModule : Module() {
override fun definition() = ModuleDefinition {
Name("CultivaScanner")
// Eventos declarados
Events("onQRDetected", "onScanError")
Constant("ENGINE_VERSION") { "mlkit-barcode-1.0" }
// Función síncrona: info del dispositivo
Function("getDeviceInfo") {
mapOf(
"brand" to Build.BRAND,
"model" to Build.MODEL,
"sdkVersion" to Build.VERSION.SDK_INT.toString()
)
}
// AsyncFunction con Coroutine: inicia escaneo QR
AsyncFunction("startScan") Coroutine {
withContext(Dispatchers.Main) {
val options = GmsBarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
val scanner = GcodeBarcodeScanning
.getClient(appContext.reactContext!!, options)
val code = suspendCancellableCoroutine { cont ->
scanner.startScan()
.addOnSuccessListener { barcode ->
cont.resume(barcode.rawValue ?: "")
}
.addOnFailureListener { e ->
sendEvent("onScanError", bundleOf("message" to e.message))
cont.resume("")
}
}
if (code.isNotEmpty()) {
sendEvent("onQRDetected",
bundleOf(
"code" to code,
"timestamp" to System.currentTimeMillis()
)
)
}
}
}
AsyncFunction("stopScan") { /* ML Kit gestiona su ciclo de vida internamente */ }
}
}
5 Vista nativa — ScannerView
La vista nativa expone la cámara como componente React Native con soporte para la linterna vía prop torchEnabled.
import ExpoModulesCore
import AVFoundation
class CultivaScannerView: ExpoView {
private var previewLayer: AVCaptureVideoPreviewLayer?
var torchEnabled: Bool = false {
didSet { applyTorch() }
}
private func applyTorch() {
guard let device = AVCaptureDevice.default(for: .video),
device.hasTorch else { return }
try? device.lockForConfiguration()
device.torchMode = torchEnabled ? .on : .off
device.unlockForConfiguration()
}
override func layoutSubviews() {
super.layoutSubviews()
previewLayer?.frame = bounds
}
}
class CultivaScannerViewModule: Module {
public func definition() -> ModuleDefinition {
Name("CultivaScannerView")
View(CultivaScannerView.self) {
Prop("torchEnabled") { (view: CultivaScannerView, value: Bool) in
view.torchEnabled = value
}
Events("onViewReady")
}
}
}
6 TypeScript API
Capa de TypeScript que expone el módulo nativo con tipos correctos. Se importa directamente desde la app.
import { requireNativeModule, NativeModulesProxy } from "expo";
import { useEvent } from "expo";
import { ViewProps } from "react-native";
const CultivaScanner = requireNativeModule("CultivaScanner");
// --- Tipos ---
export interface DeviceInfo {
brand: string;
model: string;
systemVersion?: string;
sdkVersion?: string;
}
export interface QRDetectedEvent {
code: string;
timestamp: number;
}
export interface ScannerViewProps extends ViewProps {
torchEnabled?: boolean;
onViewReady?: () => void;
}
// --- API ---
export function getDeviceInfo(): DeviceInfo {
return CultivaScanner.getDeviceInfo();
}
export async function startScan(): Promise<void> {
return CultivaScanner.startScan();
}
export async function stopScan(): Promise<void> {
return CultivaScanner.stopScan();
}
export const ENGINE_VERSION: string = CultivaScanner.ENGINE_VERSION;
// Hook para suscribirse al evento QR
export function useQRDetected(
callback: (event: QRDetectedEvent) => void
) {
return useEvent(CultivaScanner, "onQRDetected", callback);
}
7 Config Plugin — Permisos de cámara
El plugin modifica automáticamente Info.plist (iOS) y AndroidManifest.xml al hacer expo prebuild.
import {
ConfigPlugin,
createRunOncePlugin,
withInfoPlist,
withAndroidManifest
} from "expo/config-plugins";
const withCultivaScanner: ConfigPlugin<{ cameraUsageDescription?: string }> =
(config, { cameraUsageDescription = "FincaApp necesita la cámara para escanear parcelas" } = {}) => {
// iOS: añade NSCameraUsageDescription en Info.plist
config = withInfoPlist(config, (mod) => {
mod.modResults."NSCameraUsageDescription" = cameraUsageDescription;
return mod;
});
// Android: añade permiso CAMERA en AndroidManifest.xml
config = withAndroidManifest(config, (mod) => {
const manifest = mod.modResults.manifest;
const permissions = manifest["uses-permission"] || [];
const cameraPermission = "android.permission.CAMERA";
const alreadyAdded = permissions.some(
(p: any) => p.$["android:name"] === cameraPermission
);
if (!alreadyAdded) {
permissions.push({ $: { "android:name": cameraPermission } });
manifest["uses-permission"] = permissions;
}
return mod;
});
return config;
};
export default createRunOncePlugin(
withCultivaScanner,
"expo-cultiva-scanner",
"1.0.0"
);
"plugins": [["expo-cultiva-scanner", { "cameraUsageDescription": "Escaneo de parcelas" }]] y ejecutar expo prebuild.
8 expo-module.config.json
Registro del módulo para el sistema de autolinking de Expo. iOS usa el nombre de clase; Android el nombre completo con paquete.
{
"platforms": ["android", "apple"],
"apple": {
"modules": ["CultivaScannerModule", "CultivaScannerViewModule"]
},
"android": {
"modules": [
"expo.modules.cultivascanner.CultivaScannerModule",
"expo.modules.cultivascanner.CultivaScannerViewModule"
]
},
"plugin": "plugin/src/index"
}
| Campo | Descripción | Valor |
|---|---|---|
platforms | Plataformas soportadas | ["android", "apple"] |
apple.modules | Clases Swift registradas (solo nombre) | 2 módulos |
android.modules | Clases Kotlin (fully-qualified) | 2 módulos |
plugin | Ruta al config plugin | plugin/src/index |
9 Uso en FincaApp
Ejemplo de pantalla React Native que usa el hook useQRDetected y la vista nativa ScannerView.
import React, { useState, useEffect } from "react";
import { View, Text, StyleSheet, Pressable } from "react-native";
import {
startScan, stopScan, getDeviceInfo,
useQRDetected, ScannerView, ENGINE_VERSION
} from "../modules/cultiva-scanner/src";
export function ScanParcelaScreen() {
const [scanning, setScanning] = useState(false);
const [torch, setTorch] = useState(false);
const [lastCode, setLastCode] = useState<string | null>(null);
const device = getDeviceInfo();
// Suscripción al evento QR nativo
useQRDetected((event) => {
setLastCode(event.code);
setScanning(false);
// → navegar a detalles de parcela por código QR
});
const handleStart = async () => {
setScanning(true);
await startScan();
};
return (
<View style={styles.container}>
<Text style={styles.deviceInfo}>
{device.brand} {device.model} · Motor: {ENGINE_VERSION}
</Text>
{/* Vista nativa con linterna */}
<ScannerView
style={styles.scanner}
torchEnabled={torch}
onViewReady={() => console.log("Vista lista")}
/>
<Pressable
style={styles.btn}
onPress={scanning ? stopScan : handleStart}
>
<Text>{scanning ? "Detener" : "Escanear QR parcela"}</Text>
</Pressable>
{lastCode && (
<Text style={styles.result}>Parcela: {lastCode}</Text>
)}
</View>
);
}