@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.
Files changed (87) 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/Tarjeta.js +19 -3
  10. package/lib/module/componentes/Tarjeta.js.map +1 -1
  11. package/lib/module/formularios/CampoDeSeleccion.js +27 -2
  12. package/lib/module/formularios/CampoDeSeleccion.js.map +1 -1
  13. package/lib/module/formularios/campos.js +10 -2
  14. package/lib/module/formularios/campos.js.map +1 -1
  15. package/lib/module/formularios/controlados.js +3 -1
  16. package/lib/module/formularios/controlados.js.map +1 -1
  17. package/lib/module/fotos/elegir.js +5 -78
  18. package/lib/module/fotos/elegir.js.map +1 -1
  19. package/lib/module/fotos/index.js +4 -3
  20. package/lib/module/fotos/index.js.map +1 -1
  21. package/lib/module/hoja/HojaDeLista.js +211 -0
  22. package/lib/module/hoja/HojaDeLista.js.map +1 -0
  23. package/lib/module/hoja/HojaInferior.js +5 -1
  24. package/lib/module/hoja/HojaInferior.js.map +1 -1
  25. package/lib/module/hoja/index.js +1 -0
  26. package/lib/module/hoja/index.js.map +1 -1
  27. package/lib/module/index.js +2 -1
  28. package/lib/module/index.js.map +1 -1
  29. package/lib/module/listas/HojaDeFiltros.js +21 -11
  30. package/lib/module/listas/HojaDeFiltros.js.map +1 -1
  31. package/lib/module/listas/piezas.js +79 -16
  32. package/lib/module/listas/piezas.js.map +1 -1
  33. package/lib/module/pantallas/PantallaDeHistorial.js +328 -0
  34. package/lib/module/pantallas/PantallaDeHistorial.js.map +1 -0
  35. package/lib/typescript/src/camara/CampoDeFotos.d.ts +30 -0
  36. package/lib/typescript/src/camara/CampoDeFotos.d.ts.map +1 -0
  37. package/lib/typescript/src/camara/elegir.d.ts +47 -0
  38. package/lib/typescript/src/camara/elegir.d.ts.map +1 -0
  39. package/lib/typescript/src/camara/index.d.ts +4 -0
  40. package/lib/typescript/src/camara/index.d.ts.map +1 -0
  41. package/lib/typescript/src/camara/reducir.d.ts.map +1 -0
  42. package/lib/typescript/src/componentes/Tarjeta.d.ts +11 -1
  43. package/lib/typescript/src/componentes/Tarjeta.d.ts.map +1 -1
  44. package/lib/typescript/src/formularios/CampoDeSeleccion.d.ts +4 -2
  45. package/lib/typescript/src/formularios/CampoDeSeleccion.d.ts.map +1 -1
  46. package/lib/typescript/src/formularios/campos.d.ts +8 -2
  47. package/lib/typescript/src/formularios/campos.d.ts.map +1 -1
  48. package/lib/typescript/src/formularios/controlados.d.ts.map +1 -1
  49. package/lib/typescript/src/fotos/elegir.d.ts +1 -31
  50. package/lib/typescript/src/fotos/elegir.d.ts.map +1 -1
  51. package/lib/typescript/src/fotos/index.d.ts +2 -2
  52. package/lib/typescript/src/fotos/index.d.ts.map +1 -1
  53. package/lib/typescript/src/hoja/HojaDeLista.d.ts +50 -0
  54. package/lib/typescript/src/hoja/HojaDeLista.d.ts.map +1 -0
  55. package/lib/typescript/src/hoja/HojaInferior.d.ts.map +1 -1
  56. package/lib/typescript/src/hoja/index.d.ts +1 -0
  57. package/lib/typescript/src/hoja/index.d.ts.map +1 -1
  58. package/lib/typescript/src/index.d.ts +3 -2
  59. package/lib/typescript/src/index.d.ts.map +1 -1
  60. package/lib/typescript/src/listas/HojaDeFiltros.d.ts +20 -4
  61. package/lib/typescript/src/listas/HojaDeFiltros.d.ts.map +1 -1
  62. package/lib/typescript/src/listas/piezas.d.ts +27 -5
  63. package/lib/typescript/src/listas/piezas.d.ts.map +1 -1
  64. package/lib/typescript/src/pantallas/PantallaDeHistorial.d.ts +65 -0
  65. package/lib/typescript/src/pantallas/PantallaDeHistorial.d.ts.map +1 -0
  66. package/package.json +6 -1
  67. package/src/camara/CampoDeFotos.tsx +132 -0
  68. package/src/camara/elegir.ts +113 -0
  69. package/src/camara/index.ts +15 -0
  70. package/src/componentes/Tarjeta.tsx +16 -2
  71. package/src/formularios/CampoDeSeleccion.tsx +18 -4
  72. package/src/formularios/campos.tsx +14 -0
  73. package/src/formularios/controlados.tsx +3 -1
  74. package/src/fotos/elegir.ts +10 -88
  75. package/src/fotos/index.ts +4 -13
  76. package/src/hoja/HojaDeLista.tsx +214 -0
  77. package/src/hoja/HojaInferior.tsx +2 -1
  78. package/src/hoja/index.ts +1 -0
  79. package/src/index.ts +8 -0
  80. package/src/listas/HojaDeFiltros.tsx +39 -14
  81. package/src/listas/piezas.tsx +93 -17
  82. package/src/pantallas/PantallaDeHistorial.tsx +293 -0
  83. package/lib/module/fotos/reducir.js.map +0 -1
  84. package/lib/typescript/src/fotos/reducir.d.ts.map +0 -1
  85. /package/lib/module/{fotos → camara}/reducir.js +0 -0
  86. /package/lib/typescript/src/{fotos → camara}/reducir.d.ts +0 -0
  87. /package/src/{fotos → camara}/reducir.ts +0 -0
