@acvl/mobile-components 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) hide show
  1. package/README.md +146 -2
  2. package/assets/fonts/Inter_400Regular.ttf +0 -0
  3. package/assets/fonts/Inter_500Medium.ttf +0 -0
  4. package/assets/fonts/Inter_600SemiBold.ttf +0 -0
  5. package/assets/fonts/Inter_700Bold.ttf +0 -0
  6. package/assets/fonts/Inter_800ExtraBold.ttf +0 -0
  7. package/assets/fonts/Inter_900Black.ttf +0 -0
  8. package/assets/fonts/OFL.txt +93 -0
  9. package/lib/module/componentes/AccionesDelEncabezado.js +212 -0
  10. package/lib/module/componentes/AccionesDelEncabezado.js.map +1 -0
  11. package/lib/module/componentes/BotonDeAvatar.js +55 -0
  12. package/lib/module/componentes/BotonDeAvatar.js.map +1 -0
  13. package/lib/module/componentes/BotonGrande.js +105 -0
  14. package/lib/module/componentes/BotonGrande.js.map +1 -0
  15. package/lib/module/componentes/CabeceraEmpujada.js +101 -0
  16. package/lib/module/componentes/CabeceraEmpujada.js.map +1 -0
  17. package/lib/module/componentes/Chip.js +77 -0
  18. package/lib/module/componentes/Chip.js.map +1 -0
  19. package/lib/module/componentes/ContenidoDePantalla.js +56 -0
  20. package/lib/module/componentes/ContenidoDePantalla.js.map +1 -0
  21. package/lib/module/componentes/EstadoVacio.js +65 -0
  22. package/lib/module/componentes/EstadoVacio.js.map +1 -0
  23. package/lib/module/componentes/Foto.js +106 -0
  24. package/lib/module/componentes/Foto.js.map +1 -0
  25. package/lib/module/componentes/IconoDeRenglon.js +44 -0
  26. package/lib/module/componentes/IconoDeRenglon.js.map +1 -0
  27. package/lib/module/componentes/Logo.js +44 -0
  28. package/lib/module/componentes/Logo.js.map +1 -0
  29. package/lib/module/componentes/Nota.js +51 -0
  30. package/lib/module/componentes/Nota.js.map +1 -0
  31. package/lib/module/componentes/OpcionesExcluyentes.js +69 -0
  32. package/lib/module/componentes/OpcionesExcluyentes.js.map +1 -0
  33. package/lib/module/componentes/PantallaDeError.js +84 -0
  34. package/lib/module/componentes/PantallaDeError.js.map +1 -0
  35. package/lib/module/componentes/Pestanas.js +87 -0
  36. package/lib/module/componentes/Pestanas.js.map +1 -0
  37. package/lib/module/componentes/Rotulo.js +28 -0
  38. package/lib/module/componentes/Rotulo.js.map +1 -0
  39. package/lib/module/componentes/RotuloDeSeccion.js +87 -0
  40. package/lib/module/componentes/RotuloDeSeccion.js.map +1 -0
  41. package/lib/module/componentes/Tarjeta.js +47 -0
  42. package/lib/module/componentes/Tarjeta.js.map +1 -0
  43. package/lib/module/componentes/TarjetaDeDatos.js +79 -0
  44. package/lib/module/componentes/TarjetaDeDatos.js.map +1 -0
  45. package/lib/module/componentes/TiraDeFotos.js +184 -0
  46. package/lib/module/componentes/TiraDeFotos.js.map +1 -0
  47. package/lib/module/componentes/VisorDeFoto.js +171 -0
  48. package/lib/module/componentes/VisorDeFoto.js.map +1 -0
  49. package/lib/module/componentes/logos/bola-blanca.png +0 -0
  50. package/lib/module/componentes/logos/bola.png +0 -0
  51. package/lib/module/componentes/logos/logo-blanco.png +0 -0
  52. package/lib/module/componentes/logos/logo.png +0 -0
  53. package/lib/module/dialogos/HostDeDialogos.js +170 -0
  54. package/lib/module/dialogos/HostDeDialogos.js.map +1 -0
  55. package/lib/module/dialogos/dialogos.js +112 -0
  56. package/lib/module/dialogos/dialogos.js.map +1 -0
  57. package/lib/module/formularios/CampoDeSeleccion.js +47 -0
  58. package/lib/module/formularios/CampoDeSeleccion.js.map +1 -0
  59. package/lib/module/formularios/SelectorDeOpcion.js +406 -0
  60. package/lib/module/formularios/SelectorDeOpcion.js.map +1 -0
  61. package/lib/module/formularios/TextoDeAccesoControlado.js +73 -0
  62. package/lib/module/formularios/TextoDeAccesoControlado.js.map +1 -0
  63. package/lib/module/formularios/campos.js +493 -0
  64. package/lib/module/formularios/campos.js.map +1 -0
  65. package/lib/module/formularios/controlados.js +197 -0
  66. package/lib/module/formularios/controlados.js.map +1 -0
  67. package/lib/module/formularios/errores.js +91 -0
  68. package/lib/module/formularios/errores.js.map +1 -0
  69. package/lib/module/formularios/guardado.js +78 -0
  70. package/lib/module/formularios/guardado.js.map +1 -0
  71. package/lib/module/formularios/useBusqueda.js +22 -0
  72. package/lib/module/formularios/useBusqueda.js.map +1 -0
  73. package/lib/module/fotos/elegir.js +178 -0
  74. package/lib/module/fotos/elegir.js.map +1 -0
  75. package/lib/module/fotos/index.js +8 -0
  76. package/lib/module/fotos/index.js.map +1 -0
  77. package/lib/module/fotos/reducir.js +93 -0
  78. package/lib/module/fotos/reducir.js.map +1 -0
  79. package/lib/module/hoja/HojaInferior.js +108 -0
  80. package/lib/module/hoja/HojaInferior.js.map +1 -0
  81. package/lib/module/hoja/index.js +7 -0
  82. package/lib/module/hoja/index.js.map +1 -0
  83. package/lib/module/hoja/useMasDeLaFicha.js +48 -0
  84. package/lib/module/hoja/useMasDeLaFicha.js.map +1 -0
  85. package/lib/module/index.js +66 -0
  86. package/lib/module/index.js.map +1 -0
  87. package/lib/module/listas/HojaDeFiltros.js +260 -0
  88. package/lib/module/listas/HojaDeFiltros.js.map +1 -0
  89. package/lib/module/listas/ListaPaginada.js +103 -0
  90. package/lib/module/listas/ListaPaginada.js.map +1 -0
  91. package/lib/module/listas/TarjetaDeslizable.js +118 -0
  92. package/lib/module/listas/TarjetaDeslizable.js.map +1 -0
  93. package/lib/module/listas/paginado.js +11 -0
  94. package/lib/module/listas/paginado.js.map +1 -0
  95. package/lib/module/listas/piezas.js +603 -0
  96. package/lib/module/listas/piezas.js.map +1 -0
  97. package/lib/module/navegacion/BarraDePestanas.js +162 -0
  98. package/lib/module/navegacion/BarraDePestanas.js.map +1 -0
  99. package/lib/module/navegacion/BotonDeBarra.js +58 -0
  100. package/lib/module/navegacion/BotonDeBarra.js.map +1 -0
  101. package/lib/module/navegacion/encabezado.js +103 -0
  102. package/lib/module/navegacion/encabezado.js.map +1 -0
  103. package/lib/module/pantallas/PantallaDeAcceso.js +195 -0
  104. package/lib/module/pantallas/PantallaDeAcceso.js.map +1 -0
  105. package/lib/module/pantallas/PantallaDeEdicion.js +121 -0
  106. package/lib/module/pantallas/PantallaDeEdicion.js.map +1 -0
  107. package/lib/module/pantallas/PantallaDeFicha.js +94 -0
  108. package/lib/module/pantallas/PantallaDeFicha.js.map +1 -0
  109. package/lib/module/pantallas/PantallaDePerfil.js +137 -0
  110. package/lib/module/pantallas/PantallaDePerfil.js.map +1 -0
  111. package/lib/module/tema/ProveedorDeTema.js +122 -0
  112. package/lib/module/tema/ProveedorDeTema.js.map +1 -0
  113. package/lib/module/tema/estilos.js +54 -0
  114. package/lib/module/tema/estilos.js.map +1 -0
  115. package/lib/module/tema/fuentes.js +28 -0
  116. package/lib/module/tema/fuentes.js.map +1 -0
  117. package/lib/module/tema/index.js +8 -0
  118. package/lib/module/tema/index.js.map +1 -0
  119. package/lib/module/tema/paleta.js +107 -0
  120. package/lib/module/tema/paleta.js.map +1 -0
  121. package/lib/module/tema/tipografia.js +32 -0
  122. package/lib/module/tema/tipografia.js.map +1 -0
  123. package/lib/module/texto.js +48 -0
  124. package/lib/module/texto.js.map +1 -0
  125. package/lib/typescript/src/componentes/AccionesDelEncabezado.d.ts +34 -0
  126. package/lib/typescript/src/componentes/AccionesDelEncabezado.d.ts.map +1 -0
  127. package/lib/typescript/src/componentes/BotonDeAvatar.d.ts +17 -0
  128. package/lib/typescript/src/componentes/BotonDeAvatar.d.ts.map +1 -0
  129. package/lib/typescript/src/componentes/BotonGrande.d.ts +26 -0
  130. package/lib/typescript/src/componentes/BotonGrande.d.ts.map +1 -0
  131. package/lib/typescript/src/componentes/CabeceraEmpujada.d.ts +18 -0
  132. package/lib/typescript/src/componentes/CabeceraEmpujada.d.ts.map +1 -0
  133. package/lib/typescript/src/componentes/Chip.d.ts +14 -0
  134. package/lib/typescript/src/componentes/Chip.d.ts.map +1 -0
  135. package/lib/typescript/src/componentes/ContenidoDePantalla.d.ts +17 -0
  136. package/lib/typescript/src/componentes/ContenidoDePantalla.d.ts.map +1 -0
  137. package/lib/typescript/src/componentes/EstadoVacio.d.ts +17 -0
  138. package/lib/typescript/src/componentes/EstadoVacio.d.ts.map +1 -0
  139. package/lib/typescript/src/componentes/Foto.d.ts +41 -0
  140. package/lib/typescript/src/componentes/Foto.d.ts.map +1 -0
  141. package/lib/typescript/src/componentes/IconoDeRenglon.d.ts +25 -0
  142. package/lib/typescript/src/componentes/IconoDeRenglon.d.ts.map +1 -0
  143. package/lib/typescript/src/componentes/Logo.d.ts +13 -0
  144. package/lib/typescript/src/componentes/Logo.d.ts.map +1 -0
  145. package/lib/typescript/src/componentes/Nota.d.ts +7 -0
  146. package/lib/typescript/src/componentes/Nota.d.ts.map +1 -0
  147. package/lib/typescript/src/componentes/OpcionesExcluyentes.d.ts +14 -0
  148. package/lib/typescript/src/componentes/OpcionesExcluyentes.d.ts.map +1 -0
  149. package/lib/typescript/src/componentes/PantallaDeError.d.ts +23 -0
  150. package/lib/typescript/src/componentes/PantallaDeError.d.ts.map +1 -0
  151. package/lib/typescript/src/componentes/Pestanas.d.ts +28 -0
  152. package/lib/typescript/src/componentes/Pestanas.d.ts.map +1 -0
  153. package/lib/typescript/src/componentes/Rotulo.d.ts +6 -0
  154. package/lib/typescript/src/componentes/Rotulo.d.ts.map +1 -0
  155. package/lib/typescript/src/componentes/RotuloDeSeccion.d.ts +20 -0
  156. package/lib/typescript/src/componentes/RotuloDeSeccion.d.ts.map +1 -0
  157. package/lib/typescript/src/componentes/Tarjeta.d.ts +15 -0
  158. package/lib/typescript/src/componentes/Tarjeta.d.ts.map +1 -0
  159. package/lib/typescript/src/componentes/TarjetaDeDatos.d.ts +13 -0
  160. package/lib/typescript/src/componentes/TarjetaDeDatos.d.ts.map +1 -0
  161. package/lib/typescript/src/componentes/TiraDeFotos.d.ts +36 -0
  162. package/lib/typescript/src/componentes/TiraDeFotos.d.ts.map +1 -0
  163. package/lib/typescript/src/componentes/VisorDeFoto.d.ts +23 -0
  164. package/lib/typescript/src/componentes/VisorDeFoto.d.ts.map +1 -0
  165. package/lib/typescript/src/dialogos/HostDeDialogos.d.ts +14 -0
  166. package/lib/typescript/src/dialogos/HostDeDialogos.d.ts.map +1 -0
  167. package/lib/typescript/src/dialogos/dialogos.d.ts +65 -0
  168. package/lib/typescript/src/dialogos/dialogos.d.ts.map +1 -0
  169. package/lib/typescript/src/formularios/CampoDeSeleccion.d.ts +22 -0
  170. package/lib/typescript/src/formularios/CampoDeSeleccion.d.ts.map +1 -0
  171. package/lib/typescript/src/formularios/SelectorDeOpcion.d.ts +59 -0
  172. package/lib/typescript/src/formularios/SelectorDeOpcion.d.ts.map +1 -0
  173. package/lib/typescript/src/formularios/TextoDeAccesoControlado.d.ts +18 -0
  174. package/lib/typescript/src/formularios/TextoDeAccesoControlado.d.ts.map +1 -0
  175. package/lib/typescript/src/formularios/campos.d.ts +107 -0
  176. package/lib/typescript/src/formularios/campos.d.ts.map +1 -0
  177. package/lib/typescript/src/formularios/controlados.d.ts +29 -0
  178. package/lib/typescript/src/formularios/controlados.d.ts.map +1 -0
  179. package/lib/typescript/src/formularios/errores.d.ts +36 -0
  180. package/lib/typescript/src/formularios/errores.d.ts.map +1 -0
  181. package/lib/typescript/src/formularios/guardado.d.ts +36 -0
  182. package/lib/typescript/src/formularios/guardado.d.ts.map +1 -0
  183. package/lib/typescript/src/formularios/useBusqueda.d.ts +7 -0
  184. package/lib/typescript/src/formularios/useBusqueda.d.ts.map +1 -0
  185. package/lib/typescript/src/fotos/elegir.d.ts +58 -0
  186. package/lib/typescript/src/fotos/elegir.d.ts.map +1 -0
  187. package/lib/typescript/src/fotos/index.d.ts +3 -0
  188. package/lib/typescript/src/fotos/index.d.ts.map +1 -0
  189. package/lib/typescript/src/fotos/reducir.d.ts +32 -0
  190. package/lib/typescript/src/fotos/reducir.d.ts.map +1 -0
  191. package/lib/typescript/src/hoja/HojaInferior.d.ts +21 -0
  192. package/lib/typescript/src/hoja/HojaInferior.d.ts.map +1 -0
  193. package/lib/typescript/src/hoja/index.d.ts +3 -0
  194. package/lib/typescript/src/hoja/index.d.ts.map +1 -0
  195. package/lib/typescript/src/hoja/useMasDeLaFicha.d.ts +27 -0
  196. package/lib/typescript/src/hoja/useMasDeLaFicha.d.ts.map +1 -0
  197. package/lib/typescript/src/index.d.ts +45 -0
  198. package/lib/typescript/src/index.d.ts.map +1 -0
  199. package/lib/typescript/src/listas/HojaDeFiltros.d.ts +60 -0
  200. package/lib/typescript/src/listas/HojaDeFiltros.d.ts.map +1 -0
  201. package/lib/typescript/src/listas/ListaPaginada.d.ts +39 -0
  202. package/lib/typescript/src/listas/ListaPaginada.d.ts.map +1 -0
  203. package/lib/typescript/src/listas/TarjetaDeslizable.d.ts +33 -0
  204. package/lib/typescript/src/listas/TarjetaDeslizable.d.ts.map +1 -0
  205. package/lib/typescript/src/listas/paginado.d.ts +16 -0
  206. package/lib/typescript/src/listas/paginado.d.ts.map +1 -0
  207. package/lib/typescript/src/listas/piezas.d.ts +135 -0
  208. package/lib/typescript/src/listas/piezas.d.ts.map +1 -0
  209. package/lib/typescript/src/navegacion/BarraDePestanas.d.ts +57 -0
  210. package/lib/typescript/src/navegacion/BarraDePestanas.d.ts.map +1 -0
  211. package/lib/typescript/src/navegacion/BotonDeBarra.d.ts +15 -0
  212. package/lib/typescript/src/navegacion/BotonDeBarra.d.ts.map +1 -0
  213. package/lib/typescript/src/navegacion/encabezado.d.ts +44 -0
  214. package/lib/typescript/src/navegacion/encabezado.d.ts.map +1 -0
  215. package/lib/typescript/src/pantallas/PantallaDeAcceso.d.ts +32 -0
  216. package/lib/typescript/src/pantallas/PantallaDeAcceso.d.ts.map +1 -0
  217. package/lib/typescript/src/pantallas/PantallaDeEdicion.d.ts +38 -0
  218. package/lib/typescript/src/pantallas/PantallaDeEdicion.d.ts.map +1 -0
  219. package/lib/typescript/src/pantallas/PantallaDeFicha.d.ts +35 -0
  220. package/lib/typescript/src/pantallas/PantallaDeFicha.d.ts.map +1 -0
  221. package/lib/typescript/src/pantallas/PantallaDePerfil.d.ts +19 -0
  222. package/lib/typescript/src/pantallas/PantallaDePerfil.d.ts.map +1 -0
  223. package/lib/typescript/src/tema/ProveedorDeTema.d.ts +32 -0
  224. package/lib/typescript/src/tema/ProveedorDeTema.d.ts.map +1 -0
  225. package/lib/typescript/src/tema/estilos.d.ts +31 -0
  226. package/lib/typescript/src/tema/estilos.d.ts.map +1 -0
  227. package/lib/typescript/src/tema/fuentes.d.ts +20 -0
  228. package/lib/typescript/src/tema/fuentes.d.ts.map +1 -0
  229. package/lib/typescript/src/tema/index.d.ts +6 -0
  230. package/lib/typescript/src/tema/index.d.ts.map +1 -0
  231. package/lib/typescript/src/tema/paleta.d.ts +86 -0
  232. package/lib/typescript/src/tema/paleta.d.ts.map +1 -0
  233. package/lib/typescript/src/tema/tipografia.d.ts +29 -0
  234. package/lib/typescript/src/tema/tipografia.d.ts.map +1 -0
  235. package/lib/typescript/src/texto.d.ts +16 -0
  236. package/lib/typescript/src/texto.d.ts.map +1 -0
  237. package/package.json +135 -5
  238. package/src/componentes/AccionesDelEncabezado.tsx +247 -0
  239. package/src/componentes/BotonDeAvatar.tsx +59 -0
  240. package/src/componentes/BotonGrande.tsx +100 -0
  241. package/src/componentes/CabeceraEmpujada.tsx +69 -0
  242. package/src/componentes/Chip.tsx +60 -0
  243. package/src/componentes/ContenidoDePantalla.tsx +53 -0
  244. package/src/componentes/EstadoVacio.tsx +45 -0
  245. package/src/componentes/Foto.tsx +92 -0
  246. package/src/componentes/IconoDeRenglon.tsx +42 -0
  247. package/src/componentes/Logo.tsx +34 -0
  248. package/src/componentes/Nota.tsx +37 -0
  249. package/src/componentes/OpcionesExcluyentes.tsx +62 -0
  250. package/src/componentes/PantallaDeError.tsx +76 -0
  251. package/src/componentes/Pestanas.tsx +86 -0
  252. package/src/componentes/Rotulo.tsx +21 -0
  253. package/src/componentes/RotuloDeSeccion.tsx +71 -0
  254. package/src/componentes/Tarjeta.tsx +44 -0
  255. package/src/componentes/TarjetaDeDatos.tsx +62 -0
  256. package/src/componentes/TiraDeFotos.tsx +189 -0
  257. package/src/componentes/VisorDeFoto.tsx +181 -0
  258. package/src/componentes/logos/bola-blanca.png +0 -0
  259. package/src/componentes/logos/bola.png +0 -0
  260. package/src/componentes/logos/logo-blanco.png +0 -0
  261. package/src/componentes/logos/logo.png +0 -0
  262. package/src/dialogos/HostDeDialogos.tsx +172 -0
  263. package/src/dialogos/dialogos.ts +148 -0
  264. package/src/formularios/CampoDeSeleccion.tsx +46 -0
  265. package/src/formularios/SelectorDeOpcion.tsx +407 -0
  266. package/src/formularios/TextoDeAccesoControlado.tsx +72 -0
  267. package/src/formularios/campos.tsx +483 -0
  268. package/src/formularios/controlados.tsx +198 -0
  269. package/src/formularios/errores.ts +105 -0
  270. package/src/formularios/guardado.ts +76 -0
  271. package/src/formularios/useBusqueda.ts +19 -0
  272. package/src/fotos/elegir.ts +194 -0
  273. package/src/fotos/index.ts +15 -0
  274. package/src/fotos/reducir.ts +87 -0
  275. package/src/hoja/HojaInferior.tsx +102 -0
  276. package/src/hoja/index.ts +4 -0
  277. package/src/hoja/useMasDeLaFicha.tsx +63 -0
  278. package/src/index.ts +143 -0
  279. package/src/listas/HojaDeFiltros.tsx +261 -0
  280. package/src/listas/ListaPaginada.tsx +117 -0
  281. package/src/listas/TarjetaDeslizable.tsx +126 -0
  282. package/src/listas/paginado.ts +15 -0
  283. package/src/listas/piezas.tsx +594 -0
  284. package/src/navegacion/BarraDePestanas.tsx +198 -0
  285. package/src/navegacion/BotonDeBarra.tsx +61 -0
  286. package/src/navegacion/encabezado.tsx +88 -0
  287. package/src/pantallas/PantallaDeAcceso.tsx +184 -0
  288. package/src/pantallas/PantallaDeEdicion.tsx +116 -0
  289. package/src/pantallas/PantallaDeFicha.tsx +115 -0
  290. package/src/pantallas/PantallaDePerfil.tsx +126 -0
  291. package/src/tema/ProveedorDeTema.tsx +150 -0
  292. package/src/tema/estilos.ts +51 -0
  293. package/src/tema/fuentes.ts +20 -0
  294. package/src/tema/index.ts +10 -0
  295. package/src/tema/paleta.ts +147 -0
  296. package/src/tema/tipografia.ts +29 -0
  297. package/src/texto.tsx +44 -0
