@acvl/mobile-components 0.0.1 → 0.0.3

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 (92) hide show
  1. package/README.md +6 -5
  2. package/lib/module/camara/CampoDeFotos.js +124 -0
  3. package/lib/module/camara/CampoDeFotos.js.map +1 -0
  4. package/lib/module/camara/elegir.js +104 -0
  5. package/lib/module/camara/elegir.js.map +1 -0
  6. package/lib/module/camara/index.js +10 -0
  7. package/lib/module/camara/index.js.map +1 -0
  8. package/lib/module/camara/reducir.js.map +1 -0
  9. package/lib/module/componentes/ContenidoDePantalla.js +2 -1
  10. package/lib/module/componentes/ContenidoDePantalla.js.map +1 -1
  11. package/lib/module/componentes/Tarjeta.js +19 -3
  12. package/lib/module/componentes/Tarjeta.js.map +1 -1
  13. package/lib/module/formularios/CampoDeSeleccion.js +27 -2
  14. package/lib/module/formularios/CampoDeSeleccion.js.map +1 -1
  15. package/lib/module/formularios/campos.js +10 -2
  16. package/lib/module/formularios/campos.js.map +1 -1
  17. package/lib/module/formularios/controlados.js +3 -1
  18. package/lib/module/formularios/controlados.js.map +1 -1
  19. package/lib/module/fotos/elegir.js +5 -78
  20. package/lib/module/fotos/elegir.js.map +1 -1
  21. package/lib/module/fotos/index.js +4 -3
  22. package/lib/module/fotos/index.js.map +1 -1
  23. package/lib/module/hoja/HojaDeLista.js +211 -0
  24. package/lib/module/hoja/HojaDeLista.js.map +1 -0
  25. package/lib/module/hoja/HojaInferior.js +5 -1
  26. package/lib/module/hoja/HojaInferior.js.map +1 -1
  27. package/lib/module/hoja/index.js +1 -0
  28. package/lib/module/hoja/index.js.map +1 -1
  29. package/lib/module/index.js +2 -1
  30. package/lib/module/index.js.map +1 -1
  31. package/lib/module/listas/HojaDeFiltros.js +21 -11
  32. package/lib/module/listas/HojaDeFiltros.js.map +1 -1
  33. package/lib/module/listas/piezas.js +79 -16
  34. package/lib/module/listas/piezas.js.map +1 -1
  35. package/lib/module/pantallas/PantallaDeHistorial.js +328 -0
  36. package/lib/module/pantallas/PantallaDeHistorial.js.map +1 -0
  37. package/lib/typescript/src/camara/CampoDeFotos.d.ts +30 -0
  38. package/lib/typescript/src/camara/CampoDeFotos.d.ts.map +1 -0
  39. package/lib/typescript/src/camara/elegir.d.ts +47 -0
  40. package/lib/typescript/src/camara/elegir.d.ts.map +1 -0
  41. package/lib/typescript/src/camara/index.d.ts +4 -0
  42. package/lib/typescript/src/camara/index.d.ts.map +1 -0
  43. package/lib/typescript/src/camara/reducir.d.ts.map +1 -0
  44. package/lib/typescript/src/componentes/ContenidoDePantalla.d.ts +6 -1
  45. package/lib/typescript/src/componentes/ContenidoDePantalla.d.ts.map +1 -1
  46. package/lib/typescript/src/componentes/Tarjeta.d.ts +11 -1
  47. package/lib/typescript/src/componentes/Tarjeta.d.ts.map +1 -1
  48. package/lib/typescript/src/formularios/CampoDeSeleccion.d.ts +4 -2
  49. package/lib/typescript/src/formularios/CampoDeSeleccion.d.ts.map +1 -1
  50. package/lib/typescript/src/formularios/campos.d.ts +8 -2
  51. package/lib/typescript/src/formularios/campos.d.ts.map +1 -1
  52. package/lib/typescript/src/formularios/controlados.d.ts.map +1 -1
  53. package/lib/typescript/src/fotos/elegir.d.ts +1 -31
  54. package/lib/typescript/src/fotos/elegir.d.ts.map +1 -1
  55. package/lib/typescript/src/fotos/index.d.ts +2 -2
  56. package/lib/typescript/src/fotos/index.d.ts.map +1 -1
  57. package/lib/typescript/src/hoja/HojaDeLista.d.ts +50 -0
  58. package/lib/typescript/src/hoja/HojaDeLista.d.ts.map +1 -0
  59. package/lib/typescript/src/hoja/HojaInferior.d.ts.map +1 -1
  60. package/lib/typescript/src/hoja/index.d.ts +1 -0
  61. package/lib/typescript/src/hoja/index.d.ts.map +1 -1
  62. package/lib/typescript/src/index.d.ts +3 -2
  63. package/lib/typescript/src/index.d.ts.map +1 -1
  64. package/lib/typescript/src/listas/HojaDeFiltros.d.ts +20 -4
  65. package/lib/typescript/src/listas/HojaDeFiltros.d.ts.map +1 -1
  66. package/lib/typescript/src/listas/piezas.d.ts +27 -5
  67. package/lib/typescript/src/listas/piezas.d.ts.map +1 -1
  68. package/lib/typescript/src/pantallas/PantallaDeHistorial.d.ts +65 -0
  69. package/lib/typescript/src/pantallas/PantallaDeHistorial.d.ts.map +1 -0
  70. package/package.json +6 -1
  71. package/src/camara/CampoDeFotos.tsx +132 -0
  72. package/src/camara/elegir.ts +113 -0
  73. package/src/camara/index.ts +15 -0
  74. package/src/componentes/ContenidoDePantalla.tsx +12 -2
  75. package/src/componentes/Tarjeta.tsx +16 -2
  76. package/src/formularios/CampoDeSeleccion.tsx +18 -4
  77. package/src/formularios/campos.tsx +14 -0
  78. package/src/formularios/controlados.tsx +3 -1
  79. package/src/fotos/elegir.ts +10 -88
  80. package/src/fotos/index.ts +4 -13
  81. package/src/hoja/HojaDeLista.tsx +214 -0
  82. package/src/hoja/HojaInferior.tsx +2 -1
  83. package/src/hoja/index.ts +1 -0
  84. package/src/index.ts +8 -0
  85. package/src/listas/HojaDeFiltros.tsx +39 -14
  86. package/src/listas/piezas.tsx +93 -17
  87. package/src/pantallas/PantallaDeHistorial.tsx +293 -0
  88. package/lib/module/fotos/reducir.js.map +0 -1
  89. package/lib/typescript/src/fotos/reducir.d.ts.map +0 -1
  90. /package/lib/module/{fotos → camara}/reducir.js +0 -0
  91. /package/lib/typescript/src/{fotos → camara}/reducir.d.ts +0 -0
  92. /package/src/{fotos → camara}/reducir.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acvl/mobile-components",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Librería de componentes de las apps móviles de ACV (Expo / React Native).",
