Saltar a contenido

App móvil Android

La app móvil WorkDone es la herramienta de campo del personal de limpieza. Corre en celulares Android, lee tags NFC pegados en cada sanitario y registra los trabajos que se hacen. Está diseñada offline-first: opera al 100% sin conexión y sincroniza con el backend cuando hay red. Es parte del producto WorkDone de LubecaTech (cliente piloto: Corpal, Córdoba).

Esta página describe para qué sirve, su arquitectura, los flujos de UI principales y cómo registra un trabajo de punta a punta. Para entender cómo viajan los datos al backend ver Sincronización; para las entidades, Modelo de datos.

Para qué sirve y quién la usa

Desde la v2, la app maneja 3 roles y cada uno ve una variante distinta de la misma app:

Rol Qué hace Pantalla principal Historial
OPERADOR_LIMPIEZA Limpia sanitarios Main de limpieza (verde/gris) "Mis limpiezas"
SUPERVISOR Controla (trabajo tipo SUPERVISION) Main de control (textos "Controlando:") "Mis controles"
ADMIN Gestiona tags NFC (alta/asignación) Gestión NFC

El rol llega en la respuesta de login y en GET /api/v1/app/me. Si viene null, el default seguro es OPERADOR_LIMPIEZA.

La operación cotidiana es un tap

El gesto central de toda la app es apoyar el celular sobre la placa NFC del sanitario. Un tap abre el trabajo (INGRESO), otro tap lo cierra (EGRESO). Todo lo demás es soporte de ese gesto.

Stack y arquitectura

La app es 100% nativa Android, Kotlin + Jetpack Compose.

Capa Tecnología
Lenguaje Kotlin 2.2.10+
UI Jetpack Compose (Material3)
Persistencia local Room (SQLite, schema 5, migraciones SQL explícitas)
HTTP/JSON Retrofit + OkHttp + Moshi
Sync en background WorkManager (periódico)
Sesión/config DataStore (Preferences)
Inyección de dependencias Hilt
Asíncrono Coroutines + Flow/StateFlow
Min SDK 26 (Android 8.0, por estabilidad NFC)

Arquitectura (Clean lite + MVVM)

El flujo de dependencias es uidomain (use cases) → data (repositorios).

flowchart LR
    UI["ui (Compose)<br/>observa StateFlow"] --> DOMAIN["domain<br/>use cases"]
    DOMAIN --> DATA["data<br/>repositorios"]
    DATA --> ROOM[("Room<br/>(single source of truth)")]
    DATA --> API["WorkDoneApi<br/>(Retrofit)"]
    ROOM -.->|Flow| UI
    API -.->|sync| ROOM

Reglas de arquitectura que importan para entender los flujos:

  • Single source of truth: Room. Los Composables observan Flow del DAO, nunca del API directamente.
  • MVVM con StateFlow desde el ViewModel hacia la UI. No se usa LiveData.
  • La UI es optimista: al tapear, la app asume éxito y pinta verde/gris al instante. Si el server después rechaza, se reconcilia (ver Flujo B).
  • El timestamp del teléfono al momento del tap es la verdad. Se captura Instant.now() antes de cualquier procesamiento.

Estados de UI principales

La pantalla principal (Main) es una máquina de estados visual. El componente central es un círculo de color que comunica el estado de un vistazo.

stateDiagram-v2
    [*] --> GRIS
    GRIS --> VERDE: tap INGRESO
    VERDE --> RESUMEN: tap EGRESO (confirmado)
    RESUMEN --> GRIS: 4 seg / "Continuar"
    note right of GRIS
        "Acercá el celular a una placa NFC"
        NFC en foreground escuchando
    end note
    note right of VERDE
        "Limpiando: AEP-T-B12..."
        timer hh:mm:ss corriendo
    end note
    note right of RESUMEN
        "Trabajo completado"
        sanitario + duración
    end note
