GLP Cerca (4): la app Tauri, mínima interacción y decidir de un vistazo
Publicado el 04/07/2026
El backend (artículo 3) hace el trabajo difícil. La app puede, por tanto, ser deliberadamente simple. Pero "simple" no significa "descuidada": cada decisión de la app está al servicio del requisito que vertebra todo el proyecto —decidir dónde repostar de un vistazo, con el coche parado, en dos segundos— que planteé en el artículo 1.
Por qué Tauri 2 (y no nativo, y no Flutter, y no una PWA)
La app es Tauri 2 con el frontend en HTML/CSS/JS vanilla, sin framework.
- ¿Por qué no Android nativo (Kotlin)? Porque el 95 % de la "inteligencia" vive en el backend. La app pide ubicación, llama a un endpoint, pinta una lista y abre Google Maps. Para eso, montar un proyecto nativo completo era desproporcionado.
- ¿Por qué no Flutter / React Native? No quería un runtime pesado ni un árbol de dependencias enorme para algo que es, en esencia, una lista y un mapa. Y filosóficamente: si el cliente es tonto, su toolchain también debería serlo.
- ¿Por qué no una PWA pura? Necesito geolocalización fina fiable, abrir la app de mapas del sistema con un Intent y mantener la pantalla encendida, además de estar en Google Play. Tauri me da el contenedor nativo justo para esos tres puentes, y dejo todo lo demás como web.
- ¿Por qué vanilla sin framework? Porque la UI cabe en un
app.js. Meter React aquí sería traer un martillo neumático para clavar una chincheta. Menos dependencias, arranque instantáneo, y nada que se quede obsoleto en dos años.
Opinión: la elección de tecnología debería ser proporcional al problema. Mucha gente empieza por el framework y luego busca el problema. Yo tenía el problema —"lista coloreada + un botón que abre Maps"— y elegí la cosa más pequeña que lo resolviera. Tauri 2 con vanilla JS es esa cosa.
El Rust de la app: minúsculo a propósito
La parte Rust de Tauri (lib.rs) expone exactamente dos comandos. Esa
contención es intencional: cuanto menos código nativo, menos superficie que
mantener y firmar.
1. De dónde sacar la API según el entorno de compilación:
#[tauri::command]
fn api_base() -> &'static str {
if cfg!(debug_assertions) {
"http://192.168.1.12:3000" // dev: backend local
} else {
"https://glp.rexvalentia.es" // release: producción
}
}
Que sea el binario quien decida la URL (y no el JS) evita el clásico "se me
quedó la URL de dev en producción". El frontend la pide al arrancar con
invoke('api_base') y, si falla (porque corres la UI en un navegador normal,
fuera de Tauri), cae a un fallback. El JS no tiene que saber en qué entorno está.
2. Abrir la ruta de Google Maps de forma segura:
#[tauri::command]
fn abrir_ruta(app: tauri::AppHandle, url: String) -> Result<(), String> {
let permitido = url.starts_with("https://www.google.com/maps/")
|| url.starts_with("https://maps.google.com/")
|| url.starts_with("https://maps.app.goo.gl/");
if !permitido {
return Err("solo se permiten URLs https de Google Maps".into());
}
app.opener().open_url(url, None::<&str>).map_err(|e| e.to_string())
}
Decisión de seguridad: el webview no debe navegar fuera de la app. Abrir URLs se delega al sistema (un Intent en Android) a través de este comando, y el comando valida el esquema y el dominio. Aunque el frontend tuviera un bug que construyera una URL rara, el Rust no abre nada que no sea Google Maps por https. La validación vive en la frontera nativa, que es donde no se puede saltar.
El flujo, paso a paso
Todo en ui/app.js. El flujo entero está pensado como una línea recta sin
desvíos:
- Al abrir → resolver
API_BASE→ pedir ubicación fina. - Llamar a
/api/v1/estaciones/cercanas?...&orden=precio_asc&limite=200. - Pintar la lista a pantalla completa, ya ordenada por el servidor.
- Tocar una estación → confirmación →
invoke('abrir_ruta', ...)→ Google Maps en modo conducción.
const url =
`${API_BASE}/api/v1/estaciones/cercanas` +
`?lat=${pos.lat}&lon=${pos.lon}&radio_km=${radioKm}&orden=precio_asc&limite=${LIMITE}`;
const r = await fetch(url);
estaciones = await r.json();
const precios = estaciones.map((e) => e.precio_glp);
rango = { min: Math.min(...precios), max: Math.max(...precios) };
pintarVista();
Fíjate en lo que la app no hace: no ordena (lo hizo el backend), no calcula distancias (vienen en la respuesta), no decide cuál es la más barata (es la primera). El cliente tonto del artículo 1, hecho realidad.
El código de color relativo: la mejor decisión de UX
Aquí está la idea de la que estoy más orgulloso a nivel de producto.
El color de cada estación no se compara contra un umbral absoluto ("por debajo de 1,00 € es verde"). Se compara contra el rango de precios de lo que tú puedes alcanzar ahora mismo:
// Color por precio relativo: barato = verde (140°), caro = rojo (0°).
function colorPrecio(p, min, max) {
const t = max > min ? (p - min) / (max - min) : 0;
const hue = 140 * (1 - t);
return `hsl(${hue.toFixed(0)}, 80%, 40%)`;
}
El precio más bajo de tu radio es verde puro; el más alto, rojo puro; el resto, un degradado entre ambos.
Por qué importa: un umbral absoluto envejece mal. El GLP sube y baja con el mercado; lo que hoy es "barato a 0,95 €" mañana es caro. Y es injusto con las zonas: si en tu comarca todo está caro, un umbral absoluto te pintaría la pantalla entera de rojo y no te ayudaría a elegir entre lo que tienes. El color relativo responde la pregunta real del conductor: "de lo que puedo alcanzar, ¿dónde está el chollo?" Siempre hay un verde y un rojo, porque siempre hay un mejor y un peor entre tus opciones.
La estación más barata, además, lleva una estrella y la etiqueta "más barata" para rematar el "de un vistazo": ni siquiera tienes que comparar colores, el ganador está marcado.
Dos vistas, dos lógicas de carga distintas
- Lista (por defecto): carga por radio alrededor de tu posición (chips de 50/100/150/200/250 km). Es la herramienta de decisión.
- Mapa (Leaflet/OSM, toggle): carga por área visible y recarga al desplazar o hacer zoom. Es la herramienta de exploración.
Son dos modelos de carga deliberadamente distintos porque resuelven preguntas distintas: la lista responde "¿dónde repostar ya?"; el mapa, "¿qué hay por aquí?".
Detalles de UX que parecen menores y hacen la app
Estos son los que separan una demo de algo que de verdad usas conduciendo:
- No re-localizar al volver del navegador. Cuando vuelves de Google Maps, la app sigue exactamente donde estaba. No se vuelve a geolocalizar sola ni recarga la lista. Para refrescar la posición hay un botón explícito ↻ Ubicación. Una app que se reinicia cada vez que cambias de tarea es una app que te pelea.
document.addEventListener("visibilitychange", () => {
// Al volver del navegador NO se re-localiza; solo se recupera el wake lock
// y, en modo automático, se reajusta el tema por si cambió la franja horaria.
if (document.visibilityState === "visible") {
pedirWakeLock();
if (prefTema === "auto") aplicarTema();
}
});
-
Wake Lock. La pantalla no se apaga mientras usas la app. Innegociable en una app de conducción. Y se vuelve a pedir al regresar, porque el sistema lo suelta al irte.
-
Tema día/noche automático. Por defecto sigue la hora del sistema (noche entre las 20:00 y las 06:59), con opción manual. De noche, conduciendo, una pantalla blanca deslumbra. Hasta los tiles del mapa cambian con el tema.
-
Precio y distancia en grande, formateados a la española (coma decimal):
fmtPrecioyfmtDist. Cifras pensadas para leerse de un vistazo, no para estudiarse. -
Confirmación antes de salir a Maps. Un toque accidental no te saca de la app sin avisar.
-
Escapado de HTML (
esc) en todo lo que viene del backend (rótulos, localidades). Vienen de una fuente externa; nunca confíes en que un nombre de estación no traiga un<.
Lo que me llevo de esta capa
- El cliente tonto es una virtud, no una limitación. Toda la complejidad que no metes en la app es complejidad que no tienes que depurar en cinco modelos de móvil distintos.
- La UX de conducción tiene reglas propias: no robes el foco, no apagues la pantalla, no deslumbres de noche, no obligues a comparar cuando puedes señalar al ganador.
- El color relativo > el umbral absoluto siempre que la pregunta sea "elige entre estas opciones", no "¿esto es bueno en abstracto?".
En el último artículo cierro con lo que rodea al código: despliegue, observabilidad, versionado para Google Play y por qué la honestidad sobre el origen de los datos es, también, una decisión de producto.