@acvl/mobile-components 0.0.1 → 0.0.2
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.
- package/README.md +6 -5
- package/lib/module/camara/CampoDeFotos.js +124 -0
- package/lib/module/camara/CampoDeFotos.js.map +1 -0
- package/lib/module/camara/elegir.js +104 -0
- package/lib/module/camara/elegir.js.map +1 -0
- package/lib/module/camara/index.js +10 -0
- package/lib/module/camara/index.js.map +1 -0
- package/lib/module/camara/reducir.js.map +1 -0
- package/lib/module/componentes/Tarjeta.js +19 -3
- package/lib/module/componentes/Tarjeta.js.map +1 -1
- package/lib/module/formularios/CampoDeSeleccion.js +27 -2
- package/lib/module/formularios/CampoDeSeleccion.js.map +1 -1
- package/lib/module/formularios/campos.js +10 -2
- package/lib/module/formularios/campos.js.map +1 -1
- package/lib/module/formularios/controlados.js +3 -1
- package/lib/module/formularios/controlados.js.map +1 -1
- package/lib/module/fotos/elegir.js +5 -78
- package/lib/module/fotos/elegir.js.map +1 -1
- package/lib/module/fotos/index.js +4 -3
- package/lib/module/fotos/index.js.map +1 -1
- package/lib/module/hoja/HojaDeLista.js +211 -0
- package/lib/module/hoja/HojaDeLista.js.map +1 -0
- package/lib/module/hoja/HojaInferior.js +5 -1
- package/lib/module/hoja/HojaInferior.js.map +1 -1
- package/lib/module/hoja/index.js +1 -0
- package/lib/module/hoja/index.js.map +1 -1
- package/lib/module/index.js +2 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/listas/HojaDeFiltros.js +21 -11
- package/lib/module/listas/HojaDeFiltros.js.map +1 -1
- package/lib/module/listas/piezas.js +79 -16
- package/lib/module/listas/piezas.js.map +1 -1
- package/lib/module/pantallas/PantallaDeHistorial.js +328 -0
- package/lib/module/pantallas/PantallaDeHistorial.js.map +1 -0
- package/lib/typescript/src/camara/CampoDeFotos.d.ts +30 -0
- package/lib/typescript/src/camara/CampoDeFotos.d.ts.map +1 -0
- package/lib/typescript/src/camara/elegir.d.ts +47 -0
- package/lib/typescript/src/camara/elegir.d.ts.map +1 -0
- package/lib/typescript/src/camara/index.d.ts +4 -0
- package/lib/typescript/src/camara/index.d.ts.map +1 -0
- package/lib/typescript/src/camara/reducir.d.ts.map +1 -0
- package/lib/typescript/src/componentes/Tarjeta.d.ts +11 -1
- package/lib/typescript/src/componentes/Tarjeta.d.ts.map +1 -1
- package/lib/typescript/src/formularios/CampoDeSeleccion.d.ts +4 -2
- package/lib/typescript/src/formularios/CampoDeSeleccion.d.ts.map +1 -1
- package/lib/typescript/src/formularios/campos.d.ts +8 -2
- package/lib/typescript/src/formularios/campos.d.ts.map +1 -1
- package/lib/typescript/src/formularios/controlados.d.ts.map +1 -1
- package/lib/typescript/src/fotos/elegir.d.ts +1 -31
- package/lib/typescript/src/fotos/elegir.d.ts.map +1 -1
- package/lib/typescript/src/fotos/index.d.ts +2 -2
- package/lib/typescript/src/fotos/index.d.ts.map +1 -1
- package/lib/typescript/src/hoja/HojaDeLista.d.ts +50 -0
- package/lib/typescript/src/hoja/HojaDeLista.d.ts.map +1 -0
- package/lib/typescript/src/hoja/HojaInferior.d.ts.map +1 -1
- package/lib/typescript/src/hoja/index.d.ts +1 -0
- package/lib/typescript/src/hoja/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/listas/HojaDeFiltros.d.ts +20 -4
- package/lib/typescript/src/listas/HojaDeFiltros.d.ts.map +1 -1
- package/lib/typescript/src/listas/piezas.d.ts +27 -5
- package/lib/typescript/src/listas/piezas.d.ts.map +1 -1
- package/lib/typescript/src/pantallas/PantallaDeHistorial.d.ts +65 -0
- package/lib/typescript/src/pantallas/PantallaDeHistorial.d.ts.map +1 -0
- package/package.json +6 -1
- package/src/camara/CampoDeFotos.tsx +132 -0
- package/src/camara/elegir.ts +113 -0
- package/src/camara/index.ts +15 -0
- package/src/componentes/Tarjeta.tsx +16 -2
- package/src/formularios/CampoDeSeleccion.tsx +18 -4
- package/src/formularios/campos.tsx +14 -0
- package/src/formularios/controlados.tsx +3 -1
- package/src/fotos/elegir.ts +10 -88
- package/src/fotos/index.ts +4 -13
- package/src/hoja/HojaDeLista.tsx +214 -0
- package/src/hoja/HojaInferior.tsx +2 -1
- package/src/hoja/index.ts +1 -0
- package/src/index.ts +8 -0
- package/src/listas/HojaDeFiltros.tsx +39 -14
- package/src/listas/piezas.tsx +93 -17
- package/src/pantallas/PantallaDeHistorial.tsx +293 -0
- package/lib/module/fotos/reducir.js.map +0 -1
- package/lib/typescript/src/fotos/reducir.d.ts.map +0 -1
- /package/lib/module/{fotos → camara}/reducir.js +0 -0
- /package/lib/typescript/src/{fotos → camara}/reducir.d.ts +0 -0
- /package/src/{fotos → camara}/reducir.ts +0 -0
|
@@ -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";
|
|
@@ -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
|
-
|
|
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 }) => [
|
|
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
|
-
|
|
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
|
/>
|
package/src/fotos/elegir.ts
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
|
-
// Elegir
|
|
2
|
-
// elegido ya listo para el `FormData
|
|
3
|
-
//
|
|
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
|
|
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
|
package/src/fotos/index.ts
CHANGED
|
@@ -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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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";
|