Estado Color Significado Texto
GRIS #9C9C99 (⊙) No estoy limpiando "Acercá el celular a una placa NFC"
VERDE #1B9464 (✓, con pulso) Limpiando "Limpiando: AEP-T-B12 - Baño Hombres Terminal A" + timer
AZUL/RESUMEN #1168A8 (✓✓) Post-egreso transitorio (4 seg) "Trabajo completado" + duración

Los tres estados, tal como los ve el operario en el celular:

Main en estado GRIS, círculo gris con texto Acercá el celular al sanitario Main en estado VERDE limpiando AEP-T-B12 con timer corriendo Main en estado RESUMEN azul con Trabajo registrado y duración

Izquierda a derecha: GRIS ("Acercá el celular al sanitario", esperando un tap) · VERDE ("Limpiando: AEP-T-B12 - Baño Hombres - Terminal A" con timer corriendo) · RESUMEN/AZUL ("Trabajo registrado", AEP-T-B12, "Duración: 14 min 32 s", transitorio 4 seg antes de volver a GRIS). Arriba a la izquierda el menú (drawer); arriba a la derecha el operario logueado.

Variante SUPERVISOR

Para el rol SUPERVISOR los estados son idénticos pero los textos dicen "Controlando:" en vez de "Limpiando:", el trabajo es tipo SUPERVISION y el historial es "Mis controles".

Indicador de red

Un chip discreto abajo de la pantalla muestra el estado de conexión sin bloquear nunca la operación:

  • 🟢 Conectado + hora del último sync (ej. "sync: 14:32")
  • 🟡 Sincronizando... + spinner
  • 🔴 Sin conexión + cantidad de pendientes (ej. "🔴 Sin conexión · 3 pendientes")

Flujos de UI

Login

La app abre con un Splash (1 seg) que valida el refresh_token y decide si va a Login o directo a Main.

Pantalla de login de WorkDone Sanitarios con usuario jperez y campo PIN

Pantalla de login. Por defecto pide usuario + PIN (4 dígitos); el link "¿Primera vez en este dispositivo? Usar contraseña" cambia a password completa.

flowchart TD
    SPLASH[Splash 1s<br/>valida refresh_token] --> HAS{¿Hay refresh<br/>token en device?}
    HAS -->|Sí| PIN[Login pide PIN]
    HAS -->|No| PASS[Login pide contraseña completa]
    PASS --> OK[Login OK<br/>guarda refresh_token + rol]
    PIN --> OK
    OK --> MAIN[Main GRIS]
    PIN -.->|5 fallos| BLOQ[Bloqueo 10 min<br/>con countdown]
  • Por defecto pide PIN (más rápido). Si el operario nunca se logueó en ese device, pide password completa; después, el siguiente login alcanza con PIN.
  • 5 intentos fallidos → bloqueo de 10 min con mensaje claro y countdown (el backend throttlea 5 fallos por usuario+device → 429 por 10 min).

Flujo A: Tap exitoso INGRESO → EGRESO

Este es el camino feliz: el operario llega al sanitario, tapea para entrar, limpia, y tapea de nuevo para salir.

flowchart TD
    G1[GRIS] -->|tap tag| C1[Generar client_uuid]
    C1 --> R1[Insertar en trabajo_pendiente Room]
    R1 --> V1[UI optimista → VERDE<br/>inicia timer]
    V1 --> S1[Dispatch sync inmediato]
    S1 --> V2[VERDE con timer]
    V2 -->|tap de nuevo<br/>mismo sanitario| EV["Pantalla '¿Cómo quedó<br/>el sanitario?' (v2)"]
    EV -->|Confirmar| C2[Persiste EGRESO + eventos]
    C2 --> RES[RESUMEN 4 seg]
    RES --> G2[GRIS]

En v2, el EGRESO no se persiste directo: abre la pantalla de selección de eventos (ver más abajo).

Flujo de cierre con eventos (v2)