@@ -0,0 +1,214 @@
1
+ import BottomSheet, {
2
+ BottomSheetBackdrop,
3
+ BottomSheetFlatList,
4
+ BottomSheetTextInput,
5
+ type BottomSheetBackdropProps,
6
+ } from "@gorhom/bottom-sheet";
7
+ import React, { useEffect, useRef, useState } from "react";
8
+ import { ActivityIndicator, BackHandler, View } from "react-native";
9
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
10
+ import BotonGrande from "../componentes/BotonGrande";
11
+ import { EnHoja } from "../componentes/Tarjeta";
12
+ import { Buscador } from "../listas/piezas";
13
+ import { crearEstilos, renglon, tipografia, usePaleta } from "../tema";
14
+ import { Text } from "../texto";
15
+
16
+ /** Lo alto de la hoja: arriba se sigue viendo un poco de la ficha, que dice dónde se está. */
17
+ const ALTO = "85%";
18
+
19
+ /** Lo que la hoja lee de la consulta de su lista, sea de páginas o de scroll infinito. */
20
+ export interface ListaDeLaHoja<T> {
21
+ /** Lo cargado hasta ahora, de todas las páginas. */
22
+ renglones: T[];
23
+ /** Cuántos hay en total, si el servidor lo dice. */
24
+ total?: number | null;
25
+ /** La primera carga: todavía no hay nada que enseñar. */
26
+ cargando: boolean;
27
+ /** Falló y no hay nada que enseñar. */
28
+ fallo: boolean;
29
+ hayMas: boolean;
30
+ cargandoMas: boolean;
31
+ onMas: () => void;
32
+ onReintentar: () => void;
33
+ }
34
+
35
+ /**
36
+ * Una lista que sube desde abajo sobre una ficha: lo que cuelga de ella (las transferencias de un
37
+ * embarque, las facturas de un pedimento), **con su buscador, su scroll infinito y lo que se hace
38
+ * con ella** (agregar, quitar), sin salir de la ficha. Es la hermana de `HojaInferior` para cuando
39
+ * lo de adentro es una lista larga: aquélla mide su contenido y ésta tiene alto fijo, porque al
40
+ * buscar la lista crece y se encoge y la hoja no debe brincar.
41
+ *
42
+ * Los renglones son `RenglonDeHoja` o los de la lista de la app: una `Tarjeta` adentro ya va del
43
+ * color del fondo (`EnHoja`) para que se distinga de la hoja. Arriba de la lista, el buscador (si lo hay), `acciones` (los botones de la
44
+ * lista) y cuántos hay. Se cierra deslizándola, tocando el velo o con atrás.
45
+ *
46
+ * Sólo se monta abierta: quien la usa pide su consulta sólo entonces (`skip`).
47
+ */
48
+ export default function HojaDeLista<T>({
49
+ abierta,
50
+ onCerrar,
51
+ titulo,
52
+ subtitulo,
53
+ buscador,
54
+ acciones,
55
+ lista,
56
+ llave,
57
+ renderItem,
58
+ vacio,
59
+ nombres,
60
+ }: {
61
+ abierta: boolean;
62
+ onCerrar: () => void;
63
+ titulo: string;
64
+ subtitulo?: string | null;
65
+ buscador?: { texto: string; onCambiar: (texto: string) => void; placeholder: string };
66
+ /** Lo que se hace con la lista entera: «Agregar transferencias». */
67
+ acciones?: React.ReactNode;
68
+ lista: ListaDeLaHoja<T>;
69
+ llave: (item: T) => string | number;
70
+ renderItem: (item: T) => React.ReactElement;
71
+ /** Lo que se dice sin renglones: «Este embarque no trae transferencias». */
72
+ vacio: string;
73
+ /** Cómo se cuentan: `["transferencia", "transferencias"]` → «12 transferencias». */
74
+ nombres?: readonly [string, string];
75
+ }) {
76
+ const c = usePaleta();
77
+ const styles = useStyles();
78
+ const hoja = useRef<BottomSheet>(null);
79
+ const insets = useSafeAreaInsets();
80
+
81
+ // **Sólo existe abierta.** Cerrada (`index={-1}`), gorhom la estaciona justo debajo de su
82
+ // contenedor, y en Android con la barra de navegación de borde a borde ese contenedor mide
83
+ // menos que la pantalla: la hoja se asomaba al pie de la ficha desde que se entraba. Se monta
84
+ // al abrir y se desmonta cuando termina de bajar (`onClose`), así cerrada no hay nada que se
85
+ // asome, y la lista y su consulta tampoco viven de más.
86
+ const [montada, setMontada] = useState(abierta);
87
+ useEffect(() => {
88
+ if (abierta) setMontada(true);
89
+ else hoja.current?.close();
90
+ }, [abierta]);
91
+
92
+ useEffect(() => {
93
+ if (!abierta) return;
94
+ const sub = BackHandler.addEventListener("hardwareBackPress", () => {
95
+ onCerrar();
96
+ return true;
97
+ });
98
+ return () => sub.remove();
99
+ }, [abierta, onCerrar]);
100
+
101
+ const { renglones, total } = lista;
102
+
103
+ const cabecera = (
104
+ <View style={styles.cabecera}>
105
+ <View style={styles.titulos}>
106
+ <Text style={styles.titulo}>{titulo}</Text>
107
+ {subtitulo ? <Text style={styles.subtitulo}>{subtitulo}</Text> : null}
108
+ </View>
109
+ {buscador ? (
110
+ <Buscador
111
+ texto={buscador.texto}
112
+ onCambiar={buscador.onCambiar}
113
+ placeholder={buscador.placeholder}
114
+ entrada={BottomSheetTextInput}
115
+ style={styles.buscador}
116
+ />
117
+ ) : null}
118
+ {acciones}
119
+ {nombres && total != null && renglones.length ? (
120
+ <Text style={styles.cuenta}>
121
+ {total === 1 ? `1 ${nombres[0]}` : `${total} ${nombres[1]}`}
122
+ </Text>
123
+ ) : null}
124
+ </View>
125
+ );
126
+
127
+ const sinRenglones = lista.cargando ? (
128
+ <ActivityIndicator color={c.primary} style={styles.espera} />
129
+ ) : lista.fallo ? (
130
+ <View style={styles.vacio}>
131
+ <Text style={styles.linea}>No se pudo cargar. Revisa tu señal.</Text>
132
+ <BotonGrande icono="refresh" modo="contorno" onPress={lista.onReintentar}>
133
+ Reintentar
134
+ </BotonGrande>
135
+ </View>
136
+ ) : (
137
+ <Text style={[styles.linea, styles.vacio]}>{vacio}</Text>
138
+ );
139
+
140
+ if (!montada) return null;
141
+
142
+ return (
143
+ <BottomSheet
144
+ ref={hoja}
145
+ index={0}
146
+ animateOnMount
147
+ snapPoints={[ALTO]}
148
+ enableDynamicSizing={false}
149
+ enablePanDownToClose
150
+ onClose={() => {
151
+ setMontada(false);
152
+ onCerrar();
153
+ }}
154
+ // Con el teclado, la hoja sube a lo que quede de pantalla en vez de quedar tapada.
155
+ keyboardBehavior="extend"
156
+ keyboardBlurBehavior="restore"
157
+ android_keyboardInputMode="adjustResize"
158
+ backdropComponent={(props: BottomSheetBackdropProps) => (
159
+ <BottomSheetBackdrop
160
+ {...props}
161
+ appearsOnIndex={0}
162
+ disappearsOnIndex={-1}
163
+ pressBehavior="close"
164
+ />
165
+ )}
166
+ backgroundStyle={styles.fondo}
167
+ handleIndicatorStyle={styles.agarradera}
168
+ >
169
+ <EnHoja.Provider value>
170
+ <BottomSheetFlatList
171
+ data={renglones}
172
+ keyExtractor={(item: T) => String(llave(item))}
173
+ renderItem={({ item }: { item: T }) => renderItem(item)}
174
+ ListHeaderComponent={cabecera}
175
+ ListEmptyComponent={sinRenglones}
176
+ ListFooterComponent={
177
+ lista.cargandoMas ? (
178
+ <ActivityIndicator color={c.primary} style={styles.espera} />
179
+ ) : null
180
+ }
181
+ onEndReachedThreshold={0.5}
182
+ onEndReached={() => {
183
+ if (lista.hayMas && !lista.cargandoMas) lista.onMas();
184
+ }}
185
+ keyboardShouldPersistTaps="handled"
186
+ contentContainerStyle={[
187
+ styles.contenido,
188
+ { paddingBottom: insets.bottom + 24 },
189
+ ]}
190
+ />
191
+ </EnHoja.Provider>
192
+ </BottomSheet>
193
+ );
194
+ }
195
+
196
+ const useStyles = crearEstilos((c) => ({
197
+ fondo: {
198
+ backgroundColor: c.superficie,
199
+ borderWidth: c.esOscuro ? 1 : 0,
200
+ borderColor: c.secondaryLighter,
201
+ },
202
+ agarradera: { backgroundColor: c.secondaryLight },
203
+ contenido: { paddingHorizontal: 20, gap: 10 },
204
+ cabecera: { gap: 12, paddingBottom: 2 },
205
+ titulos: { gap: 2 },
206
+ titulo: { ...renglon(tipografia.titulo), fontWeight: "900", color: c.texto },
207
+ subtitulo: { ...renglon(tipografia.secundario), color: c.secondary },
208
+ // Sobre la hoja (superficie), el buscador va del color del fondo para que se vea su caja.
209
+ buscador: { backgroundColor: c.fondo },
210
+ cuenta: { ...renglon(tipografia.secundario), color: c.secondary },
211
+ linea: { ...renglon(tipografia.secundario), color: c.secondary },
212
+ vacio: { gap: 12, paddingVertical: 16 },
213
+ espera: { marginVertical: 16 },
214
+ }));
@@ -1,4 +1,5 @@
1
1
  import { crearEstilos, renglon, tipografia } from "../tema";