5
5
  "homepage": "https://github.com/ACV-Logistics/acv-mobile-components#readme",
6
6
  "bugs": {
@@ -21,6 +21,11 @@
21
21
  "types": "./lib/typescript/src/index.d.ts",
22
22
  "default": "./lib/module/index.js"
23
23
  },
24
+ "./camara": {
25
+ "source": "./src/camara/index.ts",
26
+ "types": "./lib/typescript/src/camara/index.d.ts",
27
+ "default": "./lib/module/camara/index.js"
28
+ },
24
29
  "./fotos": {
25
30
  "source": "./src/fotos/index.ts",
26
31
  "types": "./lib/typescript/src/fotos/index.d.ts",
@@ -0,0 +1,132 @@
1
+ import React from "react";
2
+ import { Controller, type FieldValues } from "react-hook-form";
3
+ import { View } from "react-native";
4
+ import BotonGrande from "../componentes/BotonGrande";
5
+ import TiraDeFotos, { type FotoDeLaTira } from "../componentes/TiraDeFotos";
6
+ import { Campo } from "../formularios/campos";
7
+ import type { CampoAtado, SinValor } from "../formularios/controlados";
8
+ import { crearEstilos } from "../tema";
9
+ import {
10
+ elegirDeGaleria,
11
+ tomarFoto,
12
+ type AlTomarFoto,
13
+ type ArchivoElegido,
14
+ type PermisoDeCamara,
15
+ } from "./elegir";
16
+
17
+ /** El lado de cada miniatura: cabe un renglón de tres en un teléfono angosto. */
18
+ const LADO = 96;
19
+
20
+ /**
21
+ * Fotos que se capturan con un formulario y suben al guardarlo (la evidencia de un alta, el cierre
22
+ * de una revisión): sus miniaturas, cada una con su «×» y abriendo el visor, y debajo **Tomar foto**
23
+ * y **De la galería**. Lo que se guarda es la lista de `ArchivoElegido` ya reducidos; subirlos es
24
+ * de quien guarda.
25
+ *
26
+ * Las que suben al momento, sin esperar a un formulario (las de una ficha que ya existe), no van
27
+ * aquí: ésas son una `TiraDeFotos` con sus botones.
28
+ */
29
+ export function CampoDeFotos({
30
+ etiqueta,
31
+ ayuda,
32
+ error,
33
+ valor,
34
+ onCambiar,
35
+ permiso,
36
+ varias = true,
37
+ alTomar,
38
+ disabled,
39
+ }: {
40
+ etiqueta?: string;
41
+ ayuda?: string;
42
+ error?: string;
43
+ valor: ArchivoElegido[];
44
+ onCambiar: (valor: ArchivoElegido[]) => void;
45
+ /** Para qué se pide la cámara, si todavía no hay permiso. */
46
+ permiso: PermisoDeCamara;
47
+ /** Con `false`, una nueva reemplaza a la que había. */
48
+ varias?: boolean;
49
+ /** Lo que se hace con la original de la cámara (guardarla en la galería): ver `AlTomarFoto`. */
50
+ alTomar?: AlTomarFoto;
51
+ disabled?: boolean;
52
+ }) {
53
+ const styles = useStyles();
54
+ // Reducir una foto tarda (se decodifica completa): el botón gira mientras tanto.
55
+ const [eligiendo, setEligiendo] = React.useState<"camara" | "galeria" | null>(null);
56
+
57
+ const agregar = async (de: "camara" | "galeria") => {
58
+ setEligiendo(de);
59
+ try {
60
+ const nuevas =
61
+ de === "camara"
62
+ ? await tomarFoto(permiso, { alTomar })
63
+ : await elegirDeGaleria({ varias });
64
+ if (nuevas.length) onCambiar(varias ? [...valor, ...nuevas] : nuevas.slice(0, 1));
65
+ } finally {
66
+ setEligiendo(null);
67
+ }
68
+ };
69
+
70
+ const fotos: FotoDeLaTira[] = valor.map((f, i) => ({
71
+ llave: `${f.uri}-${i}`,
72
+ esFoto: f.esFoto,
73
+ miniatura: { uri: f.uri },
74
+ nombre: f.nombre,
75
+ onQuitar: disabled ? undefined : () => onCambiar(valor.filter((_, j) => j !== i)),
76
+ }));
77
+
78
+ return (
79
+ <Campo etiqueta={etiqueta} ayuda={ayuda} error={error}>
80
+ <View style={styles.contenido}>
81
+ <TiraDeFotos fotos={fotos} lado={LADO} cuadricula />
82
+ <View style={styles.botones}>
83
+ <BotonGrande
84
+ icono="camera-outline"
85
+ modo="contorno"
86
+ cargando={eligiendo === "camara"}
87
+ disabled={disabled || eligiendo !== null}
88
+ onPress={() => void agregar("camara")}
89
+ >
90
+ Tomar foto
91
+ </BotonGrande>
92
+ <BotonGrande
93
+ icono="image-outline"
94
+ modo="contorno"
95
+ cargando={eligiendo === "galeria"}
96
+ disabled={disabled || eligiendo !== null}
97
+ onPress={() => void agregar("galeria")}
98
+ >
99
+ De la galería
100
+ </BotonGrande>
101
+ </View>
102
+ </View>
103
+ </Campo>
104
+ );
105
+ }
106
+
107
+ /** `CampoDeFotos` atado a react-hook-form: el valor es la lista de `ArchivoElegido`. */
108
+ export function FotosControladas<T extends FieldValues>({
109
+ control,
110
+ name,
111
+ ...props
112
+ }: CampoAtado<T> & SinValor<React.ComponentProps<typeof CampoDeFotos>>) {
113
+ return (
114
+ <Controller
115
+ name={name}
116
+ control={control}
117
+ render={({ field: { value, onChange }, fieldState }) => (
118
+ <CampoDeFotos
119
+ {...props}
120
+ valor={(value as ArchivoElegido[] | null) ?? []}
121
+ onCambiar={onChange}
122
+ error={fieldState.error?.message}
123
+ />
124
+ )}
125
+ />
126
+ );
127
+ }
128
+
129
+ const useStyles = crearEstilos(() => ({
130
+ contenido: { gap: 12 },
131
+ botones: { gap: 8 },
132
+ }));
@@ -0,0 +1,113 @@
1
+ // Tomar una foto o elegirla de la galería para subirla. Contesta lo elegido ya listo para el
2
+ // `FormData` (reducido a 1920 px); qué se hace con él —subirlo en el momento, guardarlo para una
3
+ // cola— es de quien llama. Los documentos (un PDF) están en `fotos/`, que pide expo-document-picker.
4
+ import * as ImagePicker from "expo-image-picker";
5
+ import { avisar } from "../dialogos/dialogos";
6
+ import { reducirFoto } from "./reducir";
7
+
8
+ /** Lo elegido: dónde está en el teléfono, su nombre, su tipo y si es una foto. */
9
+ export interface ArchivoElegido {
10
+ uri: string;
11
+ nombre: string;
12
+ tipo: string;
13
+ esFoto: boolean;
14
+ }
15
+
16
+ /** Para qué se pide la cámara, dicho a quien la usa. */
17
+ export interface PermisoDeCamara {
18
+ /** «tomar las fotos de evidencia»: va en «Para …, activa la cámara en los ajustes». */
19
+ para: string;
20
+ /**
21
+ * Lo que se explica antes del diálogo del sistema la primera vez: por qué y cuándo se usa. Sin
22
+ * esto, va directo al del sistema.
23
+ */
24
+ explicacion?: string;
25
+ }
26
+
27
+ /**
28
+ * Lo que se hace con la foto **original** de la cámara antes de reducirla: guardarla en la galería
29
+ * del teléfono, por ejemplo, como respaldo de quien la tomó si la subida falla. Lo que falle aquí
30
+ * no detiene la foto.
31
+ */
32
+ export type AlTomarFoto = (original: ImagePicker.ImagePickerAsset) => unknown;
33
+
34
+ /**
35
+ * El permiso de la cámara. Si ya se negó para siempre, se dice dónde activarlo; si nunca se ha
36
+ * pedido, primero la explicación de la app (si hay) y luego el diálogo del sistema.
37
+ */
38
+ export async function pedirPermisoDeCamara({
39
+ para,
40
+ explicacion,
41
+ }: PermisoDeCamara): Promise<boolean> {
42
+ const actual = await ImagePicker.getCameraPermissionsAsync();
43
+ if (actual.granted) return true;
44
+ if (!actual.canAskAgain) {
45
+ await avisar({
46
+ titulo: "Sin permiso de cámara",
47
+ mensaje: `Para ${para}, activa la cámara para la app en los ajustes del teléfono.`,
48
+ icono: "camera-off-outline",
49
+ });
50
+ return false;
51
+ }
52
+ if (explicacion) {
53
+ await avisar({
54
+ titulo: "La cámara",
55
+ mensaje: explicacion,
56
+ icono: "camera-outline",
57
+ cancelable: false,
58
+ });
59
+ }
60
+ return (await ImagePicker.requestCameraPermissionsAsync()).granted;
61
+ }
62
+
63
+ /**
64
+ * Las imágenes elegidas, reducidas a 1920 px en JPEG (`reducirFoto`). Una por una y no con
65
+ * `Promise.all`: cada foto se decodifica completa en memoria, y veinte a la vez desde la galería
66
+ * pueden tumbar la app en un Android modesto.
67
+ */
68
+ export async function deFotos(assets: ImagePicker.ImagePickerAsset[]): Promise<ArchivoElegido[]> {
69
+ const elegidos: ArchivoElegido[] = [];
70
+ for (const asset of assets) {
71
+ const reducida = await reducirFoto(asset);
72
+ elegidos.push({
73
+ uri: reducida.uri,
74
+ nombre: reducida.fileName ?? "foto.jpg",
75
+ tipo: reducida.mimeType ?? "image/jpeg",
76
+ esFoto: true,
77
+ });
78
+ }
79
+ return elegidos;
80
+ }
81
+
82
+ /** Una foto con la cámara, con su permiso. Vacío si no hubo permiso o se canceló. */
83
+ export async function tomarFoto(
84
+ permiso: PermisoDeCamara,
85
+ { alTomar }: { alTomar?: AlTomarFoto } = {},
86
+ ): Promise<ArchivoElegido[]> {
87
+ if (!(await pedirPermisoDeCamara(permiso))) return [];
88
+ const resultado = await ImagePicker.launchCameraAsync({ mediaTypes: ["images"], quality: 1 });
89
+ if (resultado.canceled) return [];
90
+ if (alTomar) {
91
+ for (const original of resultado.assets) {
92
+ try {
93
+ await alTomar(original);
94
+ } catch (error) {
95
+ console.warn("No se pudo guardar la original de la foto.", error);
96
+ }
97
+ }
98
+ }
99
+ return deFotos(resultado.assets);
100
+ }
101
+
102
+ /**
103
+ * De la galería. En Android el selector de fotos del sistema no pide permiso de almacenamiento,
104
+ * así que no hay explicación previa que dar.
105
+ */
106
+ export async function elegirDeGaleria({ varias = false } = {}): Promise<ArchivoElegido[]> {
107
+ const resultado = await ImagePicker.launchImageLibraryAsync({
108
+ mediaTypes: ["images"],
109
+ allowsMultipleSelection: varias,
110
+ quality: 1,
111
+ });
112
+ return resultado.canceled ? [] : deFotos(resultado.assets);
113
+ }
@@ -0,0 +1,15 @@
1
+ // `@acvl/mobile-components/camara`: tomar fotos o elegirlas de la galería, y el campo de fotos de un
2
+ // formulario. Va aparte de la entrada principal porque usa expo-image-picker y
3
+ // expo-image-manipulator, que sólo instala la app que sube fotos; y aparte de `fotos` porque ésa
4
+ // además elige documentos, con expo-document-picker.
5
+ export { CampoDeFotos, FotosControladas } from "./CampoDeFotos";
6
+ export {
7
+ deFotos,
8
+ elegirDeGaleria,
9
+ pedirPermisoDeCamara,
10
+ tomarFoto,
11
+ type AlTomarFoto,
12
+ type ArchivoElegido,
13
+ type PermisoDeCamara,
14
+ } from "./elegir";
15
+ export { LADO_MAXIMO, reducirFoto, reducirImagenAdjunta } from "./reducir";
@@ -6,6 +6,11 @@ import { crearEstilos } from "../tema";
6
6
 
7
7
  interface Props {
8
8
  children: React.ReactNode;
9
+ /**
10
+ * El aire de arriba. Una lista que empieza con `CabeceraDeLista` pasa `0`: la cabecera ya trae
11
+ * el suyo, y los dos juntos dejaban un hueco sobre el buscador.
12
+ */
13
+ paddingTop?: number;
9
14
  paddingBottom?: number;
10
15
  /**
11
16
  * Cuando el hijo ya maneja el teclado por su cuenta (p. ej. un `KeyboardAwareScrollView`),
@@ -20,7 +25,12 @@ const BASE_PADDING = 20;
20
25
  * El cuerpo de una pantalla debajo de su header: el fondo, el margen a los lados (el doble en una
21
26
  * tableta, más lo que pida la muesca) y, por omisión, que esquive el teclado.
22
27
  */
23
- const ContenidoDePantalla = ({ paddingBottom, avoidKeyboard = true, children }: Props) => {
28
+ const ContenidoDePantalla = ({
29
+ paddingTop = BASE_PADDING,
30
+ paddingBottom,
31
+ avoidKeyboard = true,
32
+ children,
33
+ }: Props) => {
24
34
  const styles = useStyles();
25
35
  const insets = useSafeAreaInsets();
26
36
  const { width } = useWindowDimensions();
@@ -28,7 +38,7 @@ const ContenidoDePantalla = ({ paddingBottom, avoidKeyboard = true, children }:
28
38
  const containerStyle = [
29
39
  styles.container,
30
40
  {
31
- paddingTop: 20,
41
+ paddingTop,
32
42
  paddingLeft: horizontalPadding + insets.left,
33
43
  paddingRight: horizontalPadding + insets.right,
34
44
  paddingBottom: paddingBottom ?? 0,
@@ -1,6 +1,14 @@
1
+ import React from "react";
1
2
  import { Pressable, View, type StyleProp, type ViewStyle } from "react-native";
2
3
  import { crearEstilos } from "../tema";
3
4
 
5
+ /**
6
+ * Si lo de adentro está en una hoja que sube desde abajo (`HojaInferior`, `HojaDeLista`). La hoja
7
+ * es de la superficie, igual que una tarjeta: ahí la tarjeta va del color del fondo, o no se
8
+ * distinguiría un renglón del siguiente. Lo ponen las hojas; las pantallas no lo tocan.
9
+ */
10
+ export const EnHoja = React.createContext(false);
11
+
4
12
  /**
5
13
  * La tarjeta de la casa, sola: la superficie, sus esquinas y su aire, con lo que se le ponga
6
14
  * adentro. Es de lo que están hechas `TarjetaDeLista` y las fichas; una pantalla la usa para lo que
@@ -8,6 +16,9 @@ import { crearEstilos } from "../tema";
8
16
  *
9
17
  * **Sin sombra**: en oscuro casi no se ve, así que lo que separa la tarjeta del fondo es el color
10
18
  * de la superficie y, en oscuro, un borde. Con `onPress` se toca y se atenúa al presionarla.
19
+ *
20
+ * Dentro de una hoja (`EnHoja`) va del color del fondo, así las listas de una pantalla se pueden
21
+ * enseñar también en una hoja sin otra versión de su renglón.
11
22
  */
12
23
  export default function Tarjeta({
13
24
  children,
@@ -19,12 +30,14 @@ export default function Tarjeta({
19
30
  style?: StyleProp<ViewStyle>;
20
31
  }) {
21
32
  const styles = useStyles();
22
- if (!onPress) return <View style={[styles.tarjeta, style]}>{children}</View>;
33
+ const enHoja = React.useContext(EnHoja);
34
+ const base = [styles.tarjeta, enHoja && styles.enHoja];
35
+ if (!onPress) return <View style={[base, style]}>{children}</View>;
23
36
  return (
24
37
  <Pressable
25
38
  onPress={onPress}
26
39
  accessibilityRole="button"
27
- style={({ pressed }) => [styles.tarjeta, style, pressed && styles.presionada]}
40
+ style={({ pressed }) => [base, style, pressed && styles.presionada]}
28
41
  >
29
42
  {children}
30
43
  </Pressable>
@@ -40,5 +53,6 @@ const useStyles = crearEstilos((c) => ({
40
53
  borderWidth: c.esOscuro ? 1 : 0,
41
54
  borderColor: c.secondaryLighter,
42
55
  },
56
+ enHoja: { backgroundColor: c.fondo },
43
57
  presionada: { opacity: 0.7 },
44
58
  }));
@@ -1,4 +1,6 @@
1
1
  import { View } from "react-native";
2
+ import { crearEstilos, renglon, tipografia } from "../tema";
3
+ import { Text } from "../texto";
2
4
  import { ErrorDelCampo } from "./campos";
3
5
  import SelectorDeOpcion, {
4
6
  SelectorDeVarias,
@@ -18,11 +20,14 @@ import SelectorDeOpcion, {
18
20
  */
19
21
  export default function CampoDeSeleccion<T extends Opcion>({
20
22
  error,
23
+ ayuda,
21
24
  ...props
22
- }: PropsDeSelectorDeOpcion<T> & { error?: string }) {
25
+ }: PropsDeSelectorDeOpcion<T> & { error?: string; ayuda?: string }) {
26
+ const styles = useStyles();
23
27
  return (
24
- <View>
28
+ <View style={styles.campo}>
25
29
  <SelectorDeOpcion<T> {...props} />
30
+ {ayuda ? <Text style={styles.ayuda}>{ayuda}</Text> : null}
26
31
  <ErrorDelCampo error={error} />
27
32
  </View>
28
33
  );
@@ -35,12 +40,21 @@ export default function CampoDeSeleccion<T extends Opcion>({
35
40
  */
36
41
  export function CampoDeVarias<T extends Opcion>({
37
42
  error,
43
+ ayuda,
38
44
  ...props
39
- }: PropsDeSelectorDeVarias<T> & { error?: string }) {
45
+ }: PropsDeSelectorDeVarias<T> & { error?: string; ayuda?: string }) {
46
+ const styles = useStyles();
40
47
  return (
41
- <View>
48
+ <View style={styles.campo}>
42
49
  <SelectorDeVarias<T> {...props} />
50
+ {ayuda ? <Text style={styles.ayuda}>{ayuda}</Text> : null}
43
51
  <ErrorDelCampo error={error} />
44
52
  </View>
45
53
  );
46
54
  }
55
+
56
+ // La ayuda con el mismo renglón y color que la de los demás campos (`Campo`).
57
+ const useStyles = crearEstilos((c) => ({
58
+ campo: { gap: 6 },
59
+ ayuda: { ...renglon(tipografia.secundario), color: c.secondary },
60
+ }));
@@ -117,6 +117,8 @@ export function CampoDeFechaHora({
117
117
  error,
118
118
  opcional,
119
119
  ayuda,
120
+ desde,
121
+ hasta,
120
122
  }: {
121
123
  etiqueta: string;
122
124
  valor: Date | null;
@@ -124,6 +126,9 @@ export function CampoDeFechaHora({
124
126
  error?: string;
125
127
  opcional?: boolean;
126
128
  ayuda?: string;
129
+ /** El primer y el último momento que el selector deja elegir, como en `CampoDeFecha`. */
130
+ desde?: Date;
131
+ hasta?: Date;
127
132
  }) {
128
133
  const c = usePaleta();
129
134
  const styles = useStyles();
@@ -185,6 +190,8 @@ export function CampoDeFechaHora({
185
190
  value={base}
186
191
  mode={eligiendo}
187
192
  is24Hour
193
+ minimumDate={desde}
194
+ maximumDate={hasta}
188
195
  onChange={(evento, elegida) => {
189
196
  const paso = eligiendo;
190
197
  setEligiendo(null);
@@ -261,6 +268,8 @@ export function CampoDeFecha({
261
268
  error,
262
269
  opcional,
263
270
  ayuda,
271
+ desde,
272
+ hasta,
264
273
  }: {
265
274
  etiqueta: string;
266
275
  valor: string | null;
@@ -268,6 +277,9 @@ export function CampoDeFecha({
268
277
  error?: string;
269
278
  opcional?: boolean;
270
279
  ayuda?: string;
280
+ /** Los días que el selector deja elegir: una recepción no puede ser de mañana (`hasta`). */
281
+ desde?: Date;
282
+ hasta?: Date;
271
283
  }) {
272
284
  const c = usePaleta();
273
285
  const styles = useStyles();
@@ -310,6 +322,8 @@ export function CampoDeFecha({
310
322
  <DateTimePicker
311
323
  value={fecha?.toDate() ?? new Date()}
312
324
  mode="date"
325
+ minimumDate={desde}
326
+ maximumDate={hasta}
313
327
  onChange={(evento, elegida) => {
314
328
  setEligiendo(false);
315
329
  if (evento.type === "set" && elegida) {
@@ -41,7 +41,9 @@ export function TextoControlado<T extends FieldValues>({
41
41
  render={({ field: { value, onChange }, fieldState }) => (
42
42
  <CampoDeTexto
43
43
  {...props}
44
- valor={value ?? ""}
44
+ // Un número del formulario (un conteo, un monto) también se escribe aquí: el
45
+ // TextInput sólo sabe de texto.
46
+ valor={value == null ? "" : String(value)}
45
47
  onCambiar={onChange}
46
48
  error={fieldState.error?.message}
47
49
  />
@@ -1,19 +1,16 @@
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.
1
+ // Elegir un documento para subirlo, o preguntar de dónde (cámara, galería o documento). Contesta lo
2
+ // elegido ya listo para el `FormData`; qué se hace con él es de quien llama. La cámara y la
3
+ // galería solas están en `camara/`, que no pide expo-document-picker.
4
4
  import * as DocumentPicker from "expo-document-picker";
5
5
  import { File } from "expo-file-system";
6
- import * as ImagePicker from "expo-image-picker";
6
+ import {
7
+ elegirDeGaleria,
8
+ tomarFoto,
9
+ type ArchivoElegido,
10
+ type PermisoDeCamara,
11
+ } from "../camara/elegir";
12
+ import { reducirImagenAdjunta } from "../camara/reducir";
7
13
  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
14
 
18
15
  /** El tope de un archivo en el servidor (`ConArchivoSubido.TAMANO_MAXIMO`): 25 MB. */
19
16
  export const TAMANO_MAXIMO_BYTES = 25 * 1024 * 1024;
@@ -30,81 +27,6 @@ export const TIPOS_DE_ADJUNTO = [
30
27
  "text/csv",
31
28
  ];
32
29
 
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
30
  /**
109
31
  * Documentos (un PDF, una foto ya escaneada). Uno más pesado que el tope del servidor se rechaza al
110
32
  * subirlo, así que se avisa aquí y se deja fuera. Una imagen adjuntada como archivo también se
@@ -1,15 +1,6 @@
1
1
  // `@acvl/mobile-components/fotos`: tomar y elegir fotos y archivos. Va aparte de la entrada
2
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";
3
+ // expo-file-system, que sólo instala la app que sube archivos. Lo de la cámara sola (sin
4
+ // documentos) está también en `camara`.
5
+ export * from "../camara";
6
+ export { elegirAdjunto, elegirDocumentos, TAMANO_MAXIMO_BYTES, TIPOS_DE_ADJUNTO } from "./elegir";