Al tapear el EGRESO, EvaluarTapUseCase detecta que es una salida pero no persiste todavía. Aparece la pantalla "¿Cómo quedó el sanitario?" (SeleccionEventosScreen):

Pantalla ¿Cómo quedó el sanitario? cerrando AEP-T-B12 con eventos Sin novedad, Papel agotado, Dispenser roto

Pantalla de cierre con eventos. "Cerrando: AEP-T-B12" arriba; multiselect de eventos ("Sin novedad", "Papel agotado", "Dispenser roto"). "Confirmar y finalizar" queda deshabilitado hasta elegir al menos un evento.

  • Multiselect de eventos de limpieza filtrados por rol (catálogo evento_limpieza_local).
  • Exclusividad de SIN_NOVEDAD: seleccionarlo deselecciona y bloquea al resto.
  • Observación opcional por cada evento no-exclusivo.
  • Back = cancelar → el trabajo sigue ABIERTO, no se persiste el EGRESO.
  • ConfirmarConfirmarCierreUseCase persiste el EGRESO + el snapshot de eventos (trabajo_pendiente + trabajo_pendiente_evento) en una sola transacción.
stateDiagram-v2
    [*] --> Eventos: tap EGRESO detectado
    Eventos --> Verde: Back (cancela, trabajo sigue abierto)
    Eventos --> Confirmado: Confirmar (transacción)
    Confirmado --> Resumen: EGRESO + eventos persistidos
    Resumen --> [*]
    note right of Eventos
        Multiselect filtrado por rol
        SIN_NOVEDAD es exclusivo
        Observación opcional por evento
    end note

Red de seguridad

Si el operario abandona la pantalla de eventos y tapea otro sanitario, el anterior se cierra con EGRESO_CIERRE_AUTOMATICO (sin eventos).

Flujo B: Reinterpretación del server

La UI es optimista, así que a veces el server corrige lo que la app asumió. Caso típico: el operario olvidó marcar la salida en un baño hace una hora.

flowchart TD
    V[VERDE en baño viejo<br/>sin egreso hace 1h] -->|tapea NUEVO sanitario| OPT[UI optimista:<br/>asume INGRESO, verde nuevo]
    OPT --> SYNC[Sync]
    SYNC --> SRV[Server responde:<br/>cerró el anterior con EGRESO_CIERRE_AUTOMATICO<br/>+ creó el nuevo INGRESO]
    SRV --> REC[App reconcilia:<br/>snackbar 'Se cerró automáticamente AEP-T-B11'<br/>historial refleja ambos eventos<br/>UI queda VERDE con el nuevo sanitario]

Flujo C: Tap offline

Sin red, el flujo es idéntico de cara al operario; la diferencia es la resolución del tag y la cola.

flowchart TD
    G[GRIS · 🔴 Sin conexión] -->|tap| UUID[Generar client_uuid]
    UUID --> RES{Resolver tag<br/>contra cache local}
    RES -->|tag conocido| OK[Insertar trabajo_pendiente<br/>con sanitario_id_resuelto]
    RES -->|tag desconocido| NULL[Insertar con<br/>sanitario_id_resuelto = NULL]
    OK --> VERDE[UI optimista → VERDE<br/>📤 Pendiente de sincronizar]
    NULL --> VERDE
    VERDE --> WIFI[Vuelve el wifi:<br/>NetworkCallback.onAvailable dispara sync]
    WIFI --> DRENA[Cola se procesa →<br/>trabajo_sincronizado · 🟢 Conectado]

Flujo D: Cambio de operario en el device

Al cerrar sesión, la app invalida el refresh_token y limpia el SessionStore, pero NO limpia trabajo_pendiente: la cola del operario anterior se preserva y se sigue enviando con su operario_id. El nuevo operario no ve esos trabajos en SU historial (filtro local por operario_id actual).

Flujo F: Gestión NFC del ADMIN

El rol ADMIN no limpia: su pantalla principal es Gestión NFC. Apoya el celular sobre un tag, la app resuelve su estado y ofrece acciones.

