Skarfit

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.

Rol: Lead mobile2025–2026Equipe de 2
FlutterRiverpod 3FirebaseClean Arch + DDDCloud FunctionsNext.js
99.6%
crash-free
−42%
tempo de inicialização
+31%
conclusão do 1º treino

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

Prazo: MVP em 6 mesesLGPD + Apple Sign in + CREF/CRNFirebase como único backendAcademia sem Wi-Fi confiávelSamsung gama média-baixaApp Check + Play Integrity

02 · Decisões técnicas

1

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).

2

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.

3

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

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

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.

main.dart
Run

Editor ao vivo (DartPad) — em breve

Em breve

04 · Resultados · antes / depois

crash-free
96.4%
99.6%
inicialização a frio (Galaxy A14)
3.2s
1.9s
conclusão do 1º treino (D1)
41%
72%
publicar um plano (trainer)
~12 min
~3 min
testes verdes
~120
938

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.

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);
  }
}

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.

Caso anterior
Próximo caso