Visión general
oxitec-mobile-app es la app del transportista: recibe la hoja de ruta, entrega y retira equipos
en el domicilio del paciente, escanea seriales, gestiona el stock del vehículo y hace firmar el
remito. Nombre en las tiendas: BullStock.
Stack
| Runtime | React 19 · React Native 0.81.5 · Expo SDK 54 (Hermes, new architecture) |
| Ruteo | expo-router 6 (file-based) |
| Estado servidor | @tanstack/react-query 5 |
| Base local | expo-sqlite (bullstock.db) |
| Auth | @clerk/clerk-expo + expo-secure-store · Firebase como fallback conmutable |
| Estilos | Uniwind (Tailwind v4) — ver UI y estilos |
| UI | HeroUI Native (nuevo) + Gluestack (legacy) |
| Locale | es-AR |
- iOS bundle:
com.nwyler-4quinas.bullstock - Android package:
com.nwyler_4quinas.bullstock
Estructura
app/ # rutas (expo-router)
├── index.tsx # boot: redirige según auth + mobile-config
├── home.tsx profile.tsx
├── auth/ login, forgot-password
├── (roadmap)/ roadmap, roadmapTask, roadmapVerifyDelivery, scanInventariable,
│ scanVerifyDelivery, scanVerifyRetirement, failedTask, successTask,
│ patientEventComment
├── (inventory)/ inventoryMenu, stockByElement, stockByProduct, stockSerials,
│ serialSearch, addStock, selectProducts, warehouseSelection
├── (vehicle)/ vehicle, vehicleQrScan, vehicleStock, vehicleStockMovement
├── (patient)/ patientInfo
└── (signature)/ digitalSignature, digitalSignatureForm
components/ ui-native/ (HeroUI) · ui/ (Gluestack) · shared/<categoría>/ · <feature>/
services/ api, auth, database/, offlineQueue, syncService, networkMonitor, mobileConfig
hooks/ shared/ · <dominio>/<pantalla>/
lib/ repositorios de dominio y utilidades con estado
helpers/ funciones puras
contexts/ Theme, MobileConfig, AppVersion, Drawer, NavigationLoading, ProductStatusOptions
Árbol de providers
app/_layout.tsx, de afuera hacia adentro. El orden importa — cada uno depende del contexto del
anterior:
SafeAreaProvider → ThemeProvider → ClerkProvider → AuthBridgeProvider
→ MobileConfigProvider → AppVersionProvider → ProductStatusOptionsProvider
→ QueryClientProvider → ToastProvider → HeroUINativeProvider
→ GluestackUIProvider → NavigationLoadingProvider → DrawerProvider
Al montar corre initDatabase(), arranca startSyncService(), registra push de Expo y aplica un
Text.defaultProps = { textBreakStrategy: 'simple' } para Android.
textBreakStrategyArregla el clipping de texto en varios modelos de Samsung, Pixel y OnePlus. Parece un workaround raro y lo es, pero sin él hay usuarios que no leen la mitad de las pantallas.
QueryClient: staleTime 5 min, retry: 2, refetchOnWindowFocus: false.
Capa HTTP
services/api.ts es un wrapper propio sobre fetch, no Axios. Todo sale por apiRequest():
- Timeout 60 s por default.
- Reintentos con backoff exponencial; no reintenta 4xx salvo 408.
- Inyecta el token de Clerk cuando
includeAuth, y reintenta un 401 con token fresco siretryOnAuthError. - Chequea conectividad antes de disparar.
- Lee
EXPO_PUBLIC_API_URLdeprocess.env, con fallback aexpoConfig.extra.
Encima está hooks/shared/useApi.ts (authenticatedRequest / queryFn, estabilizados con
useCallback) y los hooks de React Query.
retries es 0, no 3El archivo calcula un defaultRetries (3, o 0 si EXPO_PUBLIC_DISABLE_API_RETRIES=true) pero
nunca lo usa: la desestructuración es retries = 0. Si querés reintentos, pasalos explícitamente
en el config.
Auth
Clerk es el proveedor primario; services/authProviderConfig.ts + contexts/AuthBridgeContext.tsx
permiten que el backend conmute el cliente a Firebase en runtime.
Siempre pasá por services/auth.ts (getValidToken() / handleAuthError()); no llames hooks de
Clerk desde código de API. Ver Autenticación.
Mobile config y versión
services/mobileConfig.ts trae la config del dispositivo desde el módulo mobileConfig del backend
y decide qué features están habilitadas. Gateá features contra esa config, no con flags
hardcodeados.
En dev lee de .env (EXPO_PUBLIC_MOBILE_CONFIG_*), así que cambiar el comportamiento dev↔prod
es cambiar de archivo .env, no de código.
AppVersionContext + helpers/compareSemver.ts deciden si mostrar el modal de actualización
obligatoria.
Reglas de React Native que evitan crashes
No son estilo: son crashes reales en producción.
- Nunca
{value && <Component />}cuandovaluepueda ser0o"". Usá ternario o!!value. Un0suelto se renderiza como texto fuera de<Text>y crashea. - Todo string va dentro de
<Text>. Nunca suelto bajo un<View>. Pressableen vez deTouchableOpacity.- Memoizá los ítems de lista con
React.memo; nada de objetos o funciones inline enrenderItem. - Animá solo
transformyopacity. removeClippedSubviews={false}en lasFlatList— estaba causando un NPE nativo (dispatchGetDisplayListcon hijo null). Está enfalsea propósito: no lo "optimices".- Navegación desde handlers
async→useSafeNavigation. Y evitárouter.replacehacia la ruta en la que ya estás.
Hooks: modulares, no monolito
Nada nuevo va a hooks/useApiQueries.ts. Ese archivo ya es enorme y solo se toca para lo que
depende de su infraestructura (la mutation offline y el dedup de taps).
Lo nuevo va a hooks/<dominio>/<pantalla>/useX.ts, con las constantes en constants/<dominio>/ y
las query keys centralizadas por dominio (hooks/inventory/queryKeys.ts).
Sin tests, sin lint
package.json no tiene test runner, linter ni script de formato. La validación es npx tsc --noEmit
- build + prueba en dispositivo.
Helpers que ya existen
Varios nacieron de un bug real. Antes de escribir uno nuevo, mirá helpers/:
| Helper | Qué resuelve |
|---|---|
productLabel.ts | Etiqueta legible de un producto (descripción → nombre → SKU → Producto <id>), null-safe. Existe porque name + (attr ? ' - ' + attr : '') || fallback con description NULL daba el string "null", que es truthy y se comía el fallback: el operario veía filas que decían null |
sessionError.ts | Error tipado de sesión expirada. Antes ese caso devolvía null y las mutations crasheaban leyendo .savedOffline sobre null |
errorMessages.ts | Elige el título del alert. El cuerpo siempre viene del backend; este helper nunca reescribe ni tapa un error |
safeJsonParse.ts | JSON.parse defensivo (descarta "", "undefined", "null") |
compareSemver.ts | Compara N.N.N; devuelve 0 si no parsea, para no bloquear al usuario por un valor inválido del backend |
recalcVehicleStockAlert.ts | El texto de "cantidad insuficiente" que pidió el cliente + el alert con opción de recalcular |
lib/utils.ts | cn(...) — twMerge(clsx(...)) |
lib/states.ts | DispatchStates: los ids numéricos de estado de la parada. Espejan EntityStatus del backend: si alguien reparametriza estados, hay que tocar acá |