stateDiagram-v2
    [*] --> Lee: apoya celular sobre tag
    Lee --> EN_STOCK
    Lee --> ASIGNADO
    Lee --> BAJA
    Lee --> NO_REGISTRADO
    EN_STOCK --> [*]: Alta en stock / Asignar a sanitario
    ASIGNADO --> [*]: Desasignar / Reasignar
    BAJA --> [*]: solo informativo (re-alta en BackOffice web)
    NO_REGISTRADO --> [*]: Alta en stock

Las acciones son optimistas y encolan en nfc_evento_pendiente. Al sincronizar: OK → purga la cola; CONFLICTO_ASIGNACION → revierte el optimismo y avisa (si dos devices asignaron al mismo sanitario offline, gana el primero en sincronizar). Si el chip es ilegible, un snackbar avisa "tag sin URL WorkDone".

Otras pantallas

  • Historial: top 50 del operario actual, agrupado por día, con pull-to-refresh que dispara sync. Badges según estado: ✓ "Egreso normal", ⚠ "Cierre automático", 🔄 "Sincronizando...", ❓ "Tag no resuelto".

    Card de historial: 08:15-08:42, AEP-T-B12, 27m 10s, Egreso normal, PAPEL_AGOTADO DISPENSER_ROTO

    Card del historial: rango horario (08:15 – 08:42), sanitario (AEP-T-B12) y duración (27m 10s), badge "✓ Egreso normal" y los códigos de eventos registrados (PAPEL_AGOTADO · DISPENSER_ROTO). - Drawer (menú): Mi historial, Sincronizar ahora, Configuración, Cerrar sesión. - Configuración: versión de la app, servidor (read-only), rol actual, cantidad de eventos en cola (trabajos + gestión NFC), "Forzar sincronización".

    Pantalla Configuración: versión, servidor, rol OPERADOR_LIMPIEZA, eventos en cola, sistema, batería, red, dispositivo, botón Forzar sincronización

    Pantalla de Configuración: versión (0.1.0), servidor read-only, rol (OPERADOR_LIMPIEZA), eventos en cola (3), datos de sistema/batería/red/dispositivo y el botón "Forzar sincronización". - Mi ruta de hoy (v2.1): capa de planificación informativa con paradas ordenadas. Nunca bloquea el tap. - Registrar sin placa (v2.1): opción no prominente en el drawer para cuando la placa no lee. Elegir sanitario del catálogo → motivo obligatorio → flujo idéntico al tap (modo_registro=MANUAL).

El flujo de tap NFC en detalle

Qué pasa exactamente cuando el operario apoya el teléfono sobre el tag:

flowchart TD
    TAP[Operario apoya celular sobre el tag] --> READ["NfcReaderManager<br/>(enableReaderMode, foreground)"]
    READ --> NDEF["Leer URL del NDEF<br/>Ndef.get(tag)"]
    NDEF --> REGEX["Extraer UUID<br/>regex /t/([a-f0-9-]+)$"]
    REGEX --> UUID["⚡ Generar client_uuid<br/>+ capturar Instant.now()"]
    UUID --> ROOM["Persistir en Room<br/>ANTES de cualquier HTTP"]
    ROOM --> EVAL["EvaluarTapUseCase<br/>¿INGRESO o EGRESO?"]
    EVAL --> OPT["UI optimista<br/>(VERDE / pantalla eventos)"]
    OPT --> SYNC["Dispatch sync"]

Detalles técnicos clave:

  • Se usa NfcAdapter.enableReaderMode() en foreground (más estable que intent filters durante uso continuo). Tech filter: solo NDEF / NDEF Formatable. Modo FLAG_READER_NFC_A (NTAG21x = Type 2). La URL del tag tiene la forma https://workdone.lubeca.tech/t/{uuid}.
  • Idempotencia (CRÍTICO): al detectar el tap se genera client_uuid con UUID.randomUUID() inmediatamente, y se persiste en Room antes de cualquier I/O HTTP. El UUID sobrevive aunque la app crashee, el device se reinicie o el sync falle 100 veces. Es lo único que garantiza que nada se pierda ni se duplique.
  • El timestamp del teléfono al momento del tap es la verdad (ts_local_device). Se captura antes de procesar.
  • Nunca se bloquea el hilo principal: el I/O del NFC corre en viewModelScope con Dispatchers.IO.