2
+ import { EnHoja } from "../componentes/Tarjeta";
2
3
  import { Text } from "../texto";
3
4
  import BottomSheet, {
4
5
  BottomSheetBackdrop,
@@ -82,7 +83,7 @@ export default function HojaInferior({
82
83
  <Text style={styles.titulo}>{titulo}</Text>
83
84
  {subtitulo ? <Text style={styles.subtitulo}>{subtitulo}</Text> : null}
84
85
  </View>
85
- {abierta ? children : null}
86
+ <EnHoja.Provider value>{abierta ? children : null}</EnHoja.Provider>
86
87
  </BottomSheetScrollView>
87
88
  </BottomSheet>
88
89
  );
package/src/hoja/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // `@acvl/mobile-components/hoja`: lo que sube desde abajo sobre una ficha. Va aparte de la entrada
2
2
  // principal porque usa `@gorhom/bottom-sheet`, que sólo instala la app que la usa.
3
+ export { default as HojaDeLista, type ListaDeLaHoja } from "./HojaDeLista";
3
4
  export { default as HojaInferior } from "./HojaInferior";
4
5
  export { useMasDeLaFicha, type ListaDeLaFicha } from "./useMasDeLaFicha";
package/src/index.ts CHANGED
@@ -112,6 +112,7 @@ export {
112
112
  OpcionDeFiltro,
113
113
  OpcionesMultiples,
114
114
  OrdenDeLista,
115
+ type CampoDeOrden,
115
116
  SeccionDeFiltros,
116
117
  } from "./listas/HojaDeFiltros";
117
118
  export { default as ListaPaginada, type ConsultaPaginada } from "./listas/ListaPaginada";
@@ -123,6 +124,7 @@ export {
123
124
  EstadoDeHoja,
124
125
  FilaConContador,
125
126
  RenglonDeContacto,
127
+ RenglonDeDireccion,
126
128
  RenglonDeHoja,
127
129
  RenglonDeLiga,
128
130
  SeccionDeFicha,
@@ -140,4 +142,10 @@ export {
140
142
  } from "./pantallas/PantallaDeAcceso";
141
143
  export { EsperaDeEdicion, FormularioDeEdicion } from "./pantallas/PantallaDeEdicion";
142
144
  export { default as PantallaDeFicha } from "./pantallas/PantallaDeFicha";
145
+ export {
146
+ default as PantallaDeHistorial,
147
+ TarjetaDeEvento,
148
+ type EventoDeHistorial,
149
+ type Historial,
150
+ } from "./pantallas/PantallaDeHistorial";
143
151
  export { default as PantallaDePerfil } from "./pantallas/PantallaDePerfil";
@@ -172,6 +172,23 @@ export function InterruptorDeFiltro({
172
172
  );
173
173
  }
174
174
 
175
+ /** Un campo por el que se ordena una lista. */
176
+ export interface CampoDeOrden {
177
+ /** El `ordering` sin signo: `codigo`. */
178
+ value: string;
179
+ label: string;
180
+ /**
181
+ * Cómo se dicen sus dos sentidos. «Ascendente» no dice nada de una fecha ni de un importe:
182
+ * ahí van «Antiguos primero» / «Recientes primero», «Menor primero» / «Mayor primero».
183
+ */
184
+ sentidos?: { asc: string; desc: string };
185
+ /**
186
+ * El sentido con el que arranca al elegirlo: lo más nuevo y lo más grande arriba, el texto
187
+ * alfabético. Sin esto conserva el del campo de antes.
188
+ */
189
+ primero?: "asc" | "desc";
190
+ }
191
+
175
192
  /**
176
193
  * «Ordenar por» y su sentido. `orden` es el `ordering` que se manda (`-codigo`), o `undefined` si
177
194
  * se deja el del servidor; con `porOmision`, ese «sin orden» se ofrece como opción.
@@ -182,7 +199,7 @@ export function OrdenDeLista({
182
199
  onOrdenar,
183
200
  porOmision,
184
201
  }: {
185
- campos: readonly { value: string; label: string }[];
202
+ campos: readonly CampoDeOrden[];
186
203
  orden: string | undefined;
187
204
  /** El nuevo `ordering`, o `null` para el del servidor. */
188
205
  onOrdenar: (orden: string | null) => void;
@@ -190,6 +207,12 @@ export function OrdenDeLista({
190
207
  }) {
191
208
  const descendente = orden?.startsWith("-") ?? false;
192
209
  const campo = orden?.replace(/^-/, "");
210
+ const elegido = campos.find((c) => c.value === campo);
211
+ const sentidos = elegido?.sentidos ?? { asc: "Ascendente", desc: "Descendente" };
212
+ // El sentido con el que arranca va primero: en una fecha se lee «Recientes primero» antes
213
+ // que «Antiguos primero».
214
+ const ordenDeSentidos: ("asc" | "desc")[] =
215
+ elegido?.primero === "desc" ? ["desc", "asc"] : ["asc", "desc"];
193
216
  return (
194
217
  <>
195
218
  <SeccionDeFiltros>Ordenar por</SeccionDeFiltros>
@@ -207,24 +230,26 @@ export function OrdenDeLista({
207
230
  texto={c.label}
208
231
  unica
209
232
  elegida={campo === c.value}
210
- onPress={() => onOrdenar(`${descendente ? "-" : ""}${c.value}`)}
233
+ onPress={() => {
234
+ // Volver a tocar el elegido no le cambia el sentido.
235
+ if (campo === c.value) return;
236
+ const desc = c.primero ? c.primero === "desc" : descendente;
237
+ onOrdenar(`${desc ? "-" : ""}${c.value}`);
238
+ }}
211
239
  />
212
240
  ))}
213
241
  {campo !== undefined ? (
214
242
  <>
215
243
  <SeccionDeFiltros>Sentido</SeccionDeFiltros>
216
- <OpcionDeFiltro
217
- texto="Ascendente"
218
- unica
219
- elegida={!descendente}
220
- onPress={() => onOrdenar(campo)}
221
- />
222
- <OpcionDeFiltro
223
- texto="Descendente"
224
- unica
225
- elegida={descendente}
226
- onPress={() => onOrdenar(`-${campo}`)}
227
- />
244
+ {ordenDeSentidos.map((sentido) => (
245
+ <OpcionDeFiltro
246
+ key={sentido}
247
+ texto={sentidos[sentido]}
248
+ unica
249
+ elegida={descendente === (sentido === "desc")}
250
+ onPress={() => onOrdenar(sentido === "desc" ? `-${campo}` : campo)}
251
+ />
252
+ ))}
228
253
  </>
229
254
  ) : null}
230
255
  </>
@@ -2,7 +2,7 @@ import Chip from "../componentes/Chip";
2
2
  import IconoDeRenglon from "../componentes/IconoDeRenglon";
3
3
  import Pestanas from "../componentes/Pestanas";
4
4
  import Tarjeta from "../componentes/Tarjeta";
5
- import { crearEstilos, renglon, tipografia, usePaleta } from "../tema";
5
+ import { crearEstilos, fuenteBase, renglon, tipografia, usePaleta } from "../tema";
6
6
  import { type Pestana } from "../componentes/Pestanas";
7
7
  import { Text, TextInput } from "../texto";
8
8
  import { type TonoDeChip } from "../componentes/Chip";
@@ -13,9 +13,11 @@ import React from "react";
13
13
  import {
14
14
  ActivityIndicator,
15
15
  Linking,
16
+ Platform,
16
17
  Pressable,
17
18
  View,
18
19
  type StyleProp,
20
+ type TextInputProps,
19
21
  type ViewStyle,
20
22
  } from "react-native";
21
23
 
@@ -32,6 +34,9 @@ import {
32
34
  * **El texto de sugerencia va en un renglón, con «…» si no cabe.** Es un `Text` encima del campo y
33
35
  * no el `placeholder` del `TextInput`: el de Android parte en dos renglones lo que no cabe, y el
34
36
  * segundo queda cortado a la mitad.
37
+ *
38
+ * Dentro de una hoja de `@gorhom/bottom-sheet` el campo es el suyo (`entrada`, su
39
+ * `BottomSheetTextInput`): con el de React Native el teclado tapa la hoja en vez de subirla.
35
40
  */
36
41
  export function Buscador({
37
42
  texto,
@@ -40,6 +45,7 @@ export function Buscador({
40
45
  autoFocus,
41
46
  onBlur,
42
47
  style,
48
+ entrada: Entrada,
43
49
  }: {
44
50
  texto: string;
45
51
  onCambiar: (texto: string) => void;
@@ -47,9 +53,12 @@ export function Buscador({
47
53
  autoFocus?: boolean;
48
54
  onBlur?: () => void;
49
55
  style?: StyleProp<ViewStyle>;
56
+ /** El campo en el que se escribe, si no es el `TextInput` de la app. */
57
+ entrada?: React.ComponentType<TextInputProps>;
50
58
  }) {
51
59
  const c = usePaleta();
52
60
  const styles = useStyles();
61
+ const Campo = Entrada ?? TextInput;
53
62
  return (
54
63
  <View style={[styles.buscador, style]}>
55
64
  <MaterialCommunityIcons name="magnify" size={24} color={c.secondary} />
@@ -64,7 +73,7 @@ export function Buscador({
64
73
  {placeholder}
65
74
  </Text>
66
75
  ) : null}
67
- <TextInput
76
+ <Campo
68
77
  value={texto}
69
78
  onChangeText={onCambiar}
70
79
  accessibilityLabel={placeholder}
@@ -72,7 +81,9 @@ export function Buscador({
72
81
  autoFocus={autoFocus}
73
82
  onBlur={onBlur}
74
83
  returnKeyType="search"
75
- style={styles.entrada}
84
+ selectionColor={c.primary}
85
+ // El de la app ya trae su letra y su color; uno de fuera, no.
86
+ style={[fuenteBase, { color: c.texto }, styles.entrada]}
76
87
  />
77
88
  </View>
78
89
  {texto ? (
@@ -138,6 +149,10 @@ function BotonDeFiltros({ cuenta, punto, onPress }: FiltrosDeCabecera) {
138
149
  /**
139
150
  * Lo de arriba de una lista: el buscador, con el botón de sus filtros a la derecha si los tiene, y
140
151
  * las pestañas que la cortan («En curso», «Por iniciar»). Con una sola pestaña no se pintan.
152
+ *
153
+ * Una lista cuyo servidor no busca por texto (una bandeja que sólo se filtra) va sin `placeholder`:
154
+ * entonces no lleva buscador —uno que no filtrara nada engañaría— y el botón de los filtros queda
155
+ * solo, a la derecha.
141
156
  */
142
157
  export function CabeceraDeLista<K extends string>({
143
158
  texto,
@@ -148,9 +163,10 @@ export function CabeceraDeLista<K extends string>({
148
163
  onPestana,
149
164
  filtros,
150
165
  }: {
151
- texto: string;
152
- onBuscar: (texto: string) => void;
153
- placeholder: string;
166
+ texto?: string;
167
+ onBuscar?: (texto: string) => void;
168
+ /** Sin esto no hay buscador. */
169
+ placeholder?: string;
154
170
  pestanas?: readonly Pestana<K>[];
155
171
  activa?: K;
156
172
  onPestana?: (key: K) => void;
@@ -159,19 +175,21 @@ export function CabeceraDeLista<K extends string>({
159
175
  const styles = useStyles();
160
176
  return (
161
177
  <View style={styles.cabecera}>
162
- {filtros ? (
178
+ {placeholder !== undefined || filtros ? (
163
179
  <View style={styles.filaDeBusqueda}>
164
- <Buscador
165
- texto={texto}
166
- onCambiar={onBuscar}
167
- placeholder={placeholder}
168
- style={styles.flex}
169
- />
170
- <BotonDeFiltros {...filtros} />
180
+ {placeholder !== undefined ? (
181
+ <Buscador
182
+ texto={texto ?? ""}
183
+ onCambiar={onBuscar ?? (() => {})}
184
+ placeholder={placeholder}
185
+ style={styles.flex}
186
+ />
187
+ ) : (
188
+ <View style={styles.flex} />
189
+ )}
190
+ {filtros ? <BotonDeFiltros {...filtros} /> : null}
171
191
  </View>
172
- ) : (
173
- <Buscador texto={texto} onCambiar={onBuscar} placeholder={placeholder} />
174
- )}
192
+ ) : null}
175
193
  {pestanas && pestanas.length > 1 && activa && onPestana ? (
176
194
  <Pestanas pestanas={pestanas} activa={activa} onSelect={onPestana} />
177
195
  ) : null}
@@ -382,6 +400,64 @@ export function RenglonDeContacto({
382
400
  );
383
401
  }
384
402
 
403
+ /**
404
+ * Abre una dirección en la app de mapas del teléfono: Apple Maps en iOS y, en Android, la que
405
+ * atienda `geo:` (Google Maps, Waze…). Si ninguna la atiende, Google Maps en el navegador.
406
+ */
407
+ const abrirEnMapas = (direccion: string) => {
408
+ const q = encodeURIComponent(direccion);
409
+ const nativa = Platform.OS === "ios" ? `maps:0,0?q=${q}` : `geo:0,0?q=${q}`;
410
+ Linking.openURL(nativa).catch(() =>
411
+ Linking.openURL(`https://www.google.com/maps/search/?api=1&query=${q}`).catch(() => {}),
412
+ );
413
+ };
414
+
415
+ /**
416
+ * Una dirección **que se abre en el mapa del teléfono**: el renglón entero es el botón, para
417
+ * llegar a donde se entrega sin copiarla a mano. Las líneas que falten no se pintan; sin ninguna,
418
+ * no hay renglón. Es el hermano de `RenglonDeContacto`.
419
+ */
420
+ export function RenglonDeDireccion({
421
+ etiqueta = "Dirección",
422
+ lineas,
423
+ enHoja,
424
+ }: {
425
+ etiqueta?: string;
426
+ /** La calle, la colonia, «Ciudad, Estado, CP», el país: un renglón cada una. */
427
+ lineas: (string | null | undefined)[];
428
+ /** En una hoja (`HojaInferior`), del color del fondo, como `RenglonDeContacto`. */
429
+ enHoja?: boolean;
430
+ }) {
431
+ const c = usePaleta();
432
+ const styles = useStyles();
433
+ const presentes = lineas.map((l) => l?.trim()).filter((l): l is string => Boolean(l));
434
+ if (!presentes.length) return null;
435
+ return (
436
+ <Pressable
437
+ onPress={() => abrirEnMapas(presentes.join(", "))}
438
+ accessibilityRole="link"
439
+ accessibilityLabel={`Abrir en el mapa: ${presentes.join(", ")}`}
440
+ style={({ pressed }) => [
441
+ styles.renglon,
442
+ enHoja && styles.renglonEnHoja,
443
+ pressed && styles.presionada,
444
+ ]}
445
+ >
446
+ <View style={styles.renglonTexto}>
447
+ <Text style={styles.etiqueta}>{etiqueta}</Text>
448
+ {presentes.map((l, i) => (
449
+ <Text key={i} style={i === 0 ? styles.valor : styles.linea}>
450
+ {l}
451
+ </Text>
452
+ ))}
453
+ </View>
454
+ <View style={styles.accion}>
455
+ <MaterialCommunityIcons name="map-marker-outline" size={24} color={c.primary} />
456
+ </View>
457
+ </Pressable>
458
+ );
459
+ }
460
+
385
461
  /**
386
462
  * Un renglón de una hoja (`HojaInferior`): el título con su chip y lo que acompaña. **No es
387
463
  * `TarjetaDeLista`**: ésa es del color de la hoja y adentro de ella no se distinguía un renglón del