El historialdura años
El entrenador dicta 90 segundos al bajar de la cancha; el alumno recibe un historial de desarrollo que dura años.
rolDesarrollo integral: arquitectura Flutter, backend serverless en Firebase, canalización de IA con Gemini, reglas de seguridad, sistema de diseño y localización
estadoEn desarrollo activo con cliente real (Cliff, primer coach de la plataforma). Firebase en plan Blaze y funciones desplegadas; todavía sin publicar en tiendas.
24
pantallas
23
Cloud Functions
87
idiomas
28
habilidades en 4 categorías
De la nota suelta a la línea medida
Un plano empieza con la guía punteada y termina con el trazo firme. El producto hace lo mismo con lo que el entrenador dice de viva voz.
Lo que no existía
Un entrenador de pickleball trabaja con decenas de alumnos y, al llegar a la cancha, no puede responder en diez segundos a la pregunta que importa: ¿qué dijimos que trabajaríamos hoy? Los apuntes viven en notas de voz sueltas, en papel o en la memoria; el alumno se va con la sensación de la clase pero sin ninguna prueba de que esté mejorando; y los porcentajes que el coach dicta de viva voz («sacó 80, 90, 75») no se convierten nunca en una serie de datos comparable. Escribir un reporte formal por alumno y por clase es un trabajo que nadie hace de forma sostenida, así que el historial —que es lo único que demuestra progreso— sencillamente no existe.
Lo que se construyó
Convertir el dictado en el único trabajo que el coach tiene que hacer, y poner toda la disciplina en el servidor. La app graba el audio, lo persiste en disco antes de nada y lo encola: si no hay señal en la cancha, se sube solo al recuperarla. Una Cloud Function se lo pasa a Gemini 2.5 Flash como URI de Storage y devuelve un borrador estructurado que el coach revisa y publica. Al publicar, el servidor construye con lista blanca una proyección para el alumno, recalcula su Player DNA a partir de conteos reales y programa el recordatorio de tareas. Todo lo que el modelo no pueda contrastar contra la transcripción literal se retira con aviso en lugar de guardarse: un hueco visible se arregla en diez segundos, un número inventado ya no se distingue de una medición.
Cuatro pasos y ni uno más
El único trabajo del coach es hablar noventa segundos. Toda la disciplina vive en el servidor.
Dicta
30 a 90 segundos al bajar de la cancha. El audio se escribe en disco y se encola antes de tocar la red.
Estructura
Gemini 2.5 Flash recibe el URI de Storage y devuelve ejercicios, series contadas, tareas y claves.
Revisa y publica
El coach corrige sobre la transcripción literal y publica con un toque. La IA nunca cambia el estado.
El alumno recibe
El servidor arma con lista blanca su proyección, recalcula el Player DNA y programa el recordatorio.
Seis decisiones que sostienen el historial
Dictado que sobrevive a la cancha sin señal
El audio se escribe en disco y se encola en SharedPreferences antes de tocar la red, así que sobrevive a que se cierre la app y se vacía solo al recuperar conexión. Firebase se resuelve de forma perezosa: grabar y encolar no dependen de que su arranque haya funcionado.
Asistente conversacional con cuaderno acumulado
Cuatro callables (openAiSession, appendCoachTurn, finalizeAiSession, abandonAiSession) mantienen un cuaderno en JSON que viaja en cada turno junto al audio nuevo. No se reenvía el historial de audios: Gemini cuenta el audio a unos 32 tokens por segundo y reenviarlo crecería de forma cuadrática.
La omisión nunca borra
El modelo devuelve el cuaderno entero y una función pura lo reconcilia: solo puede añadir, corregir con justificación o eliminar con justificación. La justificación se contrasta con la transcripción del mismo turno y, ante la duda, se revierte y se anota en countDrift.
Player DNA sobre conteos, no sobre opiniones
Cuatro categorías y 28 habilidades. Una habilidad que ningún ejercicio mide se marca como no medida en vez de rellenarse con un cero, cada categoría declara su cobertura («1 de 5 medidas») y con menos de tres mediciones no se declara tendencia.
El alumno y el coach leen colecciones distintas
El jugador nunca toca /orgs/**: lee su propio player_space, una proyección construida con lista blanca que solo escriben las Cloud Functions. Las notas privadas del coach viven en una subcolección a la que ninguna regla puede evaluarse a favor del alumno, y hay pruebas de reglas contra el emulador que lo verifican.
87 idiomas, incluidos los avisos por push y correo
Las cadenas se generan desde los catálogos ARB hacia un JSON que consumen las funciones, con los plurales resueltos por Intl.PluralRules. El glosario de pickleball (dink, third shot drop, reset) no se traduce nunca, y las fuentes van empaquetadas para que la app no aparezca en Roboto al abrirla sin cobertura.
Nada cruza salvo una flecha
El alumno nunca toca /orgs/**: lee su propio player_space. El puente entre los dos lados lo cruzan únicamente las Cloud Functions.
lado a
Área del entrenador
/orgs/{orgId}/**
- Panel con avisos, cola de trabajo y quién entra hoy
- Alumnos, fichas y notas privadas del entrenador
- Clases, borradores y publicación manual
- Estudio de dictado y asistente conversacional
- Court ID: la batería versionada de diez ejercicios
lado b
Área del alumno
/player_space/{uid}/**
- Panel propio, sin nada de la organización
- Player DNA con cobertura declarada
- Progreso sobre bolas contadas
- Historial de clases que se acumula
- Tareas con id estable y su respuesta al coach
Proyección con lista blanca, feedback espejado y notas privadas que ninguna regla puede evaluar a favor del alumno.
Lo que no se inventa
Un hueco visible se arregla en diez segundos; un número inventado ya no se distingue de una medición.
La IA nunca publica
La publicación del reporte es manual. El modelo propone un borrador; el estado de la clase sólo lo cambia una persona.
La omisión nunca borra
El servidor recibe el cuaderno entero y sólo acepta añadir, corregir con justificación o eliminar con justificación. Lo omitido se restituye y se anota donde el coach lo ve.
Dos unidades que no se convierten
Bolas contadas (made/attempted) y puntuación 0-100 por serie viven separadas. Ningún denominador se deduce: o lo dijo el coach o es el tamaño de serie del catálogo.
El dominio es consistencia
Una serie hundida deja el ejercicio en ámbar aunque el promedio sea alto. Y con menos de tres mediciones no se declara tendencia.
Tres pantallas del mismo plano
La app prioriza el tema claro: lienzo #F7F7F8, tarjetas blancas y el rojo de marca como único acento. Aquí van recreadas.
Asistente
Clase 12 · Lillian
Resumen
Dinks cruzados y tercer golpe. Lillian sube la altura del reset.
Ejercicios
Tareas
Claves
· Pala arriba antes del bote
«sacó 80, 90, 75, 48 y 90»
0:41
¿De cuántas bolas era cada serie? No la supongo.
Estudio · asistente de IA conversacional
anillo del micrófono · bucle de 1600 ms
Tu ADN de juego
Un mapa de lo que hemos medido en tus clases. Lo que no se ha medido no se rellena con un cero.
Mental: todavía no lo hemos medido
empezaste en 60%, vas por 77%
empezaste en 48%, vas por 54%
empezaste en 45%, vas por 41%
Player DNA del alumno
cuatro arcos · cobertura declarada
Court ID · Lillian
6/10Una serie hundida deja el ejercicio en ámbar: el criterio es consistencia, no promedio.
Sin teclado: 21 casillas de cinco en cinco.
Compuesto objetivo
72/100
La traducción a nivel es provisional. Autoguardado a los 3 s del último conteo.
Court ID · ejecución de la evaluación
rejilla de conteo · 21 casillas, sin teclado
s-01
Panel del entrenador
Panel ordenado por urgencia y no por estética: aviso, cola de trabajo, quién entra hoy, marcador en vivo y seis gráficas servidas desde un único documento agregado.
s-05
Reporte del alumno
El destino de la notificación push: mensaje del entrenador, lo que salió bien, en qué seguimos, ejercicios contados, tareas marcables y el bloque donde el alumno responde.
Dos arcos y un solo rojo
El símbolo es la trayectoria de una bola y su rebote. Cada capa del icono está cortada para su destino: adaptativo de Android, monocromo tematizado, oscuro y teñido de iOS 18.







Atlético y contundente. Negro casi absoluto, un rojo-naranja de alta energía y tipografía display condensada (Saira) sobre cuerpo neutro (Inter). El símbolo son dos arcos en movimiento, como la trayectoria de una bola y su rebote. El gradiente empieza en el rojo hundido —la esquina donde va el titular blanco— y termina en el ámbar, que nunca lleva texto encima. La identidad es nativa de fondo oscuro, pero el producto prioriza el tema claro, así que el rojo tiene variantes medidas para cada fondo.
Índice de lo que ya funciona
Con qué está levantado
Cómo está levantado por dentro
Clean Architecture por feature: cada uno de los 11 módulos de lib/features/ se abre en domain (entidades y contratos de repositorio), infrastructure (implementaciones sobre Firebase) y presentation (providers, controllers, screens y widgets). Los imports son siempre por paquete y no hay barrel files; tampoco freezed ni json_serializable, porque copyWith y fromJson se escriben a mano para que el modelo de datos se pueda leer entero sin generación. La configuración transversal vive en lib/config (tema y router) y lib/core (i18n, catálogo de habilidades, preferencias, analítica, errores). El router es un go_router con dos StatefulShellRoute —una por cada área, entrenador y alumno— más las rutas neutras de acceso, y su redirect se decide por el modo activo y no por el rol, esperando a que los vínculos del alumno estén resueltos antes de mandar a nadie a ninguna parte. En el servidor, 23 funciones organizadas por dominio (auth, orgs, sessions, ai, dna, feedback, notifications, stats, account) mezclan callables, triggers de Firestore, un trigger de Storage y dos tareas programadas; el catálogo de habilidades, ejercicios y batería vive en shared/ como fuente única y se copia hacia las funciones en el prebuild. Las decisiones delicadas —el plan de borrado de cuenta, el de desvinculación, la reconciliación del cuaderno de la IA, el plegado de estadísticas y la regla del recordatorio— están extraídas a funciones puras y probadas aparte de la función que ejecuta.
El modelo inventaba denominadores. Como el coach dicta porcentajes con naturalidad («sacó 80, 90, 75, 48 y 90»), la suposición de series de diez bolas era imposible con un 80, así que la IA elegía el denominador que cuadraba —100— y guardaba quinientas bolas que nadie golpeó, con la marca de descarte vacía. El dato era coherente; el problema no era la coherencia.
Dos unidades separadas y un contraste obligatorio. La suposición de las diez bolas solo vale si todos los números de la serie caben en diez, y el servidor coteja cada denominador contra la transcripción literal: o lo dijo el coach en ese mismo turno, o es el tamaño de serie del catálogo. Lo demás se retira y se anota en countDrift. En la conversación el modelo pregunta por el tamaño de serie en vez de adivinarlo; en el dictado, que es una sola pasada, el ejercicio se nombra sin conteos. El caso real y sus regresiones viven en functions/test/invented_counts.test.ts.
En el asistente conversacional, que el modelo dejara fuera del cuaderno algo que había apuntado en un turno anterior equivalía a borrarlo. Con cuarenta turnos, además, un modelo desbocado producía 6,3 MB y la evaluación entera dejaba de poder escribirse en un documento de 1 MiB.
La reconciliación se sacó a una función pura (ai/merge.ts): el servidor recibe el cuaderno completo y solo acepta añadir, corregir con justificación o eliminar con justificación, restituyendo lo omitido y anotándolo donde el coach lo ve. Se añadieron topes duros por campo, y al recortar se descarta siempre lo nuevo: si el tope pudiera tirar lo anterior, sería una vía por la que la omisión sí borra.
El destino de navegación se decidía por el rol, y eso expulsaba a un entrenador de su propia área de alumno. Peor: los vínculos del alumno llegan por un stream de Firestore, así que entre resolver la sesión y recibir el espacio había un hueco en el que un alumno de dos años parecía no tener área, y se le mandaba a la pantalla de conectar con su entrenador con su panel al otro lado y ningún botón que llevara.
Un modo activo explícito, porque una misma persona puede entrenar a unos y ser alumna de otro. Y una bandera de resolución en el estado del guard: sin modo y sin resolver todavía, el splash se queda un momento más en vez de decidir. Al entrenador no le afecta, porque su rol viaja en los custom claims.
Las fichas de alumno se volvían invisibles para la búsqueda de invitaciones pendientes y el alumno entraba a una app vacía sin ningún error que lo explicara.
Firestore no distingue campo ausente de campo null a favor de quien consulta: una consulta por igualdad a null solo encuentra documentos donde el campo existe. Se convirtió en regla del proyecto escribir siempre los campos que se consultan así, aunque valgan null, con una prueba que hace la consulta de verdad contra el emulador. La regla inversa se aplica al recordatorio de tareas, donde la consulta es por rango y el campo se borra en lugar de anularse.
Vincular una ficha a una cuenta era un viaje de ida: un correo mal escrito o un alumno que entró con otra cuenta de Google dejaban la ficha atada para siempre, y la única salida era que el alumno borrara su cuenta entera. Quien se vinculaba por error a la ficha de otro se quedaba además con el historial de un tercero.
Una operación de desvinculación con su decisión en una función pura y probada aparte. Retira del espacio del alumno los reportes ya entregados de ese vínculo —retirar no destruye, porque el reporte del alumno es una copia y se repone al volver a vincular—, borra los códigos de invitación vivos, retira el correo solo si era el de esa cuenta, y no toca nada del historial del entrenador. Todo idempotente y con registro de quién, cuándo y sobre qué ficha.
En tema oscuro, la elevación clásica —aclarar la tarjeta— hundía el rojo de marca por debajo del mínimo de contraste de WCAG AA, y el color de error se pintaba como texto en seis pantallas justo en el rojo menos legible del tema. Ninguna prueba lo veía porque ese token no se miraba.
La elevación se consigue hundiendo el lienzo por debajo de la tarjeta en lugar de aclararla, lo que además mejora todos los pares de contraste a la vez. La paleta se partió en cuatro rojos con papeles distintos —marca para relleno, uno oscurecido para texto sobre claro, uno encendido para texto sobre oscuro y uno hundido para estados presionados— y un test de contraste vigila cada par.
El proyecto está en plan Blaze, que no tiene techo de gasto, y cada turno del asistente puede llevar nueve minutos de audio. Contar llamadas no es contar gasto.
El guardián real es la cuota de tokens por organización y día, contada en servidor e incluyendo los tokens de razonamiento que Gemini reporta aparte de la salida. Encima: cuota diaria de dictados, idempotencia por turno y por generación, maxInstances por función, borrado de audios a los siete días por ciclo de vida de Storage, interruptores de apagado que cortan la IA al instante sin desplegar nada, y alerta de presupuesto con avisos escalonados.
24
pantallas
11
módulos de features
23
Cloud Functions
87
idiomas
17
colecciones Firestore
28
habilidades en 4 categorías
16
ejercicios en el catálogo
10
pruebas de la batería Court ID
93
archivos de prueba
~40k
líneas de Dart
ROBUST PICKLEBALL es una plataforma de desarrollo de jugadores construida alrededor de una idea que el propio repositorio repite como norma: el producto no es el reporte de una clase, es el historial del jugador. Una clase dura una hora; el historial debe durar años.
El ciclo completo cabe en cuatro pasos. El coach termina la clase y dicta un resumen de 30 a 90 segundos (o conversa por turnos con un asistente que va llenando un cuaderno). Gemini 2.5 Flash lo estructura en ejercicios, series contadas, tareas y claves de entrenamiento. El coach revisa y publica con un toque. El alumno recibe un reporte que se acumula en su propio espacio, con su Player DNA, su línea de progreso y sus tareas.
La app es una sola aplicación Flutter con dos productos dentro: el área del entrenador (panel, alumnos, clases, estudio de dictado, evaluaciones Court ID) y el área del alumno (panel, progreso, historial, tareas). Cada lado lee colecciones de Firestore distintas y el puente entre ambos lo cruzan únicamente las Cloud Functions.
Es multi-tenant desde el primer día: ROBUST es la plataforma y el primer entrenador es solo la primera organización. Se traduce a 87 idiomas, funciona sin cobertura en la cancha y genera el PDF del reporte en el propio teléfono.
ROBUST PICKLEBALL
¿Quieres algo así para tu producto?
Una app Flutter con dos productos dentro, veintitrés Cloud Functions y una IA a la que no se le deja inventar un solo número. Si necesitas algo así —voz que se vuelve dato, permisos que de verdad separan, gasto acotado— es exactamente el terreno que conozco.