Una tarjeta de pago/acceso NO sirve como tag

El reader solo entiende tags NDEF con la URL WorkDone. Una contactless de pago hace ruido de lectura pero no pasa nada: no tiene la URL.

Comportamientos transversales del tap

  • Ventana de silencio anti-rebote: tras procesar un tap del tag X, la app ignora lecturas del mismo tag unos segundos (config remota silencio_cliente_seg, default 8) para evitar el doble-tap accidental. La lectura ignorada muestra un snackbar "Ya registrado hace un momento". Un tag distinto durante la ventana se procesa normal (baños contiguos es un caso real).
  • Confirmación de egreso corto: si un EGRESO resuelve con duración menor a un umbral (config remota confirmar_egreso_min, default 2 min), aparece un diálogo "Llevás MM:SS — ¿terminás la limpieza?" con Terminar o Seguir limpiando. Atrapa el doble-tap al entrar. En ese punto nada se escribió aún.

Estados de un trabajo (ingreso → egreso → cierre automático)

Un trabajo nace con un INGRESO y se cierra de dos maneras: por un EGRESO explícito del operario, o por cierre automático del server cuando el operario tapeó otro sanitario sin haber cerrado el anterior.

stateDiagram-v2
    [*] --> INGRESO: tap 1 (abre trabajo)
    INGRESO --> EGRESO: tap 2 mismo sanitario + confirma eventos
    INGRESO --> EGRESO_CIERRE_AUTOMATICO: tapea OTRO sanitario sin cerrar
    EGRESO --> [*]
    EGRESO_CIERRE_AUTOMATICO --> [*]
    note right of EGRESO
        Egreso normal (con eventos)
        Badge ✓ en historial
    end note
    note right of EGRESO_CIERRE_AUTOMATICO
        Cierre forzado por el server
        Badge ⚠ en historial
    end note

En el historial, los estados se distinguen por badge:

Estado / modo Badge Significado
EGRESO + ONLINE/OFFLINE_SYNC ✓ Egreso normal Cierre completo con eventos
EGRESO_CIERRE_AUTOMATICO ⚠ Cierre automático El server lo cerró solo
en trabajo_pendiente 🔄 Sincronizando... Todavía en cola
OFFLINE_PENDIENTE_RESOLUCION ❓ Tag no resuelto Tag desconocido offline

Cómo sincroniza (resumen)

La sincronización es el corazón del sistema y está manejada por SyncRepository + SyncWorker (WorkManager). Resumen de cara a la app:

  • La app encola localmente cada trabajo y cada gestión NFC en Room (trabajo_pendiente, nfc_evento_pendiente) y dispara un push por lotes a POST /api/v1/app/sync.
  • El sync se dispara: inmediatamente tras cada tap, periódicamente vía WorkManager, y al recuperar red (NetworkCallback.onAvailable).
  • El backend responde deltas (catálogos), trabajos_procesados y nfc_eventos_procesados, y la app reconcilia el estado local (purga la cola, mueve a trabajo_sincronizado, aplica cierres automáticos).
  • La cola es persistente: los trabajos sobreviven a reinicios de la app, del device y a actualizaciones.

Detalle completo del protocolo

El contrato exacto del sync (request/response, estados, idempotencia, reconciliación) está en Sincronización. Para las entidades locales y del server, ver Modelo de datos.

Comportamientos no obvios

Esto es lo que el operario o soporte ve en la cancha y que parece magia si no conocés el porqué. Toques que se ignoran solos, diálogos que aparecen de la nada, círculos que cambian de color sin que nadie haga nada, logins que se traban. Nada de eso es un bug: cada uno tiene una razón. Acá están, una por una, con el archivo donde vive cada regla.