@@ -0,0 +1,105 @@
1
+ /** Si un texto es una página HTML y no un mensaje. */
2
+ const pareceHtml = (texto: string) => /^\s*</.test(texto) || /<(html|body|head)[\s>]/i.test(texto);
3
+
4
+ /**
5
+ * El mensaje que el backend mandó, listo para enseñárselo al usuario.
6
+ *
7
+ * Los errores de los backends de ACV salen de `drf_simple_api_errors`, que responde
8
+ * `{ title, detail?: string[], invalid_params?: [{ name, reason: string[] }] }`. Llegan en dos
9
+ * formas y se leen las dos:
10
+ *
11
+ * - `detail`: los errores de regla, que no cuelgan de un campo (el orden de los pasos, «falta lo
12
+ * que la acción pide»). Si vienen varios se juntan todos, uno por renglón: enseñar sólo el
13
+ * primero obligaba a corregir y reintentar para descubrir el siguiente.
14
+ * - `invalid_params`: los de un campo (`clave`, `lng`, `fecha_real`), cada uno con su motivo.
15
+ *
16
+ * `title` es la etiqueta del tipo de error («Validation error.»), que sola no le dice nada a
17
+ * nadie: sólo se usa si no hay nada más.
18
+ *
19
+ * Sirve para el feedback de una acción —un aviso, Pendientes—, no para pintar errores en los
20
+ * campos de un formulario: eso lo hace `setErrors` con `invalid_params`.
21
+ */
22
+ export const mensajeDeError = (error: any, fallback: string): string => {
23
+ const data = error?.data;
24
+
25
+ if (typeof data === "string" && data) {
26
+ // Una página de error de un proxy (un 413 o un 502 de nginx) llega como `PARSING_ERROR`
27
+ // con el HTML en `data`: enseñarla tal cual llenaba Pendientes de etiquetas. Va el
28
+ // mensaje genérico con el código.
29
+ if (pareceHtml(data)) {
30
+ const codigo = error?.originalStatus ?? error?.status;
31
+ return typeof codigo === "number" ? `${fallback} (error ${codigo})` : fallback;
32
+ }
33
+ return data;
34
+ }
35
+
36
+ if (data && typeof data === "object") {
37
+ const renglones: string[] = [];
38
+
39
+ if (Array.isArray(data.detail)) renglones.push(...data.detail.map(String));
40
+ else if (typeof data.detail === "string" && data.detail) renglones.push(data.detail);
41
+
42
+ for (const param of Array.isArray(data.invalid_params) ? data.invalid_params : []) {
43
+ const razon = Array.isArray(param?.reason) ? param.reason.join(" ") : param?.reason;
44
+ if (razon) renglones.push(String(razon));
45
+ }
46
+
47
+ const limpios = renglones.map((r) => r.trim()).filter(Boolean);
48
+ if (limpios.length) return limpios.join("\n");
49
+
50
+ if (typeof data.title === "string" && data.title) return data.title;
51
+ }
52
+
53
+ // Un `Error` de la propia app (p. ej. el dispositivo no sabe compartir archivos) no trae
54
+ // `data`: su mensaje ya está escrito para el usuario.
55
+ if (typeof error?.message === "string" && error.message) return error.message;
56
+
57
+ return fallback;
58
+ };
59
+
60
+ /**
61
+ * Vuelca los motivos de un 400 sobre los campos del formulario.
62
+ *
63
+ * El backend responde en RFC 7807 (`invalid_params: [{ name, reason }]`). Un campo suelto trae
64
+ * su motivo como lista de textos y se cuelga de su nombre tal cual. Una **lista editable**
65
+ * (`useFieldArray`) trae en su lugar un objeto por renglón —vacío si ese
66
+ * renglón está bien—, que es como DRF reporta un serializador anidado `many=True`; ahí el motivo
67
+ * va a `lista.<índice>.<campo>`, que es justo como se llaman los inputs de la fila, para que se
68
+ * pinte sobre el campo que falla.
69
+ *
70
+ * Sin ese segundo caso el error quedaba a nombre de la lista entera, donde no hay ningún input
71
+ * registrado: quien capturaba sólo veía el aviso genérico de que no se pudo guardar. Es la misma
72
+ * traducción que hace el portal (`setErrors` de @acvl/frontend-components), así que la forma de
73
+ * la respuesta es una sola para los dos clientes.
74
+ */
75
+ export const setErrors = (form: any, error: any) => {
76
+ // Sin respuesta (una falla de red) o un 400 de regla sin campos (`invalid_params: null`): no
77
+ // hay nada que colgar de los campos, y quien llama ya enseña el aviso general.
78
+ const params = error?.data?.invalid_params;
79
+ if (!params || typeof params !== "object") return;
80
+
81
+ Object.values(params).forEach((param: any) => {
82
+ const porRenglon =
83
+ Array.isArray(param.reason) &&
84
+ param.reason.length > 0 &&
85
+ param.reason.every(
86
+ (renglon: any) =>
87
+ typeof renglon === "object" && renglon !== null && !Array.isArray(renglon),
88
+ );
89
+
90
+ if (porRenglon) {
91
+ param.reason.forEach((renglon: any, indice: number) => {
92
+ Object.entries(renglon).forEach(([campo, motivos]: [string, any]) => {
93
+ form.setError(`${param.name}.${indice}.${campo}`, {
94
+ message: Array.isArray(motivos) ? motivos.join(", ") : String(motivos),
95
+ });
96
+ });
97
+ });
98
+ return;
99
+ }
100
+
101
+ form.setError(param.name, {
102
+ message: Array.isArray(param.reason) ? param.reason.join(", ") : param.reason,
103
+ });
104
+ });
105
+ };
@@ -0,0 +1,76 @@
1
+ import { router } from "expo-router";
2
+ import React from "react";
3
+ import type { FieldValues, UseFormReturn } from "react-hook-form";
4
+ import { avisar } from "../dialogos/dialogos";
5
+ import { mensajeDeError, setErrors } from "./errores";
6
+
7
+ /**
8
+ * Lo que comparten los formularios de edición: cómo se compara lo capturado contra lo que vino,
9
+ * cómo se lee un rechazo del servidor y cómo se guarda.
10
+ */
11
+
12
+ /** Un texto anulable como se manda: sin espacios a los lados, y vacío es `null`. */
13
+ export const textoONulo = (texto: string | null | undefined): string | null => {
14
+ const limpio = (texto ?? "").trim();
15
+ return limpio ? limpio : null;
16
+ };
17
+
18
+ /** Una fecha como se manda: ISO **con su hora** (la hora es el dato), o `null`. */
19
+ export const isoONulo = (fecha: Date | null): string | null => (fecha ? fecha.toISOString() : null);
20
+
21
+ /** Una fecha del servidor como la captura el formulario. */
22
+ export const fechaONula = (iso: string | null | undefined): Date | null =>
23
+ iso ? new Date(iso) : null;
24
+
25
+ /** Si dos instantes son el mismo; dos nulos también lo son. */
26
+ export const mismoInstante = (a: Date | null, b: Date | null): boolean =>
27
+ (a?.getTime() ?? null) === (b?.getTime() ?? null);
28
+
29
+ /**
30
+ * Cuelga de cada campo el motivo con el que el servidor lo rechazó (`invalid_params`). Sólo si
31
+ * vienen como lista: un 400 de regla trae `detail` y `invalid_params` nulo, y `setErrors` no lo
32
+ * aguanta. Lo que no es de un campo lo dice quien llama, con `mensajeDeError`.
33
+ */
34
+ export const volcarErrores = <T extends FieldValues>(form: UseFormReturn<T>, error: unknown) => {
35
+ const data = (error as { data?: { invalid_params?: unknown } } | null)?.data;
36
+ if (data && Array.isArray(data.invalid_params)) setErrors(form, error);
37
+ };
38
+
39
+ /**
40
+ * Guardar un formulario de edición, igual en todos: si nada cambió, sólo se regresa; si el
41
+ * servidor lo rechaza, cada motivo se cuelga de su campo (`volcarErrores`), el mensaje queda
42
+ * abajo del formulario (`error`) y se avisa. Al guardar, regresa a la pantalla de antes.
43
+ */
44
+ export function useGuardado<T extends FieldValues>(form: UseFormReturn<T>) {
45
+ const [error, setError] = React.useState<string | null>(null);
46
+ const guardar = async (cambios: object, mandar: () => Promise<unknown>) => {
47
+ if (Object.keys(cambios).length === 0) {
48
+ router.back();
49
+ return;
50
+ }
51
+ setError(null);
52
+ try {
53
+ await mandar();
54
+ router.back();
55
+ } catch (falla) {
56
+ volcarErrores(form, falla);
57
+ const mensaje = mensajeDeError(falla, "Revisa la señal e intenta de nuevo.");
58
+ setError(mensaje);
59
+ await avisar({ titulo: "No se guardó", mensaje, icono: "alert-circle-outline" });
60
+ }
61
+ };
62
+ return { error, guardar };
63
+ }
64
+
65
+ /**
66
+ * De dos cuerpos completos —lo que se mandaría con lo que vino y con lo capturado—, sólo las
67
+ * llaves que cambiaron, como el `formParser` del portal: un `PATCH` con todo pisaría lo que alguien
68
+ * cambió en el portal mientras la pantalla estaba abierta. Compara por valor (`JSON`), así que
69
+ * sirve con listas y objetos (los contactos, una dirección).
70
+ */
71
+ export const soloLoQueCambio = <T extends Record<string, unknown>>(antes: T, despues: T) =>
72
+ Object.fromEntries(
73
+ Object.entries(despues).filter(
74
+ ([llave, valor]) => JSON.stringify(valor) !== JSON.stringify(antes[llave]),
75
+ ),
76
+ ) as Partial<T>;
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+
3
+ /** Lo que tarda en buscar después de la última tecla: no una petición por letra. */
4
+ const PAUSA_MS = 300;
5
+
6
+ /**
7
+ * Un buscador que pide al servidor: `[texto, busqueda, setTexto]`. `texto` es lo escrito, al
8
+ * momento, para el campo; `busqueda` es lo mismo sin espacios a los lados y **300 ms después de la
9
+ * última tecla**, para la consulta.
10
+ */
11
+ export function useBusqueda(): [string, string, (texto: string) => void] {
12
+ const [texto, setTexto] = React.useState("");
13
+ const [busqueda, setBusqueda] = React.useState("");
14
+ React.useEffect(() => {
15
+ const espera = setTimeout(() => setBusqueda(texto.trim()), PAUSA_MS);
16
+ return () => clearTimeout(espera);
17
+ }, [texto]);
18
+ return [texto, busqueda, setTexto];
19
+ }
@@ -0,0 +1,194 @@
1
+ // Elegir una foto o un archivo para subirlo: de la cámara, de la galería o un documento. Contesta lo
2
+ // elegido ya listo para el `FormData` (las fotos reducidas a 1920 px); qué se hace con él —subirlo
3
+ // en el momento, guardarlo para una cola— es de quien llama.
4
+ import * as DocumentPicker from "expo-document-picker";
5
+ import { File } from "expo-file-system";
6
+ import * as ImagePicker from "expo-image-picker";
7
+ import { avisar, mostrarDialogo } from "../dialogos/dialogos";
8
+ import { reducirFoto, reducirImagenAdjunta } from "./reducir";
9
+
10
+ /** Lo elegido: dónde está en el teléfono, su nombre, su tipo y si es una foto. */
11
+ export interface ArchivoElegido {
12
+ uri: string;
13
+ nombre: string;
14
+ tipo: string;
15
+ esFoto: boolean;
16
+ }
17
+
18
+ /** El tope de un archivo en el servidor (`ConArchivoSubido.TAMANO_MAXIMO`): 25 MB. */
19
+ export const TAMANO_MAXIMO_BYTES = 25 * 1024 * 1024;
20
+
21
+ /**
22
+ * Lo que el servidor acepta de un adjunto (`core/shared/serializers.py`): PDF, imágenes y hojas de
23
+ * cálculo. Lo demás ni se ofrece en el selector.
24
+ */
25
+ export const TIPOS_DE_ADJUNTO = [
26
+ "application/pdf",
27
+ "image/*",
28
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
29
+ "application/vnd.ms-excel",
30
+ "text/csv",
31
+ ];
32
+
33
+ /** Para qué se pide la cámara, dicho a quien la usa. */
34
+ export interface PermisoDeCamara {
35
+ /** «tomar las fotos de evidencia»: va en «Para …, activa la cámara en los ajustes». */
36
+ para: string;
37
+ /**
38
+ * Lo que se explica antes del diálogo del sistema la primera vez: por qué y cuándo se usa. Sin
39
+ * esto, va directo al del sistema.
40
+ */
41
+ explicacion?: string;
42
+ }
43
+
44
+ /**
45
+ * El permiso de la cámara. Si ya se negó para siempre, se dice dónde activarlo; si nunca se ha
46
+ * pedido, primero la explicación de la app (si hay) y luego el diálogo del sistema.
47
+ */
48
+ export async function pedirPermisoDeCamara({
49
+ para,
50
+ explicacion,
51
+ }: PermisoDeCamara): Promise<boolean> {
52
+ const actual = await ImagePicker.getCameraPermissionsAsync();
53
+ if (actual.granted) return true;
54
+ if (!actual.canAskAgain) {
55
+ await avisar({
56
+ titulo: "Sin permiso de cámara",
57
+ mensaje: `Para ${para}, activa la cámara para la app en los ajustes del teléfono.`,
58
+ icono: "camera-off-outline",
59
+ });
60
+ return false;
61
+ }
62
+ if (explicacion) {
63
+ await avisar({
64
+ titulo: "La cámara",
65
+ mensaje: explicacion,
66
+ icono: "camera-outline",
67
+ cancelable: false,
68
+ });
69
+ }
70
+ return (await ImagePicker.requestCameraPermissionsAsync()).granted;
71
+ }
72
+
73
+ /** Las imágenes elegidas, reducidas a 1920 px en JPEG (`reducirFoto`). */
74
+ async function deFotos(assets: ImagePicker.ImagePickerAsset[]): Promise<ArchivoElegido[]> {
75
+ const elegidos: ArchivoElegido[] = [];
76
+ for (const asset of assets) {
77
+ const reducida = await reducirFoto(asset);
78
+ elegidos.push({
79
+ uri: reducida.uri,
80
+ nombre: reducida.fileName ?? "foto.jpg",
81
+ tipo: reducida.mimeType ?? "image/jpeg",
82
+ esFoto: true,
83
+ });
84
+ }
85
+ return elegidos;
86
+ }
87
+
88
+ /** Una foto con la cámara, con su permiso. Vacío si no hubo permiso o se canceló. */
89
+ export async function tomarFoto(permiso: PermisoDeCamara): Promise<ArchivoElegido[]> {
90
+ if (!(await pedirPermisoDeCamara(permiso))) return [];
91
+ const resultado = await ImagePicker.launchCameraAsync({ mediaTypes: ["images"], quality: 1 });
92
+ return resultado.canceled ? [] : deFotos(resultado.assets);
93
+ }
94
+
95
+ /**
96
+ * De la galería. En Android el selector de fotos del sistema no pide permiso de almacenamiento,
97
+ * así que no hay explicación previa que dar.
98
+ */
99
+ export async function elegirDeGaleria({ varias = false } = {}): Promise<ArchivoElegido[]> {
100
+ const resultado = await ImagePicker.launchImageLibraryAsync({
101
+ mediaTypes: ["images"],
102
+ allowsMultipleSelection: varias,
103
+ quality: 1,
104
+ });
105
+ return resultado.canceled ? [] : deFotos(resultado.assets);
106
+ }
107
+
108
+ /**
109
+ * Documentos (un PDF, una foto ya escaneada). Uno más pesado que el tope del servidor se rechaza al
110
+ * subirlo, así que se avisa aquí y se deja fuera. Una imagen adjuntada como archivo también se
111
+ * reduce, igual que una de la cámara.
112
+ */
113
+ export async function elegirDocumentos({
114
+ tipos = TIPOS_DE_ADJUNTO,
115
+ varias = false,
116
+ tamanoMaximo = TAMANO_MAXIMO_BYTES,
117
+ }: { tipos?: string[]; varias?: boolean; tamanoMaximo?: number } = {}): Promise<ArchivoElegido[]> {
118
+ const resultado = await DocumentPicker.getDocumentAsync({
119
+ type: tipos,
120
+ multiple: varias,
121
+ copyToCacheDirectory: true,
122
+ });
123
+ if (resultado.canceled) return [];
124
+ const elegidos: ArchivoElegido[] = [];
125
+ for (const doc of resultado.assets) {
126
+ const pesa = doc.size ?? new File(doc.uri).size;
127
+ if (pesa > tamanoMaximo) {
128
+ await avisar({
129
+ titulo: "Archivo demasiado pesado",
130
+ mensaje:
131
+ `«${doc.name}» pesa ${(pesa / (1024 * 1024)).toFixed(1)} MB y el tope son ` +
132
+ `${tamanoMaximo / (1024 * 1024)} MB. Elige uno más ligero o tómale una foto.`,
133
+ icono: "file-alert-outline",
134
+ });
135
+ continue;
136
+ }
137
+ const tipo = doc.mimeType ?? "application/octet-stream";
138
+ const esFoto = tipo.startsWith("image/");
139
+ const archivo = esFoto
140
+ ? await reducirImagenAdjunta({ uri: doc.uri, nombre: doc.name, tipo })
141
+ : { uri: doc.uri, nombre: doc.name, tipo };
142
+ elegidos.push({ ...archivo, esFoto });
143
+ }
144
+ return elegidos;
145
+ }
146
+
147
+ /**
148
+ * Pregunta de dónde (cámara, galería o un documento) y elige uno. Contesta `null` si se canceló en
149
+ * cualquier paso o el archivo no se puede subir (y ya se dijo por qué).
150
+ */
151
+ export async function elegirAdjunto({
152
+ titulo = "Subir un archivo",
153
+ permiso,
154
+ tipos,
155
+ }: {
156
+ titulo?: string;
157
+ permiso: PermisoDeCamara;
158
+ tipos?: string[];
159
+ }): Promise<ArchivoElegido | null> {
160
+ const origen = await mostrarDialogo<"camara" | "galeria" | "documento" | null>({
161
+ titulo,
162
+ icono: "paperclip",
163
+ botones: [
164
+ {
165
+ texto: "Tomar una foto",
166
+ icono: "camera-outline",
167
+ estilo: "principal",
168
+ valor: "camara",
169
+ },
170
+ {
171
+ texto: "De la galería",
172
+ icono: "image-outline",
173
+ estilo: "secundario",
174
+ valor: "galeria",
175
+ },
176
+ {
177
+ texto: "Un documento",
178
+ icono: "file-document-outline",
179
+ estilo: "secundario",
180
+ valor: "documento",
181
+ },
182
+ { texto: "Cancelar", estilo: "cancelar", valor: null },
183
+ ],
184
+ });
185
+ const elegidos =
186
+ origen === "camara"
187
+ ? await tomarFoto(permiso)
188
+ : origen === "galeria"
189
+ ? await elegirDeGaleria()
190
+ : origen === "documento"
191
+ ? await elegirDocumentos({ tipos })
192
+ : [];
193
+ return elegidos[0] ?? null;
194
+ }
@@ -0,0 +1,15 @@
1
+ // `@acvl/mobile-components/fotos`: tomar y elegir fotos y archivos. Va aparte de la entrada
2
+ // principal porque usa expo-image-picker, expo-image-manipulator, expo-document-picker y
3
+ // expo-file-system, que sólo instala la app que sube fotos.
4
+ export {
5
+ elegirAdjunto,
6
+ elegirDeGaleria,
7
+ elegirDocumentos,
8
+ pedirPermisoDeCamara,
9
+ TAMANO_MAXIMO_BYTES,
10
+ TIPOS_DE_ADJUNTO,
11
+ tomarFoto,
12
+ type ArchivoElegido,
13
+ type PermisoDeCamara,
14
+ } from "./elegir";
15
+ export { LADO_MAXIMO, reducirFoto, reducirImagenAdjunta } from "./reducir";
@@ -0,0 +1,87 @@
1
+ // Achicar las fotos en el teléfono antes de subirlas.
2
+ import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
3
+ import type { ImagePickerAsset } from "expo-image-picker";
4
+ import { Image } from "react-native";
5
+
6
+ /**
7
+ * Lado más largo con el que se sube una foto. Es el mismo con el que el backend la guarda
8
+ * (`MAX_DIMENSION` en `core/shared/fields.py`): arriba de eso los píxeles viajan para que el
9
+ * servidor los tire.
10
+ */
11
+ export const LADO_MAXIMO = 1920;
12
+
13
+ /** Calidad JPEG de la copia reducida; el backend recodifica a 82 al guardarla. */
14
+ const CALIDAD = 0.8;
15
+
16
+ /**
17
+ * La foto con su lado más largo en `LADO_MAXIMO` y codificada en JPEG.
18
+ *
19
+ * Una foto de la cámara (12 MP o más) pesa de 2 a 5 MB, y se sube con la señal que haya en el
20
+ * camino o en el patio: reducida pesa unos cientos de KB, sale antes y no se pasa del tope
21
+ * de tamaño del servidor. El servidor no pierde nada, porque de todos modos la guarda a 1920 px.
22
+ *
23
+ * Falla en blando: si no se puede reducir, se guarda la original —pesa más, pero es mejor que
24
+ * perder la foto que se acaba de tomar—.
25
+ */
26
+ export async function reducirFoto(asset: ImagePickerAsset): Promise<ImagePickerAsset> {
27
+ const { width, height } = asset;
28
+ const cabe = width > 0 && height > 0 && Math.max(width, height) <= LADO_MAXIMO;
29
+ if (cabe && asset.mimeType === "image/jpeg") return asset;
30
+
31
+ try {
32
+ const contexto = ImageManipulator.manipulate(asset.uri);
33
+ if (!cabe) {
34
+ // Se da un solo lado y el otro sale de la proporción.
35
+ contexto.resize(width >= height ? { width: LADO_MAXIMO } : { height: LADO_MAXIMO });
36
+ }
37
+ const imagen = await contexto.renderAsync();
38
+ const reducida = await imagen.saveAsync({ compress: CALIDAD, format: SaveFormat.JPEG });
39
+
40
+ const base = (asset.fileName ?? reducida.uri.split("/").pop() ?? "foto").replace(
41
+ /\.[^.]+$/,
42
+ "",
43
+ );
44
+ return {
45
+ ...asset,
46
+ uri: reducida.uri,
47
+ width: reducida.width,
48
+ height: reducida.height,
49
+ fileName: `${base}.jpg`,
50
+ mimeType: "image/jpeg",
51
+ fileSize: undefined,
52
+ };
53
+ } catch (error) {
54
+ console.warn("No se pudo reducir la foto; se sube la original.", error);
55
+ return asset;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Lo mismo para una imagen adjuntada como archivo (`DocumentPicker`), que no trae sus medidas: se
61
+ * leen del archivo y se reduce como una foto. Si no se pueden leer, se guarda la original.
62
+ */
63
+ export async function reducirImagenAdjunta(archivo: {
64
+ uri: string;
65
+ nombre: string;
66
+ tipo: string;
67
+ }): Promise<{ uri: string; nombre: string; tipo: string }> {
68
+ try {
69
+ const { width, height } = await Image.getSize(archivo.uri);
70
+ const reducida = await reducirFoto({
71
+ uri: archivo.uri,
72
+ width,
73
+ height,
74
+ fileName: archivo.nombre,
75
+ mimeType: archivo.tipo,
76
+ type: "image",
77
+ });
78
+ return {
79
+ uri: reducida.uri,
80
+ nombre: reducida.fileName ?? archivo.nombre,
81
+ tipo: reducida.mimeType ?? archivo.tipo,
82
+ };
83
+ } catch (error) {
84
+ console.warn("No se pudieron leer las medidas de la imagen; se sube la original.", error);
85
+ return archivo;
86
+ }
87
+ }
@@ -0,0 +1,102 @@
1
+ import { crearEstilos, renglon, tipografia } from "../tema";
2
+ import { Text } from "../texto";
3
+ import BottomSheet, {
4
+ BottomSheetBackdrop,
5
+ BottomSheetScrollView,
6
+ type BottomSheetBackdropProps,
7
+ } from "@gorhom/bottom-sheet";
8
+ import React, { useEffect, useRef } from "react";
9
+ import { BackHandler, useWindowDimensions, View } from "react-native";
10
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
11
+
12
+ /** Lo más que sube: arriba se sigue viendo un poco de la ficha, que dice dónde se está. */
13
+ const ALTO_MAXIMO = 0.85;
14
+
15
+ /**
16
+ * Una hoja que sube desde abajo sobre una ficha, para lo que se consulta y se cierra sin salir
17
+ * de ella: los hitos de un tramo, las excepciones, las comisiones. **Mide lo que
18
+ * su contenido**, hasta `ALTO_MAXIMO` de la pantalla; lo demás se recorre adentro.
19
+ *
20
+ * Se cierra deslizándola, tocando el velo de atrás o con atrás. Va **dentro de la pantalla** (no
21
+ * es un modal): en una ficha empujada no hay barra de pestañas que librar. Donde sí la hay (un
22
+ * mapa en una pestaña), la hoja es de la app.
23
+ *
24
+ * Está en `@acvl/mobile-components/hoja` y no en la entrada principal: usa `@gorhom/bottom-sheet`,
25
+ * que sólo instala la app que la usa.
26
+ */
27
+ export default function HojaInferior({
28
+ abierta,
29
+ onCerrar,
30
+ titulo,
31
+ subtitulo,
32
+ children,
33
+ }: {
34
+ abierta: boolean;
35
+ onCerrar: () => void;
36
+ titulo: string;
37
+ subtitulo?: string | null;
38
+ children: React.ReactNode;
39
+ }) {
40
+ const styles = useStyles();
41
+ const hoja = useRef<BottomSheet>(null);
42
+ const insets = useSafeAreaInsets();
43
+ const { height } = useWindowDimensions();
44
+
45
+ useEffect(() => {
46
+ if (abierta) hoja.current?.snapToIndex(0);
47
+ else hoja.current?.close();
48
+ }, [abierta]);
49
+
50
+ useEffect(() => {
51
+ if (!abierta) return;
52
+ const sub = BackHandler.addEventListener("hardwareBackPress", () => {
53
+ onCerrar();
54
+ return true;
55
+ });
56
+ return () => sub.remove();
57
+ }, [abierta, onCerrar]);
58
+
59
+ return (
60
+ <BottomSheet
61
+ ref={hoja}
62
+ index={-1}
63
+ enableDynamicSizing
64
+ maxDynamicContentSize={height * ALTO_MAXIMO}
65
+ enablePanDownToClose
66
+ onClose={onCerrar}
67
+ backdropComponent={(props: BottomSheetBackdropProps) => (
68
+ <BottomSheetBackdrop
69
+ {...props}
70
+ appearsOnIndex={0}
71
+ disappearsOnIndex={-1}
72
+ pressBehavior="close"
73
+ />
74
+ )}
75
+ backgroundStyle={styles.fondo}
76
+ handleIndicatorStyle={styles.agarradera}
77
+ >
78
+ <BottomSheetScrollView
79
+ contentContainerStyle={[styles.contenido, { paddingBottom: insets.bottom + 24 }]}
80
+ >
81
+ <View style={styles.cabecera}>
82
+ <Text style={styles.titulo}>{titulo}</Text>
83
+ {subtitulo ? <Text style={styles.subtitulo}>{subtitulo}</Text> : null}
84
+ </View>
85
+ {abierta ? children : null}
86
+ </BottomSheetScrollView>
87
+ </BottomSheet>
88
+ );
89
+ }
90
+
91
+ const useStyles = crearEstilos((c) => ({
92
+ fondo: {
93
+ backgroundColor: c.superficie,
94
+ borderWidth: c.esOscuro ? 1 : 0,
95
+ borderColor: c.secondaryLighter,
96
+ },
97
+ agarradera: { backgroundColor: c.secondaryLight },
98
+ contenido: { paddingHorizontal: 20, gap: 12 },
99
+ cabecera: { gap: 2 },
100
+ titulo: { ...renglon(tipografia.titulo), fontWeight: "900", color: c.texto },
101
+ subtitulo: { ...renglon(tipografia.secundario), color: c.secondary },
102
+ }));
@@ -0,0 +1,4 @@
1
+ // `@acvl/mobile-components/hoja`: lo que sube desde abajo sobre una ficha. Va aparte de la entrada
2
+ // principal porque usa `@gorhom/bottom-sheet`, que sólo instala la app que la usa.
3
+ export { default as HojaInferior } from "./HojaInferior";
4
+ export { useMasDeLaFicha, type ListaDeLaFicha } from "./useMasDeLaFicha";
@@ -0,0 +1,63 @@
1
+ import HojaInferior from "./HojaInferior";
2
+ import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
3
+ import React from "react";
4
+ import { FilaConContador, SeccionDeFicha } from "../listas/piezas";
5
+
6
+ /** Una lista de «Más»: su renglón con contador y lo que abre en la hoja. */
7
+ export interface ListaDeLaFicha {
8
+ clave: string;
9
+ titulo: string;
10
+ icono: React.ComponentProps<typeof MaterialCommunityIcons>["name"];
11
+ /** `null` mientras se cuenta. */
12
+ cuantos: number | null;
13
+ /** Lo que acompaña al número: «2 vencidas», «1 pendiente». */
14
+ detalle?: string | null;
15
+ /** Lo que enseña la hoja; sólo se monta abierta. */
16
+ contenido: React.ReactNode;
17
+ }
18
+
19
+ /**
20
+ * «Más» de una ficha (un tracto, un cliente): lo que cuelga de ella, un renglón con cuántos hay
21
+ * que abre su hoja desde abajo (`HojaInferior`). Devuelve la sección (`seccion`, dentro del
22
+ * scroll) y la hoja (`hoja`, encima de la ficha, en `PantallaDeFicha.encima`).
23
+ *
24
+ * Quien llama pasa sólo las listas que se ven (cada una con su permiso); sin ninguna, la sección
25
+ * no se pinta.
26
+ */
27
+ export function useMasDeLaFicha(listas: ListaDeLaFicha[], subtitulo?: string) {
28
+ // Lo que enseña se queda al cerrarla, para que su título no se vacíe mientras baja.
29
+ const [clave, setClave] = React.useState<string | null>(null);
30
+ const [abierta, setAbierta] = React.useState(false);
31
+ const cerrar = React.useCallback(() => setAbierta(false), []);
32
+ const elegida = listas.find((l) => l.clave === clave);
33
+
34
+ const seccion = listas.length ? (
35
+ <SeccionDeFicha titulo="Más">
36
+ {listas.map((l) => (
37
+ <FilaConContador
38
+ key={l.clave}
39
+ icono={l.icono}
40
+ etiqueta={l.titulo}
41
+ cuantos={l.cuantos}
42
+ detalle={l.detalle}
43
+ onPress={() => {
44
+ setClave(l.clave);
45
+ setAbierta(true);
46
+ }}
47
+ />
48
+ ))}
49
+ </SeccionDeFicha>
50
+ ) : null;
51
+
52
+ const hoja = (
53
+ <HojaInferior
54
+ abierta={abierta && Boolean(elegida)}
55
+ onCerrar={cerrar}
56
+ titulo={elegida?.titulo ?? ""}
57
+ subtitulo={subtitulo}
58
+ >
59
+ {abierta ? elegida?.contenido : null}
60
+ </HojaInferior>
61
+ );
62
+ return { seccion, hoja };
63
+ }