Redesign do app de fitness com presença humana do trainer, sincronização ao vivo do plano e operação estável em academias sem sinal.
01 · Contexto e problema
A Skarfit conecta personal trainers (CREF + CRN) com alunos no Brasil. A promessa do produto: «o trainer parece presente sem precisar estar» — o aluno treina com um plano publicado pelo seu coach, vê sua voz/foto/mensagens durante a sessão, e o trainer acompanha por um dashboard B2B sem estar fisicamente na academia.
Recebi o projeto com dívida herdada que se chocava: um onboarding com rotas hardcoded (10 de 16 grupos musculares caíam silenciosamente em «Costas»), um dashboard do aluno acoplado em um arquivo de ~3.500 linhas, e uma vertical de alimentação que misturava o dia de hoje com um histórico não imutável.
O objetivo foi sustentar o app em produção enquanto se reconstruía a base — sem reescrever tudo de uma vez, com cobertura de testes e migração idempotente a partir do schema legado.
Restrições
02 · Decisões técnicas
Arquitetura
Problema
O estado do aluno vivia disperso entre setState, SharedPreferences global (vazava dados entre usuários ao trocar de conta) e providers ad-hoc; os alunos legados caíam em estados impossíveis.
Opções
setState + InheritedWidget · Provider · BLoC · Riverpod 3
Decisão ✓
Riverpod 3 + Clean Architecture com DDD pragmático: 6 bounded contexts, 3 camadas, value objects manuais e um SAD canônico com 13 ADRs vivos dentro do repo.
Trade-off
Curva de aprendizado em VOs e aggregates. Mitigado com o SAD + testes por aggregate (310+ só em Alimentação).
Sincronização trainer ↔ aluno
Problema
Publicar o plano pelo dashboard devia refletir no aluno sem interromper uma sessão ativa e com a garantia de que «publicado» só o servidor escreve.
Opções
Polling 5s · streams + cliente escreve · streams + Cloud Function única · FCM push
Decisão ✓
Streams em tempo real do Firestore + Cloud Function publishPlan como único writer do estado publicado, gateada por CREF verificado e regras do Firestore. Leituras Source.server nas rotas críticas (splash/login/onboarding).
Trade-off
+1 hop e ~60 ms ao publicar. Em troca: conformidade CREF, zero race conditions e roteamento determinístico.
Offline na academia + gama baixa
Problema
Sessões de 45 min sem rede, com foreground service para que o gerenciador de bateria não mate o processo; dados sensíveis que não podem se perder e listas longas que derrubavam os FPS.
Opções
Offline persistence nativo · SharedPreferences direta · Hive · Fila idempotente
Decisão ✓
SharedPreferences UID-scoped (sem chave global jamais), Firestore offline persistence, WorkoutForegroundService e RestAlertService com escalonamento declarativo. Catálogo de exercícios em cache na memória + listas virtualizadas.
Trade-off
Invalidação complexa → checklist obrigatório de persistência pré-PR. Freou ~15% o desenvolvimento mas cortou os bugs de «perdeu o peso anotado».
Arquitetura
03 · Migração de Alimentação para DDD por dia da semana + histórico imutável
O schema anterior guardava as refeições como uma lista global com flags «done»: servia para «o que como hoje», mas quebrava ao querer planejar por dia da semana, ver o histórico das últimas 4 semanas e marcar dias livres dentro do limite semanal.
A migração devia deixar o app em produção sem downtime, preservar o que o aluno já havia planejado e manter uma boundary limpa (outras abas leem o progresso sem tocar nos internos do módulo).
Solução: 4 aggregate roots + 5 value objects, uma migração one-shot idempotente do schema legado, e uma boundary cross-tab via um único api.dart. 310+ testes por aggregate e 7 fases em 34 commits granulares, mergeado em produção sem um bug reportado.
O critério de sucesso foi duro: que o aluno legado abrisse a nova tela e não percebesse a migração. Foi alcançado.
Demo interativa
Em breve: execute o código e veja-o rodando ao vivo, sem instalar nada.
Editor ao vivo (DartPad) — em breve
04 · Resultados · antes / depois
05 · Retrospectiva
Introduziria o checklist de persistência desde o dia 1: apareceu no mês 4 após dois incidentes de perda de dados; tê-lo antes teria poupado ~3 semanas de retrabalho.
Fecharia antes a boundary cross-tab: Alimentação ficou limpo exportando só api.dart, mas outras abas ainda se importam entre si — dívida assumida conscientemente.
Manteria o SAD único com ADRs internos e documentar o porquê de cada decisão em memória persistente: não é código, mas poupou horas entre sessões.
Código em destaque · Painel admin · busca de usuário
Endpoint do painel (Next.js) que consolida em uma única resposta o estado de um usuário no Firebase Auth e Firestore (trainers + alunos), independentemente do seu estado de aprovação. Três fontes em paralelo, distinção «não encontrado» vs erro real, narrowing seguro de erros do SDK e normalização de timestamps do Firestore.
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);
}
}Em entrevista se nota: diferenciar «não encontrado» de um erro real, colocar chamadas independentes em paralelo, tratar erros do SDK como unknown com narrowing e normalizar timestamps proprietários — tudo em ~60 linhas.