Ventana de silencio anti-rebote (lado cliente)

El MainViewModel guarda en memoria un mapa tag → timestamp del último tap (ultimoTapPorTag). Si el operario apoya el celular sobre el mismo tag de nuevo dentro de silencioClienteSeg (config remota silencio_cliente_seg, default 8s), la segunda lectura se descarta: emite un toast "Ya registrado hace un momento" y no crea nada (no hay INGRESO, no hay EGRESO, no toca disco).

val ultimo = ultimoTapPorTag[uuidTagLeido]
if (ultimo != null && ahora - ultimo < cfg.silencioClienteSeg * 1000L) {
    _tapIgnorado.tryEmit(Unit)   // toast "Ya registrado hace un momento"
    return@launch
}

Clave: el silencio es por tag. Apoyar el celular sobre un tag distinto (baños contiguos, un caso real) se procesa normal — no hay silencio global.

Es una segunda red de seguridad, no la única

Esta ventana es el filtro del cliente. El server tiene su propio anti-rebote y puede devolver RECHAZADO_REBOTE (ver más abajo). Las dos capas trabajan juntas. El detalle de la idempotencia completa está en Sincronización.

Fuente: MainViewModel.kt:136-243.

Confirmación de egreso corto

Si entre el INGRESO y el EGRESO pasaron menos de confirmarEgresoMin (config remota confirmar_egreso_min, default 2 min), antes de cerrar aparece un diálogo "Llevás MM:SS — ¿terminás la limpieza?". Esto atrapa el doble-tap al entrar (el operario tapeó dos veces seguidas pensando que la primera no leyó).

Botón Qué hace
Terminar Sigue al flujo normal de eventos (abrirSeleccionEventos)
Seguir limpiando Cancela la evaluación, el trabajo sigue ABIERTO y no persiste nada

En este punto nada se escribió todavía a la cola: el client_uuid del EGRESO sigue solo en memoria (cierrePendiente). "Seguir limpiando" simplemente lo descarta.

Fuente: MainViewModel.kt:315-342.

Reconciliación silenciosa del server

Un observer combina dos flows: el trabajo abierto del operario (espejo local del server, observeAbiertoDeOperario) + la cantidad de pendientes en cola (observeCount). Cada vez que cambian, llama a reconcileFromServer.

flowchart TD
    OBS["Observer combine<br/>trabajo abierto local + count pendientes"] --> GUARD{¿cola vacía?<br/>pendienteCount == 0}
    GUARD -->|No, hay pendientes| KEEP["No toca nada<br/>la UI optimista manda"]
    GUARD -->|Sí, sync limpio| REC["reconcileFromServer ajusta UI"]
    REC --> A["abierto=null + UI VERDE → GRIS<br/>(el server cerró el trabajo)"]
    REC --> B["abierto!=null + UI GRIS → VERDE"]
    REC --> C["server reinterpretó el sanitario<br/>→ repinta VERDE con el nuevo"]

Lo no obvio: solo reconcilia si la cola está vacía (if (pendienteCount > 0) return). Mientras haya taps sin sincronizar, la UI optimista manda y el server no la pisa. Y cuando sí reconcilia (ej. el server cerró el trabajo con EGRESO_CIERRE_AUTOMATICO), el cambio GRIS↔VERDE pasa sin avisarle al usuario — solo queda registrado en logs (Log.i(TAG, "Reconcile: ...")). El operario ve el círculo cambiar de color "solo".

Fuente: MainViewModel.kt:207-220,485-521. Para entender qué reinterpreta el server (cierres automáticos, etc.), ver Sincronización.

Bloqueo de login por PIN

Tras 5 intentos fallidos, el backend throttlea por usuario + device y devuelve HTTP 429 con header Retry-After (en segundos; default 600s = 10 min si el header no viene). La app mapea eso a AuthResult.Bloqueado(retry) y arranca un countdown en memoria (un Job en el LoginViewModel).

