Skarfit

Rediseño de la app de fitness con presencia humana del trainer, sincronización en vivo del plan y operación estable en gimnasios sin señal.

Rol: Lead móvil2025–2026Equipo de 2
FlutterRiverpod 3FirebaseClean Arch + DDDCloud FunctionsNext.js
99.6%
crash-free
−42%
tiempo de arranque
+31%
finalización del 1er treino

01 · Contexto y problema

Skarfit conecta personal trainers (CREF + CRN) con alumnos en Brasil. La promesa del producto: «el trainer parece presente sin necesitar estar» — el alumno entrena con un plan publicado por su coach, ve su voz/foto/mensajes durante la sesión, y el trainer monitorea desde un dashboard B2B sin estar físicamente en el gym.

Recibí el proyecto con deuda heredada que se chocaba: un onboarding con rutas hardcoded (10 de 16 grupos musculares caían silenciosamente en «Espalda»), un dashboard del alumno acoplado en un archivo de ~3.500 líneas, y una vertical de alimentación que mezclaba el día de hoy con un histórico no inmutable.

El objetivo fue sostener la app en producción mientras se reconstruía la base — sin reescribir todo de golpe, con cobertura de tests y migración idempotente desde el schema legacy.

Restricciones

Plazo: MVP en 6 mesesLGPD + Apple Sign in + CREF/CRNFirebase como único backendGimnasio sin Wi-Fi confiableSamsung gama media-bajaApp Check + Play Integrity

02 · Decisiones técnicas

1

Arquitectura

Problema

El estado del alumno vivía disperso entre setState, SharedPreferences global (filtraba datos entre usuarios al cambiar de cuenta) y providers ad-hoc; los alumnos legacy caían en estados imposibles.

Opciones

setState + InheritedWidget · Provider · BLoC · Riverpod 3

Decisión ✓

Riverpod 3 + Clean Architecture con DDD pragmático: 6 bounded contexts, 3 capas, value objects manuales y un SAD canónico con 13 ADRs vivos dentro del repo.

Trade-off

Curva de aprendizaje en VOs y aggregates. Mitigado con el SAD + tests por aggregate (310+ solo en Alimentación).

2

Sincronización trainer ↔ alumno

Problema

Publicar el plan desde el dashboard debía reflejarse en el alumno sin interrumpir una sesión activa y con la garantía de que «publicado» solo lo escribe el servidor.

Opciones

Polling 5s · streams + cliente escribe · streams + Cloud Function única · FCM push

Decisión ✓

Firestore real-time streams + Cloud Function publishPlan como único writer del estado publicado, gateada por CREF verificado y reglas Firestore. Lecturas Source.server en rutas críticas (splash/login/onboarding).

Trade-off

+1 hop y ~60 ms al publicar. A cambio: cumplimiento CREF, cero race conditions y routing determinístico.

3

Offline en el gym + gama baja

Problema

Sesiones de 45 min sin red, con foreground service para que el battery manager no mate el proceso; datos sensibles que no pueden perderse y listas largas que tiraban los FPS.

Opciones

Offline persistence nativo · SharedPreferences directa · Hive · Cola idempotente

Decisión ✓

SharedPreferences UID-scoped (sin clave global jamás), Firestore offline persistence, WorkoutForegroundService y RestAlertService con escalación declarativa. Catálogo de ejercicios cacheado en memoria + listas virtualizadas.

Trade-off

Invalidación compleja → checklist obligatorio de persistencia pre-PR. Frenó ~15 % el desarrollo pero cortó los bugs de «se perdió el peso anotado».

Arquitectura

UI · Features
Application · Riverpod 3
Domain · Dart puro (VOs)
Infra · Firestore + CF

03 · Migración de Alimentación a DDD per-weekday + histórico inmutable

El schema previo guardaba comidas como una lista global con flags «done»: servía para «lo que como hoy», pero se rompía al querer planear por día de la semana, ver el histórico de las últimas 4 semanas y marcar días libres dentro del límite semanal.