// AuthRepository.kt
429 -> {
    val retry = response.headers()["Retry-After"]?.toIntOrNull() ?: DEFAULT_BLOQUEO_SEG  // 600
    AuthResult.Bloqueado(retry)
}

El bloqueo lo impone el server, no la app

El countdown del LoginViewModel (iniciarCountdownBloqueo) es solo visual y vive en memoria: si el operario mata la app y la reabre, el countdown se reinicia visualmente, pero el server sigue bloqueando hasta que pase su ventana real. No es un candado que se saltea cerrando la app.

Fuente: AuthRepository.kt:91-99, LoginViewModel.kt:106-119.

PIN vs password: lo decide el refresh_token

La app prefiere PIN (más rápido, 4 dígitos). La decisión de qué pedir depende del refresh_token guardado en el device:

flowchart TD
    START["Login"] --> HAS{¿hay refresh_token<br/>en el device?}
    HAS -->|Sí| PIN["Pide PIN (4 dígitos)<br/>modoPassword = false"]
    HAS -->|No| PASS["Pide password (≥6)<br/>modoPassword = true"]
    PIN -->|server responde 409<br/>PIN_NO_CONFIGURADO| AUTO["La pantalla cambia sola<br/>a modo password"]
  • PIN: validación local ^\d{4}$ (exactamente 4 dígitos).
  • Password: mínimo 6 caracteres (MIN_PASSWORD = 6).
  • Si el operario intenta por PIN pero el server responde 409 PIN_NO_CONFIGURADO, la app cambia sola la pantalla a modo password (modoPassword = true, limpia el PIN tipeado) — sin que el operario tenga que tocar nada.

Fuente: LoginViewModel.kt:122-135 (auto-switch en aplicarResultado), flujo de Splash documentado arriba en Login.

Registro sin placa (contingencia manual)

Cuando la placa no lee (rota, ausente, ilegible), el operario tiene una salida desde el drawer: elige el sanitario del catálogo + un motivo obligatorio. El motivo es un catálogo fijo que no viaja por sync (MOTIVOS_CONTINGENCIA):

Pantalla Registrar sin placa con buscador y lista de sanitarios AEP-T-B12 y AEP-T-B13

Pantalla "Registrar sin placa": buscador de sanitario y la lista del catálogo (AEP-T-B12 — Baño Hombres, AEP-T-B13 — Baño Mujeres). Tras elegir uno se pide el motivo obligatorio.

Código Cuándo
PLACA_ROTA La placa está físicamente dañada
PLACA_AUSENTE No hay placa pegada
NO_LEE La placa está pero el celular no la lee
OTRO Cualquier otro caso

Lo no obvio: sigue exactamente el mismo flujo que un tap (INGRESO→VERDE / EGRESO→eventos), incluida la ventana de silencio anti-rebote, pero indexada por sanitario con la key manual:$sanitarioId en vez del UUID del tag. El trabajo viaja al server con manual=true y sin uuidTagLeido (cadena vacía, sin UUID de tag).

Si el motivo es inválido, el server devuelve MANUAL_INVALIDO — un rechazo terminal: se purga de la cola sin reintentar.

Fuente: RegistrarManualViewModel.kt, MainViewModel.kt:268-290, EvaluarTapUseCase.kt:74-76.

Device no registrado vs device revocado

Los dos llegan como HTTP 401 durante el sync, pero la app los trata de forma opuesta — y la diferencia importa muchísimo:

Caso Cómo lo detecta Qué hace la app ¿Reintenta?
No registrado errorBody contiene "no registrado" Snackbar "contactá al admin"; marca retry y espera que el admin dé de alta el device (transitorio)
Revocado (robo) errorBody contiene "DEVICE_REVOCADO" Señaliza el gate, bloquea la app y cancela todo el sync (periódico + oneshot) No (terminal)
// SyncRepository.kt
if (httpResponse.code() == 401 && errorBody?.contains("DEVICE_REVOCADO") == true) {
    _deviceRevocado.tryEmit(Unit)          // → bloquea la app, sin reintentos
    return SyncOutcome.DeviceRevocado
}
if (errorBody?.contains("no registrado") == true) {
    _deviceNoRegistrado.tryEmit(Unit)      // → snackbar, reintenta
    markRetry(pendientes, nfcPendientes, "device no registrado")
    return SyncOutcome.DeviceNoRegistrado
}

DeviceRevocado es el escenario de robo: el admin revoca el device desde el BackOffice y la app se ladrillea sola en el próximo sync. DeviceNoRegistrado es benigno: en cuanto el admin lo da de alta, el siguiente sync arranca.

Fuente: SyncRepository.kt:134-146, SyncWorker.kt:79-80.

Reintentos del sync y rechazos terminales

El sync corre por WorkManager periódico cada 15 min (mínimo permitido), con backoff exponencial al fallar y constraint de conectividad. El SyncWorker mapea el resultado a un Result de WorkManager:

Resultado del server WorkManager Result
Ok / NoSession success()
NetworkError, HttpError 5xx, DeviceNoRegistrado retry() (backoff)
HttpError 4xx, DeviceRevocado failure()

Pero hay un nivel más fino: ciertos estados que el server devuelve por trabajo son rechazos terminales y la app los purga de la cola sin reintentar (reintentar daría siempre lo mismo):

  • SANITARIO_INACTIVO — el sanitario fue dado de baja
  • ROL_NO_PERMITIDO — ej. un ADMIN intentando limpiar
  • MANUAL_INVALIDO — registro sin placa con datos inválidos
  • RECHAZADO_REBOTE — el server descartó un INGRESO rebote (anti doble-tap del lado server)

Fuente: SyncWorker.kt:76-84, SyncRepository.kt:309-333.

Migraciones Room (schema 1→5)

Las migraciones de Room son SQL explícito y nunca usan fallbackToDestructiveMigration — porque eso borraría la base, y con ella las colas locales sin sincronizar. La regla de oro que sigue cada migración:

Tipo de tabla Estrategia Por qué
Espejo (catálogos, trabajo_sincronizado) puede recrearse (DROP+CREATE) el server la repuebla por sync
Cola local (trabajo_pendiente, nfc_evento_pendiente) debe sobrevivir (ALTER ADD) un operario puede actualizar la app con taps encolados sin sincronizar

Ejemplo concreto: en MIGRATION_4_5 (registro sin placa) las dos columnas nuevas se suman con ALTER TABLE trabajo_pendiente ADD COLUMN ... justamente para no perder los [ts_local_device] y client_uuid de trabajos que el operario tapeó offline antes de actualizar.

Fuente: Migrations.kt (MIGRATION_1_2 a MIGRATION_4_5).

Riesgo conocido: la cola offline no tiene techo

LIMITACIÓN a evaluar: la cola trabajo_pendiente crece sin límite

La cola de trabajos pendientes (trabajo_pendiente) no tiene ni límite de tamaño ni envejecimiento (no hay TTL, ni cap de filas, ni purga por antigüedad). Mientras el sync drena bien, no se nota. Pero si un device nunca recupera la red (se pierde, se rompe la conectividad de forma permanente, queda en un sótano sin señal), cada tap sigue insertando filas en Room indefinidamente y la base crece sin techo.

Hoy lo único que saca filas de la cola es un sync exitoso (purga por client_uuid) o un rechazo terminal del server — ambos requieren llegar al server. Sin red, nada la achica. Es un riesgo real a evaluar (ej. cap + alerta, o purga por antigüedad con preservación de los más recientes), no un bug actual.

Fuente: la cola se llena en EvaluarTapUseCase / MainViewModel.onNfcTagRead; solo se purga en SyncRepository.procesar* tras tocar el server.