La migración debía dejar la app en producción sin downtime, preservar lo que el alumno ya había planeado y mantener una boundary limpia (otras tabs leen el progreso sin tocar las internas del módulo).

Solución: 4 aggregate roots + 5 value objects, una migración one-shot idempotente del schema legacy, y una boundary cross-tab vía un único api.dart. 310+ tests por aggregate y 7 fases en 34 commits granulares, mergeado en producción sin un bug reportado.

El criterio de éxito fue duro: que el alumno legacy abriera la nueva pantalla y no se diera cuenta de la migración. Se logró.

Demo interactiva

Próximamente: ejecuta el código y míralo correr en vivo, sin instalar nada.

main.dart
Run

Editor en vivo (DartPad) — próximamente

Próximamente

04 · Resultados · antes / después

crash-free
96.4%
99.6%
arranque en frío (Galaxy A14)
3.2s
1.9s
finalización del 1er treino (D1)
41%
72%
publicar un plan (trainer)
~12 min
~3 min
tests verdes
~120
938

05 · Retrospectiva

Introduciría el checklist de persistencia desde el día 1: apareció en el mes 4 tras dos incidentes de pérdida de datos; tenerlo antes habría ahorrado ~3 semanas de retrabajo.

Cerraría antes la boundary cross-tab: Alimentación quedó limpio exportando solo api.dart, pero otras tabs aún se importan entre sí — deuda asumida conscientemente.

Conservaría el SAD único con ADRs internos y documentar el porqué de cada decisión en memoria persistente: no es código, pero ahorró horas entre sesiones.

Código destacado · Panel admin · búsqueda de usuario

Endpoint del panel (Next.js) que consolida en una sola respuesta el estado de un usuario en Firebase Auth y Firestore (trainers + alunos), sin importar su estado de aprobación. Tres fuentes en paralelo, distinción «no encontrado» vs error real, narrowing seguro de errores del SDK y normalización de timestamps de Firestore.

app/api/user/search/route.ts
export async function GET(req: NextRequest) {
  try {
    await requireSession();
    const { searchParams } = new URL(req.url);
    const email = (searchParams.get('email') ?? '').trim().toLowerCase();
    const uid = (searchParams.get('uid') ?? '').trim();

    if (!email && !uid) {
      return NextResponse.json(
        { error: 'Informe ?email=... ou ?uid=...' },
        { status: 400 },
      );
    }

    // 1) Auth: user-not-found NO es 500, es resultado vacío
    let authUser;
    try {
      authUser = uid
        ? await adminAuth().getUser(uid)
        : await adminAuth().getUserByEmail(email);
    } catch (e: unknown) {
      const code = (e as { code?: string } | null)?.code;
      if (code === 'auth/user-not-found') {
        return NextResponse.json({ found: false, query: email || uid });
      }
      throw e;
    }

    // 2) Firestore: 2 reads en paralelo
    const [trainerSnap, alunoSnap] = await Promise.all([
      adminDb().collection('trainers').doc(authUser.uid).get(),
      adminDb().collection('alunos').doc(authUser.uid).get(),
    ]);

    const trainer = trainerSnap.exists ? trainerSnap.data() : null;
    const aluno = alunoSnap.exists ? alunoSnap.data() : null;

    // 3) Tipo principal: trainer / aluno / both / solo-auth
    let userType: 'trainer' | 'aluno' | 'both' | 'auth-only' = 'auth-only';
    if (trainer && aluno) userType = 'both';
    else if (trainer) userType = 'trainer';
    else if (aluno) userType = 'aluno';

    return NextResponse.json({ found: true, query: email || uid, userType /* ... */ });
  } catch (e) {
    return errResponse(e);
  }
}

En entrevista se nota: diferenciar «no encontrado» de un error real, poner llamadas independientes en paralelo, tratar errores del SDK como unknown con narrowing y normalizar timestamps propietarios — todo en ~60 líneas.

Caso anterior
Siguiente caso